Files

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;