Files
plezy/lib/watch_together/models/sync_message.dart
T
edde746 972b62f6fa fix(watch-together): tell lobby guests the room's control mode
Guests joining an "Anyone" room saw "Host controls playback" and a
locked room until the host actually started something. Control mode
only travelled inside the host's PlaybackState broadcast, and every
broadcast path requires an active media epoch, so an idle lobby had no
carrier at all: guests sat on the joinAsGuest hostOnly default. The v1
protocol's sessionConfig message covered this; the v3 rewrite lost it.

Carry the mode on the host's join messages instead: the directed join
reply every participant already sends to a new peer, and the host's
reconnect re-announce. The field is optional on the wire ('cm'), so
older clients ignore it and rooms with older hosts degrade to the
previous behavior. Guests apply it only from the relay-derived host
peer ID, never from a join's own spoofable isHost flag.

close #1950
2026-08-16 08:48:58 +02:00

253 lines
7.4 KiB
Dart

import 'dart:convert';
import 'playback_state.dart';
import 'watch_session.dart';
/// Types of sync messages sent over the relay data channel (protocol v3).
enum SyncMessageType {
/// Authoritative playback state broadcast by the host
state,
/// A peer's player status report to the host
status,
/// A guest's playback control request to the host
control,
/// Request the current playback state from the host
requestState,
/// Participant joined the session
join,
/// Participant left the session
leave,
/// Ping for clock-offset measurement
ping,
/// Pong response
pong,
/// Host exited the video player
hostExitedPlayer,
}
/// A message sent over the relay data channel for synchronization
class SyncMessage {
/// Current sync protocol version, carried on join messages. Peers with a
/// different version are excluded from readiness gating and surfaced as
/// needing an update.
static const int protocolVersion = 3;
/// Type of this message
final SyncMessageType type;
/// Timestamp when this message was created (Unix ms). For pong messages
/// this is the responder's "clock now" used for offset estimation.
final int timestamp;
/// Peer ID of the sender
final String? peerId;
/// Display name of the sender (for join message)
final String? displayName;
/// Whether the sender is the host (for join message)
final bool? isHost;
/// Ping ID for matching pong responses
final int? pingId;
/// Authoritative playback state (for state message)
final PlaybackState? state;
/// Peer player status report (for status message)
final PeerStatus? status;
/// Playback control request (for control message)
final ControlRequest? control;
/// Sync protocol version (for join message)
final int? version;
/// Room control mode (host-sent join messages; lobby-safe carrier so
/// guests learn the mode before any playback state exists)
final ControlMode? controlMode;
const SyncMessage({
required this.type,
required this.timestamp,
this.peerId,
this.displayName,
this.isHost,
this.pingId,
this.state,
this.status,
this.control,
this.version,
this.controlMode,
});
/// Create a STATE message carrying the host's authoritative playback state
factory SyncMessage.state(PlaybackState state, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.state,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
state: state,
);
}
/// Create a STATUS message reporting this peer's player state to the host
factory SyncMessage.status(PeerStatus status, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.status,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
status: status,
);
}
/// Create a CONTROL message requesting a playback action from the host
factory SyncMessage.control(ControlRequest control, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.control,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
control: control,
);
}
/// Create a REQUEST_STATE message asking the host to re-send its state
factory SyncMessage.requestState({String? peerId}) {
return SyncMessage(
type: SyncMessageType.requestState,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
);
}
/// Create a JOIN message (carries the sender's protocol version, and the
/// room's control mode when the sender is the host)
factory SyncMessage.join({
required String peerId,
required String displayName,
required bool isHost,
ControlMode? controlMode,
}) {
return SyncMessage(
type: SyncMessageType.join,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
displayName: displayName,
isHost: isHost,
version: protocolVersion,
controlMode: controlMode,
);
}
/// Create a LEAVE message
factory SyncMessage.leave({required String peerId}) {
return SyncMessage(type: SyncMessageType.leave, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
}
/// Create a PING message
factory SyncMessage.ping(int pingId, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.ping,
timestamp: DateTime.now().millisecondsSinceEpoch,
pingId: pingId,
peerId: peerId,
);
}
/// Create a PONG message
factory SyncMessage.pong(int pingId, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.pong,
timestamp: DateTime.now().millisecondsSinceEpoch,
pingId: pingId,
peerId: peerId,
);
}
/// Create a HOST_EXITED_PLAYER message (sent by host when exiting video player)
factory SyncMessage.hostExitedPlayer({String? peerId}) {
return SyncMessage(
type: SyncMessageType.hostExitedPlayer,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
);
}
SyncMessage copyWith({String? peerId}) {
return SyncMessage(
type: type,
timestamp: timestamp,
peerId: peerId ?? this.peerId,
displayName: displayName,
isHost: isHost,
pingId: pingId,
state: state,
status: status,
control: control,
version: version,
controlMode: controlMode,
);
}
/// Serialize to JSON string for sending over the data channel
String toJson() {
final map = <String, dynamic>{'t': type.name, 'ts': timestamp};
if (peerId != null) map['pid'] = peerId;
if (displayName != null) map['name'] = displayName;
if (isHost != null) map['host'] = isHost;
if (pingId != null) map['ping'] = pingId;
if (state != null) map['st'] = state!.toMap();
if (status != null) map['su'] = status!.toMap();
if (control != null) map['co'] = control!.toMap();
if (version != null) map['v'] = version;
if (controlMode != null) map['cm'] = controlMode!.index;
return jsonEncode(map);
}
/// Parse from JSON string received from the data channel
factory SyncMessage.fromJson(String jsonString) {
final map = jsonDecode(jsonString) as Map<String, dynamic>;
final typeString = map['t'] as String;
final type =
SyncMessageType.values.asNameMap()[typeString] ?? (throw FormatException('Unknown message type: $typeString'));
return SyncMessage(
type: type,
timestamp: map['ts'] as int,
peerId: map['pid'] as String?,
displayName: map['name'] as String?,
isHost: map['host'] as bool?,
pingId: map['ping'] as int?,
state: map['st'] != null ? PlaybackState.fromMap((map['st'] as Map).cast<String, dynamic>()) : null,
status: map['su'] != null ? PeerStatus.fromMap((map['su'] as Map).cast<String, dynamic>()) : null,
control: map['co'] != null ? ControlRequest.fromMap((map['co'] as Map).cast<String, dynamic>()) : null,
version: map['v'] as int?,
controlMode: _controlModeFromIndex(map['cm'] as int?),
);
}
@override
String toString() {
return 'SyncMessage(type: $type, timestamp: $timestamp, peerId: $peerId, '
'state: $state, status: $status, control: $control)';
}
}
/// Tolerant enum-index parse: absent or out-of-range (a future mode from a
/// newer peer) reads as "unknown" rather than failing the whole message.
ControlMode? _controlModeFromIndex(int? index) {
if (index == null || index < 0 || index >= ControlMode.values.length) return null;
return ControlMode.values[index];
}