265 lines
8.3 KiB
Dart
265 lines
8.3 KiB
Dart
import '../primitives.dart';
|
|
import 'watch_session.dart';
|
|
|
|
/// Playback lifecycle phase broadcast by the host.
|
|
///
|
|
/// Serialized as the enum index — append new values only.
|
|
enum PlaybackPhase { loading, waitingForPeers, paused, playing }
|
|
|
|
/// What caused a state transition (drives participant toasts).
|
|
///
|
|
/// Serialized as the enum index — append new values only.
|
|
enum PlaybackActionHint { play, pause, seek, rate, mediaSwitch }
|
|
|
|
/// Authoritative playback state, broadcast by the host on every transition
|
|
/// and as the periodic heartbeat. Receivers keep the highest [seq] seen and
|
|
/// drop anything older, so missed or reordered messages self-heal on the
|
|
/// next broadcast.
|
|
///
|
|
/// A `phase == playing` state whose [anchorHostTimeMs] lies in the future is
|
|
/// a scheduled group start: [targetPositionMs] clamps elapsed time to >= 0,
|
|
/// so peers hold at [anchorPositionMs] until the start moment and then
|
|
/// extrapolate from a shared origin.
|
|
class PlaybackState {
|
|
final int seq;
|
|
final String ratingKey;
|
|
final String serverId;
|
|
final String? mediaTitle;
|
|
final PlaybackPhase phase;
|
|
|
|
/// Timeline-adjusted position at [anchorHostTimeMs].
|
|
final int anchorPositionMs;
|
|
|
|
/// Host wall-clock time (Unix ms) the anchor was captured — or, when in
|
|
/// the future with `phase == playing`, the scheduled group-start moment.
|
|
final int anchorHostTimeMs;
|
|
|
|
final double rate;
|
|
final ControlMode controlMode;
|
|
|
|
/// Peers the room is currently waiting on (readiness or buffering).
|
|
final List<String> waitingOn;
|
|
|
|
/// Peer that caused this transition (host's own id for local actions).
|
|
final String? actorPeerId;
|
|
final PlaybackActionHint? actionHint;
|
|
|
|
const PlaybackState({
|
|
required this.seq,
|
|
required this.ratingKey,
|
|
required this.serverId,
|
|
required this.phase,
|
|
required this.anchorPositionMs,
|
|
required this.anchorHostTimeMs,
|
|
required this.rate,
|
|
required this.controlMode,
|
|
this.mediaTitle,
|
|
this.waitingOn = const [],
|
|
this.actorPeerId,
|
|
this.actionHint,
|
|
});
|
|
|
|
String get mediaKey => mediaKeyFor(ratingKey: ratingKey, serverId: serverId);
|
|
|
|
static String mediaKeyFor({required String ratingKey, required String serverId}) => '$serverId:$ratingKey';
|
|
|
|
/// Where the room should be at [nowHostMs] (host clock).
|
|
int targetPositionMs(int nowHostMs) {
|
|
if (phase != PlaybackPhase.playing) return anchorPositionMs;
|
|
final elapsed = nowHostMs - anchorHostTimeMs;
|
|
if (elapsed <= 0) return anchorPositionMs;
|
|
return anchorPositionMs + (elapsed * rate).round();
|
|
}
|
|
|
|
PlaybackState copyWith({
|
|
int? seq,
|
|
String? ratingKey,
|
|
String? serverId,
|
|
String? mediaTitle,
|
|
PlaybackPhase? phase,
|
|
int? anchorPositionMs,
|
|
int? anchorHostTimeMs,
|
|
double? rate,
|
|
ControlMode? controlMode,
|
|
List<String>? waitingOn,
|
|
String? actorPeerId,
|
|
PlaybackActionHint? actionHint,
|
|
}) {
|
|
return PlaybackState(
|
|
seq: seq ?? this.seq,
|
|
ratingKey: ratingKey ?? this.ratingKey,
|
|
serverId: serverId ?? this.serverId,
|
|
mediaTitle: mediaTitle ?? this.mediaTitle,
|
|
phase: phase ?? this.phase,
|
|
anchorPositionMs: anchorPositionMs ?? this.anchorPositionMs,
|
|
anchorHostTimeMs: anchorHostTimeMs ?? this.anchorHostTimeMs,
|
|
rate: rate ?? this.rate,
|
|
controlMode: controlMode ?? this.controlMode,
|
|
waitingOn: waitingOn ?? this.waitingOn,
|
|
actorPeerId: actorPeerId ?? this.actorPeerId,
|
|
actionHint: actionHint ?? this.actionHint,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toMap() => {
|
|
'q': seq,
|
|
'rk': ratingKey,
|
|
'sid': serverId,
|
|
if (mediaTitle != null) 'ti': mediaTitle,
|
|
'ph': phase.index,
|
|
'ap': anchorPositionMs,
|
|
'at': anchorHostTimeMs,
|
|
'r': rate,
|
|
'cm': controlMode.index,
|
|
if (waitingOn.isNotEmpty) 'w': waitingOn,
|
|
if (actorPeerId != null) 'ab': actorPeerId,
|
|
if (actionHint != null) 'ah': actionHint!.index,
|
|
};
|
|
|
|
factory PlaybackState.fromMap(Map<String, dynamic> map) {
|
|
return PlaybackState(
|
|
seq: map['q'] as int,
|
|
ratingKey: map['rk'] as String,
|
|
serverId: map['sid'] as String,
|
|
mediaTitle: map['ti'] as String?,
|
|
phase: _enumFromIndex(PlaybackPhase.values, map['ph'] as int) ?? PlaybackPhase.paused,
|
|
anchorPositionMs: map['ap'] as int,
|
|
anchorHostTimeMs: map['at'] as int,
|
|
rate: (map['r'] as num).toDouble(),
|
|
controlMode: _enumFromIndex(ControlMode.values, map['cm'] as int) ?? ControlMode.hostOnly,
|
|
waitingOn: (map['w'] as List?)?.cast<String>() ?? const [],
|
|
actorPeerId: map['ab'] as String?,
|
|
actionHint: map['ah'] != null ? _enumFromIndex(PlaybackActionHint.values, map['ah'] as int) : null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is PlaybackState &&
|
|
other.seq == seq &&
|
|
other.ratingKey == ratingKey &&
|
|
other.serverId == serverId &&
|
|
other.mediaTitle == mediaTitle &&
|
|
other.phase == phase &&
|
|
other.anchorPositionMs == anchorPositionMs &&
|
|
other.anchorHostTimeMs == anchorHostTimeMs &&
|
|
other.rate == rate &&
|
|
other.controlMode == controlMode &&
|
|
orderedStringListsEqual(other.waitingOn, waitingOn) &&
|
|
other.actorPeerId == actorPeerId &&
|
|
other.actionHint == actionHint;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
seq,
|
|
ratingKey,
|
|
serverId,
|
|
mediaTitle,
|
|
phase,
|
|
anchorPositionMs,
|
|
anchorHostTimeMs,
|
|
rate,
|
|
controlMode,
|
|
Object.hashAll(waitingOn),
|
|
actorPeerId,
|
|
actionHint,
|
|
);
|
|
|
|
@override
|
|
String toString() =>
|
|
'PlaybackState(seq: $seq, media: $mediaKey, phase: ${phase.name}, '
|
|
'anchor: ${anchorPositionMs}ms@$anchorHostTimeMs, rate: $rate, waitingOn: $waitingOn)';
|
|
}
|
|
|
|
/// A peer's report of its own player to the host.
|
|
class PeerStatus {
|
|
/// The media this peer currently has loaded or is loading.
|
|
final String mediaKey;
|
|
|
|
/// File loaded and first frame rendered (plus startup gates cleared).
|
|
final bool ready;
|
|
final bool buffering;
|
|
final int positionMs;
|
|
|
|
/// The peer's measured min RTT to the host (sizes scheduled-start delays).
|
|
final int? rttMs;
|
|
|
|
const PeerStatus({
|
|
required this.mediaKey,
|
|
required this.ready,
|
|
required this.buffering,
|
|
required this.positionMs,
|
|
this.rttMs,
|
|
});
|
|
|
|
Map<String, dynamic> toMap() => {
|
|
'mk': mediaKey,
|
|
'rdy': ready,
|
|
'buf': buffering,
|
|
'pos': positionMs,
|
|
if (rttMs != null) 'rtt': rttMs,
|
|
};
|
|
|
|
factory PeerStatus.fromMap(Map<String, dynamic> map) => PeerStatus(
|
|
mediaKey: map['mk'] as String,
|
|
ready: map['rdy'] as bool,
|
|
buffering: map['buf'] as bool,
|
|
positionMs: map['pos'] as int,
|
|
rttMs: map['rtt'] as int?,
|
|
);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is PeerStatus &&
|
|
other.mediaKey == mediaKey &&
|
|
other.ready == ready &&
|
|
other.buffering == buffering &&
|
|
other.positionMs == positionMs &&
|
|
other.rttMs == rttMs;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(mediaKey, ready, buffering, positionMs, rttMs);
|
|
|
|
@override
|
|
String toString() => 'PeerStatus($mediaKey, ready: $ready, buffering: $buffering, pos: ${positionMs}ms)';
|
|
}
|
|
|
|
/// Serialized as the enum index — append new values only.
|
|
enum ControlRequestKind { play, pause, seek, rate }
|
|
|
|
/// A guest's request for the host to apply a playback action (anyone mode).
|
|
class ControlRequest {
|
|
final ControlRequestKind kind;
|
|
final int? positionMs;
|
|
final double? rate;
|
|
|
|
const ControlRequest({required this.kind, this.positionMs, this.rate});
|
|
|
|
Map<String, dynamic> toMap() => {
|
|
'k': kind.index,
|
|
if (positionMs != null) 'pos': positionMs,
|
|
if (rate != null) 'r': rate,
|
|
};
|
|
|
|
factory ControlRequest.fromMap(Map<String, dynamic> map) => ControlRequest(
|
|
kind: _enumFromIndex(ControlRequestKind.values, map['k'] as int) ?? ControlRequestKind.pause,
|
|
positionMs: map['pos'] as int?,
|
|
rate: (map['r'] as num?)?.toDouble(),
|
|
);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is ControlRequest && other.kind == kind && other.positionMs == positionMs && other.rate == rate;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(kind, positionMs, rate);
|
|
|
|
@override
|
|
String toString() => 'ControlRequest(${kind.name}, pos: $positionMs, rate: $rate)';
|
|
}
|
|
|
|
/// Index-safe enum decode: out-of-range values (from a newer protocol
|
|
/// version) return null instead of throwing.
|
|
T? _enumFromIndex<T extends Enum>(List<T> values, int index) =>
|
|
index >= 0 && index < values.length ? values[index] : null;
|