feat(watch-together): let the host hand the room to another participant
close #2183 Reusing a room means whoever enters first becomes the host, and moving the session to someone else's library meant everyone leaving and re-entering in the right order. The host can now promote any connected guest from the participant list (Watch Together screen and the in-player session sheet), with playback, control mode, and reconnect identities carried across. The relay owns the swap: a new `transferHost` message validates the sender is the live host and the target a connected modern-protocol guest, then swaps `HostPeerID` and the reconnect verifiers (each peer keeps its own token), persists the room, and broadcasts `hostChanged` to every peer. Clients flip roles in place: the controller swaps its role engine while keeping the session, message queue, and player attachment. A promoted guest seeds the coordinator with the room's last known intent (a paused room stays paused) and the known-peer roster so the fresh epoch re-gates instead of solo-starting; a demoted host falls back to a reconciler with a fresh clock sync and asks the new authority for state. Guests re-pin the host identity, reset their sequence, and re-converge their clocks. A rejected transfer (`not_host` / `peer_not_found`) surfaces as a toast instead of tearing the session down. Pre-transfer app builds in the room ignore `hostChanged` and stop following the room at the next transfer; targets on an old sync protocol are not offered the action.
This commit is contained in:
@@ -1587,6 +1587,13 @@
|
||||
"host": "Təşkilatçı",
|
||||
"hostBadge": "TƏŞKİLATÇI",
|
||||
"youAreHost": "Təşkilatçı sizsiniz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Başqaları ilə izlənilir",
|
||||
"endSession": "Seansı bitir",
|
||||
"leaveSession": "Seansdan çıx",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вие сте организаторът",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Гледате с други",
|
||||
"endSession": "Край на сесията",
|
||||
"leaveSession": "Напусни сесията",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Vært",
|
||||
"hostBadge": "VÆRT",
|
||||
"youAreHost": "Du er vært",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Ser med andre",
|
||||
"endSession": "Afslut session",
|
||||
"leaveSession": "Forlad session",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Du bist der Host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Mit anderen schauen",
|
||||
"endSession": "Sitzung beenden",
|
||||
"leaveSession": "Sitzung verlassen",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "You are the host",
|
||||
"makeHost": "Make host",
|
||||
"makeHostQuestion": "Transfer host?",
|
||||
"makeHostConfirm": "${name} will control playback and drive the session for everyone.",
|
||||
"transfer": "Transfer",
|
||||
"hostChangedTo": "${name} is now the host",
|
||||
"youAreNowHost": "You are now the host",
|
||||
"hostTransferFailed": "Couldn't make ${name} the host",
|
||||
"watchingWithOthers": "Watching with others",
|
||||
"endSession": "End Session",
|
||||
"leaveSession": "Leave Session",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Anfitrión",
|
||||
"hostBadge": "ANFITRIÓN",
|
||||
"youAreHost": "Eres el anfitrión",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Viendo contenido con otras personas",
|
||||
"endSession": "Finalizar sesión",
|
||||
"leaveSession": "Salir de la sesión",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Hôte",
|
||||
"hostBadge": "HÔTE",
|
||||
"youAreHost": "Vous êtes l'hôte",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Regarder avec d'autres personnes",
|
||||
"endSession": "Terminer la session",
|
||||
"leaveSession": "Quitter la session",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Házigazda",
|
||||
"hostBadge": "HÁZIGAZDA",
|
||||
"youAreHost": "Te vagy a házigazda",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Nézés másokkal",
|
||||
"endSession": "Munkamenet befejezése",
|
||||
"leaveSession": "Munkamenet elhagyása",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Sei l'host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "In visione con altri partecipanti",
|
||||
"endSession": "Termina la sessione",
|
||||
"leaveSession": "Abbandona la sessione",
|
||||
|
||||
@@ -1577,6 +1577,13 @@
|
||||
"host": "ホスト",
|
||||
"hostBadge": "ホスト",
|
||||
"youAreHost": "あなたはホストです",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "他の人と視聴中",
|
||||
"endSession": "セッションを終了",
|
||||
"leaveSession": "セッションを退出",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Ұйымдастырушы",
|
||||
"hostBadge": "ҰЙЫМДАСТЫРУШЫ",
|
||||
"youAreHost": "Сіз ұйымдастырушысыз",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Басқалармен бірге көрілуде",
|
||||
"endSession": "Сеансты аяқтау",
|
||||
"leaveSession": "Сеанстан шығу",
|
||||
|
||||
@@ -1577,6 +1577,13 @@
|
||||
"host": "호스트",
|
||||
"hostBadge": "호스트",
|
||||
"youAreHost": "호스트입니다",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "다른 사람과 함께 시청 중",
|
||||
"endSession": "세션 종료",
|
||||
"leaveSession": "세션 나가기",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Vert",
|
||||
"hostBadge": "VERT",
|
||||
"youAreHost": "Du er verten",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Ser med andre",
|
||||
"endSession": "Avslutt økt",
|
||||
"leaveSession": "Forlat økt",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Jij bent de host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Kijken met anderen",
|
||||
"endSession": "Sessie beëindigen",
|
||||
"leaveSession": "Sessie verlaten",
|
||||
|
||||
@@ -1607,6 +1607,13 @@
|
||||
"host": "Gospodarz",
|
||||
"hostBadge": "GOSPODARZ",
|
||||
"youAreHost": "Jesteś gospodarzem",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Oglądasz z innymi",
|
||||
"endSession": "Zakończ sesję",
|
||||
"leaveSession": "Opuść sesję",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Anfitrião",
|
||||
"hostBadge": "ANFITRIÃO",
|
||||
"youAreHost": "Você é o anfitrião",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Assistindo com outras pessoas",
|
||||
"endSession": "Encerrar sessão",
|
||||
"leaveSession": "Sair da sessão",
|
||||
|
||||
@@ -1607,6 +1607,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вы — организатор",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Совместный просмотр",
|
||||
"endSession": "Завершить сессию",
|
||||
"leaveSession": "Покинуть сессию",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 43738 (1988 per locale)
|
||||
/// Strings: 43745 (1988 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -4022,6 +4022,27 @@ class Translations$watchTogether$en {
|
||||
/// en: 'You are the host'
|
||||
String get youAreHost => 'You are the host';
|
||||
|
||||
/// en: 'Make host'
|
||||
String get makeHost => 'Make host';
|
||||
|
||||
/// en: 'Transfer host?'
|
||||
String get makeHostQuestion => 'Transfer host?';
|
||||
|
||||
/// en: '${name} will control playback and drive the session for everyone.'
|
||||
String makeHostConfirm({required Object name}) => '${name} will control playback and drive the session for everyone.';
|
||||
|
||||
/// en: 'Transfer'
|
||||
String get transfer => 'Transfer';
|
||||
|
||||
/// en: '${name} is now the host'
|
||||
String hostChangedTo({required Object name}) => '${name} is now the host';
|
||||
|
||||
/// en: 'You are now the host'
|
||||
String get youAreNowHost => 'You are now the host';
|
||||
|
||||
/// en: 'Couldn't make ${name} the host'
|
||||
String hostTransferFailed({required Object name}) => 'Couldn\'t make ${name} the host';
|
||||
|
||||
/// en: 'Watching with others'
|
||||
String get watchingWithOthers => 'Watching with others';
|
||||
|
||||
@@ -8354,6 +8375,13 @@ extension on Translations {
|
||||
'watchTogether.host' => 'Host',
|
||||
'watchTogether.hostBadge' => 'HOST',
|
||||
'watchTogether.youAreHost' => 'You are the host',
|
||||
'watchTogether.makeHost' => 'Make host',
|
||||
'watchTogether.makeHostQuestion' => 'Transfer host?',
|
||||
'watchTogether.makeHostConfirm' => ({required Object name}) => '${name} will control playback and drive the session for everyone.',
|
||||
'watchTogether.transfer' => 'Transfer',
|
||||
'watchTogether.hostChangedTo' => ({required Object name}) => '${name} is now the host',
|
||||
'watchTogether.youAreNowHost' => 'You are now the host',
|
||||
'watchTogether.hostTransferFailed' => ({required Object name}) => 'Couldn\'t make ${name} the host',
|
||||
'watchTogether.watchingWithOthers' => 'Watching with others',
|
||||
'watchTogether.endSession' => 'End Session',
|
||||
'watchTogether.leaveSession' => 'Leave Session',
|
||||
@@ -8453,6 +8481,8 @@ extension on Translations {
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.',
|
||||
'downloads.removeListSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"?',
|
||||
'downloads.deleteSyncRuleDownloads' => 'Also delete associated downloads',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteSyncRuleDownloadsDescription' => 'Downloads used by another sync rule or profile will be kept.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes',
|
||||
'downloads.syncRuleUpdated' => 'Sync rule updated',
|
||||
@@ -8460,8 +8490,6 @@ extension on Translations {
|
||||
'downloads.syncRuleAndDownloadsRemoved' => 'Sync rule and associated downloads removed',
|
||||
'downloads.syncRuleCleanupBusy' => 'Sync rules are currently updating. Try again in a moment.',
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
|
||||
'downloads.activeSyncRules' => 'Sync rules',
|
||||
'downloads.noSyncRules' => 'No sync rules',
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Värd",
|
||||
"hostBadge": "VÄRD",
|
||||
"youAreHost": "Du är värden",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Tittar med andra",
|
||||
"endSession": "Avsluta session",
|
||||
"leaveSession": "Lämna session",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Kurucu",
|
||||
"hostBadge": "KURUCU",
|
||||
"youAreHost": "Kurucu sizsiniz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Başkalarıyla birlikte izleniyor",
|
||||
"endSession": "Oturumu Bitir",
|
||||
"leaveSession": "Oturumdan Ayrıl",
|
||||
|
||||
@@ -1587,6 +1587,13 @@
|
||||
"host": "Tashkilotchi",
|
||||
"hostBadge": "TASHKILOTCHI",
|
||||
"youAreHost": "Siz tashkilotchisiz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Boshqalar bilan tomosha qilinmoqda",
|
||||
"endSession": "Seansni yakunlash",
|
||||
"leaveSession": "Seansdan chiqish",
|
||||
|
||||
@@ -1577,6 +1577,13 @@
|
||||
"host": "主持人",
|
||||
"hostBadge": "主持人",
|
||||
"youAreHost": "您是主持人",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "與他人一起觀看",
|
||||
"endSession": "結束工作階段",
|
||||
"leaveSession": "離開工作階段",
|
||||
|
||||
@@ -1577,6 +1577,13 @@
|
||||
"host": "主持人",
|
||||
"hostBadge": "主持人",
|
||||
"youAreHost": "你是主持人",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "与他人一起观看",
|
||||
"endSession": "结束会话",
|
||||
"leaveSession": "离开会话",
|
||||
|
||||
@@ -72,6 +72,10 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
bool _isWaitingForHostReconnect = false;
|
||||
bool _hostIntentionallyLeft = false;
|
||||
|
||||
// Display name of the guest a host transfer was requested for; names the
|
||||
// failure toast if the relay rejects the request.
|
||||
String? _pendingTransferTargetName;
|
||||
|
||||
// Debounce map for action events (peerId+type → last emission timestamp)
|
||||
final Map<String, int> _lastActionEventMs = {};
|
||||
|
||||
@@ -100,6 +104,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
StreamSubscription<PeerError>? _errorSubscription;
|
||||
StreamSubscription<void>? _sessionEndedSubscription;
|
||||
StreamSubscription<String>? _hostChangedSubscription;
|
||||
|
||||
// Getters
|
||||
bool get isInSession => _session != null;
|
||||
@@ -508,11 +513,13 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_observeSubscriptionCancellation(_messageSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_errorSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_sessionEndedSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_hostChangedSubscription?.cancel());
|
||||
_peerConnectedSubscription = null;
|
||||
_peerDisconnectedSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_errorSubscription = null;
|
||||
_sessionEndedSubscription = null;
|
||||
_hostChangedSubscription = null;
|
||||
|
||||
_hostReconnectTimer?.cancel();
|
||||
_hostReconnectTimer = null;
|
||||
@@ -529,6 +536,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_playbackPhase = null;
|
||||
_playbackDispatcher.reset();
|
||||
_lastActionEventMs.clear();
|
||||
_pendingTransferTargetName = null;
|
||||
_hostIntentionallyLeft = false;
|
||||
|
||||
if (!_disposed) notifyListeners();
|
||||
@@ -689,6 +697,19 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
appLogger.d('WatchTogether: Declared host is not connected yet; keeping the retained-room join pending');
|
||||
return;
|
||||
}
|
||||
if (error.serverCode == RelayProtocol.notHostCode || error.serverCode == RelayProtocol.peerNotFoundCode) {
|
||||
// A rejected host transfer is not a session failure — surface a toast
|
||||
// and keep the room connected.
|
||||
appLogger.w('WatchTogether: Host transfer rejected: ${error.message}');
|
||||
final targetName = _pendingTransferTargetName;
|
||||
_pendingTransferTargetName = null;
|
||||
if (targetName != null) {
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: targetName, type: ParticipantEventType.hostTransferFailed),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
||||
|
||||
// Only established transport stream errors are recoverable. Relay and
|
||||
@@ -713,6 +734,10 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
});
|
||||
_hostChangedSubscription = peerService.onHostChanged.listen((newHostPeerId) {
|
||||
if (_disposed || !identical(_peerService, peerService)) return;
|
||||
_handleHostChanged(newHostPeerId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle incoming sync messages for participant management
|
||||
@@ -878,6 +903,79 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether the current user (as host) may hand host authority to
|
||||
/// [participant]: connected guest speaking the current sync protocol.
|
||||
bool canTransferHostTo(Participant participant) {
|
||||
final peerService = _peerService;
|
||||
final controller = _controller;
|
||||
if (peerService == null || controller == null) return false;
|
||||
if (!isHost || !isConnected) return false;
|
||||
if (participant.isHost || participant.peerId == peerService.myPeerId) return false;
|
||||
if (!peerService.connectedPeers.contains(participant.peerId)) return false;
|
||||
return controller.isPeerCompatible(participant.peerId);
|
||||
}
|
||||
|
||||
/// Ask the relay to make [participant] the host (host only). Roles flip
|
||||
/// when the relay's `hostChanged` broadcast arrives; a rejection surfaces
|
||||
/// as a [ParticipantEventType.hostTransferFailed] event.
|
||||
void transferHost(Participant participant) {
|
||||
if (!canTransferHostTo(participant)) {
|
||||
appLogger.w('WatchTogether: Ignoring host transfer to ineligible peer ${participant.peerId}');
|
||||
return;
|
||||
}
|
||||
appLogger.d('WatchTogether: Requesting host transfer to ${participant.peerId}');
|
||||
_pendingTransferTargetName = participant.displayName;
|
||||
_peerService!.transferHost(participant.peerId);
|
||||
}
|
||||
|
||||
/// The relay reassigned host authority ([peerService] already flipped its
|
||||
/// own role state): rebuild session/participants and swap the controller's
|
||||
/// role engine in place.
|
||||
void _handleHostChanged(String newHostPeerId) {
|
||||
final session = _session;
|
||||
final peerService = _peerService;
|
||||
if (session == null || peerService == null) return;
|
||||
|
||||
final wasHost = session.isHost;
|
||||
final amHost = newHostPeerId == peerService.myPeerId;
|
||||
_pendingTransferTargetName = null;
|
||||
|
||||
// Any host-departure bookkeeping referred to the previous host.
|
||||
_cancelHostReconnectGracePeriod();
|
||||
_hostIntentionallyLeft = false;
|
||||
|
||||
final updated = session.copyWith(role: amHost ? SessionRole.host : SessionRole.guest, hostPeerId: newHostPeerId);
|
||||
_session = updated;
|
||||
|
||||
for (var i = 0; i < _participants.length; i++) {
|
||||
final isHostNow = _participants[i].peerId == newHostPeerId;
|
||||
if (_participants[i].isHost != isHostNow) {
|
||||
_participants[i] = _participants[i].copyWith(isHost: isHostNow);
|
||||
}
|
||||
}
|
||||
|
||||
_controller?.applyHostChange(updated);
|
||||
|
||||
if (amHost && !wasHost) {
|
||||
// Re-announce as host — the lobby-safe carrier that teaches guests the
|
||||
// room's control mode now comes from this peer.
|
||||
_controller?.announceJoin(_displayName);
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: _displayName, type: ParticipantEventType.becameHost),
|
||||
);
|
||||
} else if (!amHost) {
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(
|
||||
displayName: _displayNameForPeer(newHostPeerId) ?? '?',
|
||||
type: ParticipantEventType.hostChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Host changed to $newHostPeerId (self: $amHost)');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Notify guests that host is exiting the video player
|
||||
///
|
||||
/// Call this from video player dispose when host exits.
|
||||
@@ -961,7 +1059,19 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Type of participant event
|
||||
enum ParticipantEventType { joined, left, paused, resumed, seeked, buffering, needsUpdate, resumedWithout }
|
||||
enum ParticipantEventType {
|
||||
joined,
|
||||
left,
|
||||
paused,
|
||||
resumed,
|
||||
seeked,
|
||||
buffering,
|
||||
needsUpdate,
|
||||
resumedWithout,
|
||||
hostChanged,
|
||||
becameHost,
|
||||
hostTransferFailed,
|
||||
}
|
||||
|
||||
/// Event emitted when a participant joins or leaves
|
||||
class ParticipantEvent {
|
||||
|
||||
@@ -606,7 +606,13 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(participant.displayName, style: theme.textTheme.bodyMedium),
|
||||
Flexible(
|
||||
child: Text(
|
||||
participant.displayName,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (participant.isHost) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
@@ -629,6 +635,17 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
|
||||
),
|
||||
],
|
||||
if (watchTogether.canTransferHostTo(participant)) ...[
|
||||
const Spacer(),
|
||||
FocusableButton(
|
||||
useBackgroundFocus: true,
|
||||
onPressed: () => _confirmTransferHost(context, participant),
|
||||
child: TextButton(
|
||||
onPressed: () => _confirmTransferHost(context, participant),
|
||||
child: Text(t.watchTogether.makeHost),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -682,6 +699,17 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
appLogger.e('WatchTogether: Session leave failed', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmTransferHost(BuildContext context, Participant participant) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.watchTogether.makeHostQuestion,
|
||||
message: t.watchTogether.makeHostConfirm(name: participant.displayName),
|
||||
confirmText: t.watchTogether.transfer,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
watchTogether.transferHost(participant);
|
||||
}
|
||||
}
|
||||
|
||||
class _JoinCurrentPlaybackCard extends StatefulWidget {
|
||||
|
||||
@@ -83,6 +83,24 @@ class ClockSync {
|
||||
_pending.clear();
|
||||
}
|
||||
|
||||
/// Discard every sample and in-flight ping (the peer the offset was
|
||||
/// measured against changed — e.g. a host transfer) and re-converge
|
||||
/// against the current host with a fresh burst.
|
||||
void reset() {
|
||||
_samples.clear();
|
||||
if (_started) {
|
||||
_started = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_burstTimer?.cancel();
|
||||
_burstTimer = null;
|
||||
_pending.clear();
|
||||
start();
|
||||
} else {
|
||||
_pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _ping() {
|
||||
final now = _nowMs();
|
||||
_pending.removeWhere((_, sentAt) => now - sentAt > _pendingExpiryMs);
|
||||
|
||||
@@ -113,6 +113,11 @@ class GuestPlaybackReconciler {
|
||||
|
||||
PlaybackState? get latestState => _latestState;
|
||||
|
||||
/// Whether the attached player has rendered a frame — carried across a
|
||||
/// host-transfer engine swap so the promoted coordinator doesn't wait for
|
||||
/// a first frame that already happened.
|
||||
bool get firstFrameSeen => _firstFrameSeen;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -111,6 +111,11 @@ class HostPlaybackCoordinator {
|
||||
|
||||
PlaybackPhase get phase => _phase;
|
||||
|
||||
/// Whether the attached player has reported readiness — carried across a
|
||||
/// host-transfer engine swap so the demoted reconciler doesn't wait for a
|
||||
/// first frame that already happened.
|
||||
bool get localPlayerReady => _localReady;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -146,18 +151,25 @@ class HostPlaybackCoordinator {
|
||||
/// screen's first-frame snapshot (covers attaching to an already-rendering
|
||||
/// player); [startupHold] delays readiness past platform startup gates
|
||||
/// (e.g. the Android frame-rate switch).
|
||||
///
|
||||
/// [intendPlaying] seeds the room's play intent for the new epoch. The
|
||||
/// default (true) is the normal "opening media implies play" flow; a host
|
||||
/// promoted mid-session passes the room's last known intent so a paused
|
||||
/// room doesn't start playing just because its host changed.
|
||||
void attach(
|
||||
AttachedPlayer player, {
|
||||
required String ratingKey,
|
||||
required String serverId,
|
||||
String? mediaTitle,
|
||||
bool hasFirstFrame = false,
|
||||
bool intendPlaying = true,
|
||||
Future<void>? startupHold,
|
||||
}) {
|
||||
detachPlayer();
|
||||
final sameEpoch =
|
||||
hasActiveEpoch && PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId) == _mediaKey;
|
||||
setLocalMedia(ratingKey: ratingKey, serverId: serverId, mediaTitle: mediaTitle);
|
||||
_intendedPlaying = intendPlaying;
|
||||
|
||||
_player = player;
|
||||
_rate = player.rate;
|
||||
|
||||
@@ -11,6 +11,7 @@ abstract final class RelayProtocol {
|
||||
static const String ping = 'ping';
|
||||
static const String leave = 'leave';
|
||||
static const String endSession = 'endSession';
|
||||
static const String transferHost = 'transferHost';
|
||||
static const String created = 'created';
|
||||
static const String joined = 'joined';
|
||||
static const String peerJoined = 'peerJoined';
|
||||
@@ -20,6 +21,7 @@ abstract final class RelayProtocol {
|
||||
static const String pong = 'pong';
|
||||
static const String left = 'left';
|
||||
static const String ended = 'ended';
|
||||
static const String hostChanged = 'hostChanged';
|
||||
static const String rateLimitedCode = 'rate_limited';
|
||||
static const String invalidMessageCode = 'invalid_message';
|
||||
static const String roomExistsCode = 'room_exists';
|
||||
@@ -29,6 +31,8 @@ abstract final class RelayProtocol {
|
||||
static const String alreadyInRoomCode = 'already_in_room';
|
||||
static const String peerIdUnavailableCode = 'peer_id_unavailable';
|
||||
static const String protocolMismatchCode = 'protocol_mismatch';
|
||||
static const String notHostCode = 'not_host';
|
||||
static const String peerNotFoundCode = 'peer_not_found';
|
||||
|
||||
static const int maxRoomSize = 8;
|
||||
static const int maxMessageSize = 65536;
|
||||
|
||||
@@ -31,37 +31,45 @@ class WatchTogetherController {
|
||||
_session = session,
|
||||
_nowMs = nowMs ?? watchTogetherSystemNowMs {
|
||||
if (session.isHost) {
|
||||
_coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: peerService.myPeerId ?? '',
|
||||
controlMode: session.controlMode,
|
||||
sendState: _sendState,
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onResumedWithout: (peers) => onResumedWithout?.call(peers),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_createCoordinator();
|
||||
} else {
|
||||
_clockSync = ClockSync(sendPing: _sendClockPing, nowMs: _nowMs);
|
||||
_reconciler = GuestPlaybackReconciler(
|
||||
myPeerId: peerService.myPeerId ?? '',
|
||||
sendToHost: _sendToHost,
|
||||
clockSync: _clockSync!,
|
||||
onMediaSwitchNeeded: (ratingKey, serverId, title) => onMediaStateReceived?.call(ratingKey, serverId, title),
|
||||
onControlModeChanged: (mode) => onControlModeReceived?.call(mode),
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onCorrectingChanged: (correcting) => onCorrectingChanged?.call(correcting),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_clockSync!.start();
|
||||
_createReconciler();
|
||||
}
|
||||
|
||||
_subscriptions.add(peerService.onMessageReceived.listen(_enqueueMessage));
|
||||
_subscriptions.add(peerService.onPeerDisconnected.listen(_handlePeerDisconnected));
|
||||
}
|
||||
|
||||
void _createCoordinator() {
|
||||
_coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: _peerService.myPeerId ?? '',
|
||||
controlMode: _session.controlMode,
|
||||
sendState: _sendState,
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onResumedWithout: (peers) => onResumedWithout?.call(peers),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _createReconciler() {
|
||||
_clockSync = ClockSync(sendPing: _sendClockPing, nowMs: _nowMs);
|
||||
_reconciler = GuestPlaybackReconciler(
|
||||
myPeerId: _peerService.myPeerId ?? '',
|
||||
sendToHost: _sendToHost,
|
||||
clockSync: _clockSync!,
|
||||
onMediaSwitchNeeded: (ratingKey, serverId, title) => onMediaStateReceived?.call(ratingKey, serverId, title),
|
||||
onControlModeChanged: (mode) => onControlModeReceived?.call(mode),
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onCorrectingChanged: (correcting) => onCorrectingChanged?.call(correcting),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_clockSync!.start();
|
||||
}
|
||||
|
||||
final WatchTogetherPeerService _peerService;
|
||||
final int Function() _nowMs;
|
||||
WatchSession _session;
|
||||
@@ -71,6 +79,9 @@ class WatchTogetherController {
|
||||
ClockSync? _clockSync;
|
||||
|
||||
AttachedPlayer? _attachedPlayer;
|
||||
String? _attachedRatingKey;
|
||||
String? _attachedServerId;
|
||||
String? _attachedMediaTitle;
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
Future<void> _messageQueue = Future.value();
|
||||
bool _disposed = false;
|
||||
@@ -100,6 +111,78 @@ class WatchTogetherController {
|
||||
_coordinator?.updateControlMode(session.controlMode);
|
||||
}
|
||||
|
||||
/// Whether [peerId] speaks the current sync protocol (drives host-transfer
|
||||
/// eligibility — a peer on an old protocol can't take over the room).
|
||||
bool isPeerCompatible(String peerId) => (_peerVersions[peerId] ?? 1) == SyncMessage.protocolVersion;
|
||||
|
||||
/// The relay reassigned host authority: swap the role engine while keeping
|
||||
/// the session, message queue, peer knowledge, and player attachment.
|
||||
///
|
||||
/// [session] already carries the new role and host peer ID.
|
||||
void applyHostChange(WatchSession session) {
|
||||
final wasHost = _session.isHost;
|
||||
_session = session;
|
||||
|
||||
if (wasHost == session.isHost) {
|
||||
if (!session.isHost) {
|
||||
// Still a guest, but the authority (and its clock) moved: discard
|
||||
// offsets and sequence numbering learned from the old host and
|
||||
// re-converge against the new one.
|
||||
_clockSync?.reset();
|
||||
_reconciler?.resetSequence();
|
||||
requestState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final attached = _attachedPlayer;
|
||||
if (session.isHost) {
|
||||
// Promotion: adopt the room where the old host left it.
|
||||
final lastState = _reconciler?.latestState;
|
||||
final firstFrameSeen = _reconciler?.firstFrameSeen ?? false;
|
||||
_clockSync?.stop();
|
||||
_clockSync = null;
|
||||
_reconciler?.dispose();
|
||||
_reconciler = null;
|
||||
_createCoordinator();
|
||||
if (attached != null && _attachedRatingKey != null && _attachedServerId != null) {
|
||||
_coordinator!.attach(
|
||||
attached,
|
||||
ratingKey: _attachedRatingKey!,
|
||||
serverId: _attachedServerId!,
|
||||
mediaTitle: _attachedMediaTitle,
|
||||
hasFirstFrame: firstFrameSeen,
|
||||
// A paused room must not start playing just because its host
|
||||
// changed; anything else (playing, a stall, mid-load) carries the
|
||||
// intent to (re)start.
|
||||
intendPlaying: lastState == null || lastState.phase != PlaybackPhase.paused,
|
||||
);
|
||||
}
|
||||
// Seed the roster so the fresh epoch gates on the peers we already
|
||||
// know instead of solo-starting before their first status report.
|
||||
for (final entry in _peerVersions.entries) {
|
||||
if (entry.key == _peerService.myPeerId) continue;
|
||||
_coordinator!.onPeerJoined(entry.key, compatible: entry.value == SyncMessage.protocolVersion);
|
||||
}
|
||||
} else {
|
||||
// Demotion: hand the room to the new host and fall in line.
|
||||
final localReady = _coordinator?.localPlayerReady ?? false;
|
||||
_coordinator?.dispose();
|
||||
_coordinator = null;
|
||||
_createReconciler();
|
||||
if (attached != null && _attachedRatingKey != null && _attachedServerId != null) {
|
||||
_reconciler!.attach(
|
||||
attached,
|
||||
ratingKey: _attachedRatingKey!,
|
||||
serverId: _attachedServerId!,
|
||||
hasFirstFrame: localReady,
|
||||
);
|
||||
}
|
||||
requestState();
|
||||
}
|
||||
appLogger.d('WatchTogether: Host change applied (host: ${session.isHost})');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Player attachment
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -131,6 +214,9 @@ class WatchTogetherController {
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_attachedPlayer = attached;
|
||||
_attachedRatingKey = ratingKey;
|
||||
_attachedServerId = serverId;
|
||||
_attachedMediaTitle = mediaTitle;
|
||||
|
||||
if (_session.isHost) {
|
||||
_coordinator!.attach(
|
||||
@@ -159,6 +245,9 @@ class WatchTogetherController {
|
||||
final attached = _attachedPlayer;
|
||||
if (attached == null) return;
|
||||
_attachedPlayer = null;
|
||||
_attachedRatingKey = null;
|
||||
_attachedServerId = null;
|
||||
_attachedMediaTitle = null;
|
||||
_coordinator?.detachPlayer(exiting: exiting);
|
||||
_reconciler?.detachPlayer();
|
||||
unawaited(
|
||||
|
||||
@@ -82,6 +82,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<bool>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
final _hostChangedController = StreamController<String>.broadcast();
|
||||
|
||||
// Reconnection state
|
||||
int _reconnectAttempts = 0;
|
||||
@@ -124,6 +125,11 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
/// Emitted when the host has durably ended the relay room.
|
||||
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||
|
||||
/// Emitted with the new host's peer ID when the relay reassigns host
|
||||
/// authority ([transferHost]). [hostPeerId] and [isHost] are already
|
||||
/// updated when this fires.
|
||||
Stream<String> get onHostChanged => _hostChangedController.stream;
|
||||
|
||||
/// Current session ID (null if not in a session)
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
@@ -489,6 +495,20 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_failSetup(_invalidSetupResponse(RelayProtocol.ended));
|
||||
}
|
||||
|
||||
case RelayProtocol.hostChanged:
|
||||
final newHostPeerId = msg['hostPeerId'];
|
||||
if (msg['sessionId'] != _sessionId ||
|
||||
newHostPeerId is! String ||
|
||||
!RelayProtocol.isValidPeerId(newHostPeerId)) {
|
||||
appLogger.w('WatchTogether: Relay returned an invalid hostChanged message');
|
||||
break;
|
||||
}
|
||||
if (newHostPeerId == _hostPeerId) break; // Duplicate delivery.
|
||||
appLogger.d('WatchTogether: Host authority moved to $newHostPeerId');
|
||||
_hostPeerId = newHostPeerId;
|
||||
_isHost = newHostPeerId == _myPeerId;
|
||||
_safeAdd(_hostChangedController, newHostPeerId);
|
||||
|
||||
case RelayProtocol.error:
|
||||
final code = msg['code'] as String? ?? 'unknown';
|
||||
final message = msg['message'] as String? ?? t.common.unknown;
|
||||
@@ -773,6 +793,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload});
|
||||
}
|
||||
|
||||
/// Ask the relay to reassign host authority to [peerId] (host only).
|
||||
///
|
||||
/// The relay answers with a `hostChanged` broadcast on success or a
|
||||
/// `not_host`/`peer_not_found` error on the error stream; local role state
|
||||
/// only flips when the broadcast arrives.
|
||||
void transferHost(String peerId) {
|
||||
if (!RelayProtocol.isValidPeerId(peerId)) {
|
||||
throw ArgumentError.value(peerId, 'peerId', 'Must be 1–${RelayProtocol.maxPeerIdLength} letters, digits, _ or -');
|
||||
}
|
||||
_sendRaw({'type': RelayProtocol.transferHost, 'to': peerId, 'protocolVersion': _relayProtocolVersion});
|
||||
}
|
||||
|
||||
/// Explicitly release this peer's relay ownership. Hosts destroy the room;
|
||||
/// guests release their reserved reconnect identity. If transport was lost,
|
||||
/// authenticate a fresh connection first so an intentional exit is not
|
||||
@@ -881,6 +913,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_disposed = true;
|
||||
unawaited(disconnect());
|
||||
|
||||
_hostChangedController.close();
|
||||
_peerConnectedController.close();
|
||||
_peerDisconnectedController.close();
|
||||
_messageReceivedController.close();
|
||||
|
||||
@@ -215,31 +215,25 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
Text(t.watchTogether.participants, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
for (final participant in provider.participants)
|
||||
ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: participant.isHost
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
child: AppIcon(
|
||||
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
if (provider.canTransferHostTo(participant))
|
||||
FocusableListTile(
|
||||
leading: _participantAvatar(theme, participant),
|
||||
title: Text(participant.displayName),
|
||||
trailing:
|
||||
_bufferingIndicator(participant) ??
|
||||
AppIcon(Symbols.star_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
onTap: () => unawaited(_confirmTransferHost(context, participant)),
|
||||
contentPadding: .zero,
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: _participantAvatar(theme, participant),
|
||||
title: Text(participant.displayName),
|
||||
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
|
||||
trailing: _bufferingIndicator(participant),
|
||||
dense: true,
|
||||
contentPadding: .zero,
|
||||
),
|
||||
title: Text(participant.displayName),
|
||||
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
|
||||
trailing: participant.isBuffering
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: PlatformDetector.isTV()
|
||||
? const AppIcon(Symbols.hourglass_empty_rounded, size: 16)
|
||||
: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
dense: true,
|
||||
contentPadding: .zero,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
@@ -259,6 +253,40 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _participantAvatar(ThemeData theme, Participant participant) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: participant.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
|
||||
child: AppIcon(
|
||||
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _bufferingIndicator(Participant participant) {
|
||||
if (!participant.isBuffering) return null;
|
||||
return SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: PlatformDetector.isTV()
|
||||
? const AppIcon(Symbols.hourglass_empty_rounded, size: 16)
|
||||
: const CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmTransferHost(BuildContext context, Participant participant) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.watchTogether.makeHostQuestion,
|
||||
message: t.watchTogether.makeHostConfirm(name: participant.displayName),
|
||||
confirmText: t.watchTogether.transfer,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
provider.transferHost(participant);
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
}
|
||||
|
||||
void _copySessionCode(BuildContext context, String sessionId) {
|
||||
Clipboard.setData(ClipboardData(text: sessionId));
|
||||
showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied);
|
||||
@@ -351,6 +379,9 @@ class _ParticipantNotificationOverlayState extends State<ParticipantNotification
|
||||
ParticipantEventType.buffering => t.watchTogether.participantBuffering(name: n.event.displayName),
|
||||
ParticipantEventType.needsUpdate => t.watchTogether.participantNeedsUpdate(name: n.event.displayName),
|
||||
ParticipantEventType.resumedWithout => t.watchTogether.resumingWithout(name: n.event.displayName),
|
||||
ParticipantEventType.hostChanged => t.watchTogether.hostChangedTo(name: n.event.displayName),
|
||||
ParticipantEventType.becameHost => t.watchTogether.youAreNowHost,
|
||||
ParticipantEventType.hostTransferFailed => t.watchTogether.hostTransferFailed(name: n.event.displayName),
|
||||
};
|
||||
return Container(
|
||||
key: ValueKey(n.id),
|
||||
|
||||
+7
-3
@@ -8,7 +8,8 @@
|
||||
"sendTo": "sendTo",
|
||||
"ping": "ping",
|
||||
"leave": "leave",
|
||||
"endSession": "endSession"
|
||||
"endSession": "endSession",
|
||||
"transferHost": "transferHost"
|
||||
},
|
||||
"serverMessageTypes": {
|
||||
"created": "created",
|
||||
@@ -19,7 +20,8 @@
|
||||
"error": "error",
|
||||
"pong": "pong",
|
||||
"left": "left",
|
||||
"ended": "ended"
|
||||
"ended": "ended",
|
||||
"hostChanged": "hostChanged"
|
||||
},
|
||||
"errorCodes": {
|
||||
"rateLimited": "rate_limited",
|
||||
@@ -30,7 +32,9 @@
|
||||
"notInRoom": "not_in_room",
|
||||
"alreadyInRoom": "already_in_room",
|
||||
"peerIdUnavailable": "peer_id_unavailable",
|
||||
"protocolMismatch": "protocol_mismatch"
|
||||
"protocolMismatch": "protocol_mismatch",
|
||||
"notHost": "not_host",
|
||||
"peerNotFound": "peer_not_found"
|
||||
},
|
||||
"limits": {
|
||||
"maxRoomSize": 8,
|
||||
|
||||
@@ -2278,6 +2278,80 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
guest.close()
|
||||
}
|
||||
|
||||
case relayTypeTransferHost:
|
||||
if currentRoom == nil {
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotInRoom, Message: "Not in a room"})
|
||||
continue
|
||||
}
|
||||
room := currentRoom
|
||||
room.mu.Lock()
|
||||
authorized :=
|
||||
!room.closing &&
|
||||
room.Peers[currentPeerID] == client &&
|
||||
msg.ProtocolVersion == room.ProtocolVersion
|
||||
if !authorized {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerIdUnavailable, Message: "Unable to transfer host authority"})
|
||||
continue
|
||||
}
|
||||
if currentPeerID != room.HostPeerID {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotHost, Message: "Only the host can transfer host authority"})
|
||||
continue
|
||||
}
|
||||
if room.ProtocolVersion != relayProtocolVersion {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Host transfer requires current protocol"})
|
||||
continue
|
||||
}
|
||||
targetPeerID := msg.To
|
||||
targetConnected := false
|
||||
if validRelayID(targetPeerID, maxPeerIDLength) && targetPeerID != currentPeerID {
|
||||
_, targetConnected = room.Peers[targetPeerID]
|
||||
}
|
||||
if !targetConnected {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerNotFound, Message: "Target peer not found"})
|
||||
continue
|
||||
}
|
||||
targetReservation, targetReserved := room.peerReservations[targetPeerID]
|
||||
if !targetReserved || targetReservation.releasePending {
|
||||
room.mu.Unlock()
|
||||
if !targetReserved {
|
||||
// Connected modern-room guests always hold a reservation.
|
||||
log.Printf("transferHost: connected guest missing reservation")
|
||||
}
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerNotFound, Message: "Target peer not found"})
|
||||
continue
|
||||
}
|
||||
|
||||
// Swap authority. The host never holds a peer reservation (snapshot
|
||||
// loading rejects that), so the target's reservation becomes the
|
||||
// host verifier and the old host gains a connected reservation.
|
||||
// Reconnect tokens are untouched: both peers keep their own.
|
||||
oldHostPeerID := currentPeerID
|
||||
delete(room.peerReservations, targetPeerID)
|
||||
room.peerReservations[oldHostPeerID] = peerReservation{verifier: room.hostVerifier}
|
||||
room.hostVerifier = targetReservation.verifier
|
||||
room.HostPeerID = targetPeerID
|
||||
room.LastActivityAt = time.Now()
|
||||
recipients := make([]*Client, 0, len(room.Peers))
|
||||
for _, peerClient := range room.Peers {
|
||||
recipients = append(recipients, peerClient)
|
||||
}
|
||||
hostChanged := serverMsg{
|
||||
Type: relayTypeHostChanged,
|
||||
SessionID: room.SessionID,
|
||||
HostPeerID: targetPeerID,
|
||||
From: oldHostPeerID,
|
||||
}
|
||||
s.snap.recordMutation()
|
||||
room.mu.Unlock()
|
||||
|
||||
for _, peerClient := range recipients {
|
||||
peerClient.sendJSON(hostChanged)
|
||||
}
|
||||
|
||||
case relayTypeBroadcast:
|
||||
if currentRoom == nil {
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotInRoom, Message: "Not in a room"})
|
||||
|
||||
@@ -5294,6 +5294,271 @@ func TestAuthenticatedModernHostEndDeletesRoomAndIsRetrySafe(t *testing.T) {
|
||||
retry.expectError(relayErrorRoomNotFound)
|
||||
}
|
||||
|
||||
func TestTransferHostSwapsAuthorityAndBroadcastsHostChanged(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
hostToken, _ := mustReconnectToken(t)
|
||||
host := h.dial(t, "6.3.0.1")
|
||||
host.send(clientMsg{
|
||||
Type: relayTypeCreate,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
host.expectAuthority(relayTypeCreated, "H")
|
||||
|
||||
guestAToken, _ := mustReconnectToken(t)
|
||||
guestA := h.dial(t, "6.3.0.2")
|
||||
guestA.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "A",
|
||||
ReconnectToken: guestAToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
guestA.expectAuthority(relayTypeJoined, "H")
|
||||
host.expect(relayTypePeerJoined)
|
||||
|
||||
guestBToken, _ := mustReconnectToken(t)
|
||||
guestB := h.dial(t, "6.3.0.3")
|
||||
guestB.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "B",
|
||||
ReconnectToken: guestBToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
guestB.expectAuthority(relayTypeJoined, "H")
|
||||
host.expect(relayTypePeerJoined)
|
||||
guestA.expect(relayTypePeerJoined)
|
||||
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "A", ProtocolVersion: relayProtocolVersion})
|
||||
for _, peer := range []*testConn{host, guestA, guestB} {
|
||||
changed := peer.expect(relayTypeHostChanged)
|
||||
if changed.SessionID != "XFER_HAPPY" || changed.HostPeerID != "A" || changed.From != "H" {
|
||||
t.Fatalf("hostChanged=%+v, want sessionId=XFER_HAPPY hostPeerId=A from=H", changed)
|
||||
}
|
||||
}
|
||||
|
||||
// The old host lost end-session authority even with its valid token.
|
||||
host.send(clientMsg{
|
||||
Type: relayTypeEndSession,
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
host.expectError(relayErrorPeerIdUnavailable)
|
||||
|
||||
// The new host ends the room with its own unchanged reconnect token.
|
||||
guestA.send(clientMsg{
|
||||
Type: relayTypeEndSession,
|
||||
ReconnectToken: guestAToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
ended := guestA.expect(relayTypeEnded)
|
||||
if ended.SessionID != "XFER_HAPPY" || ended.ProtocolVersion != relayProtocolVersion {
|
||||
t.Fatalf("ended acknowledgement=%+v", ended)
|
||||
}
|
||||
for _, peer := range []*testConn{host, guestB} {
|
||||
messages, err := peer.recvUntilClosed(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("peer stayed connected after transfer-then-end: %v (frames=%v)", err, messages)
|
||||
}
|
||||
if len(messages) != 1 ||
|
||||
messages[0].Type != relayTypeEnded ||
|
||||
messages[0].SessionID != "XFER_HAPPY" {
|
||||
t.Fatalf("terminal frames=%+v, want one ended notification", messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsNonHostSender(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host, guest, hostToken, _ := createModernRoomWithGuest(t, h, "XFER_NOT_HOST", "6.3.1.1", "6.3.1.2")
|
||||
guest.send(clientMsg{Type: relayTypeTransferHost, To: "H", ProtocolVersion: relayProtocolVersion})
|
||||
guest.expectError(relayErrorNotHost)
|
||||
host.recvNothing(200 * time.Millisecond)
|
||||
|
||||
h.srv.mu.RLock()
|
||||
room := h.srv.rooms["XFER_NOT_HOST"]
|
||||
h.srv.mu.RUnlock()
|
||||
if room == nil {
|
||||
t.Fatal("room missing after rejected transfer")
|
||||
}
|
||||
presented, ok := reconnectVerifierFromToken(hostToken)
|
||||
if !ok {
|
||||
t.Fatal("host token has invalid shape")
|
||||
}
|
||||
room.mu.RLock()
|
||||
hostPeerID := room.HostPeerID
|
||||
hostAuthorityIntact := reconnectVerifierMatches(room.hostVerifier, presented)
|
||||
_, guestReserved := room.peerReservations["G"]
|
||||
room.mu.RUnlock()
|
||||
if hostPeerID != "H" {
|
||||
t.Fatalf("hostPeerId=%q after rejected transfer, want H", hostPeerID)
|
||||
}
|
||||
if !hostAuthorityIntact {
|
||||
t.Fatal("host verifier changed after rejected transfer")
|
||||
}
|
||||
if !guestReserved {
|
||||
t.Fatal("guest reservation lost after rejected transfer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsInvalidTargets(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
outsider := h.dial(t, "6.3.2.9")
|
||||
outsider.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
outsider.expectError(relayErrorNotInRoom)
|
||||
|
||||
host, guest, _, _ := createModernRoomWithGuest(t, h, "XFER_TARGETS", "6.3.2.1", "6.3.2.2")
|
||||
for _, target := range []string{"", "H", "UNKNOWN", "bad peer!"} {
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: target, ProtocolVersion: relayProtocolVersion})
|
||||
host.expectError(relayErrorPeerNotFound)
|
||||
}
|
||||
guest.recvNothing(200 * time.Millisecond)
|
||||
|
||||
// A reserved-but-disconnected guest is not a valid transfer target.
|
||||
if err := guest.conn.Close(); err != nil {
|
||||
t.Fatalf("close guest: %v", err)
|
||||
}
|
||||
left := host.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "G" {
|
||||
t.Fatalf("peerLeft peerId=%q, want G", left.PeerID)
|
||||
}
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expectError(relayErrorPeerNotFound)
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsLegacyRoom(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host := h.dial(t, "6.3.3.1")
|
||||
host.send(clientMsg{Type: relayTypeCreate, SessionID: "XFER_LEGACY", PeerID: "H"})
|
||||
host.expect(relayTypeCreated)
|
||||
guest := h.dial(t, "6.3.3.2")
|
||||
guest.send(clientMsg{Type: relayTypeJoin, SessionID: "XFER_LEGACY", PeerID: "G"})
|
||||
guest.expect(relayTypeJoined)
|
||||
host.expect(relayTypePeerJoined)
|
||||
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G"})
|
||||
host.expectError(relayErrorInvalidMessage)
|
||||
guest.recvNothing(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestTransferHostPreservesReconnectAuthority(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host, guest, hostToken, guestToken := createModernRoomWithGuest(t, h, "XFER_RECONNECT", "6.3.4.1", "6.3.4.2")
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expect(relayTypeHostChanged)
|
||||
guest.expect(relayTypeHostChanged)
|
||||
|
||||
// The old host drops and rejoins with its original token as a guest.
|
||||
if err := host.conn.Close(); err != nil {
|
||||
t.Fatalf("close old host: %v", err)
|
||||
}
|
||||
left := guest.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "H" {
|
||||
t.Fatalf("peerLeft peerId=%q, want H", left.PeerID)
|
||||
}
|
||||
oldHost := h.dial(t, "6.3.4.3")
|
||||
oldHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RECONNECT",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
rejoinedGuest := oldHost.expectAuthority(relayTypeJoined, "G")
|
||||
if rejoinedGuest.ReconnectToken != hostToken {
|
||||
t.Fatalf("old host reconnect token changed: %+v", rejoinedGuest)
|
||||
}
|
||||
guest.expect(relayTypePeerJoined)
|
||||
|
||||
// The new host drops and rejoins with its original token as the host.
|
||||
if err := guest.conn.Close(); err != nil {
|
||||
t.Fatalf("close new host: %v", err)
|
||||
}
|
||||
left = oldHost.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "G" {
|
||||
t.Fatalf("peerLeft peerId=%q, want G", left.PeerID)
|
||||
}
|
||||
newHost := h.dial(t, "6.3.4.4")
|
||||
newHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RECONNECT",
|
||||
PeerID: "G",
|
||||
ReconnectToken: guestToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
rejoinedHost := newHost.expectAuthority(relayTypeJoined, "G")
|
||||
if rejoinedHost.ReconnectToken != guestToken {
|
||||
t.Fatalf("new host reconnect token changed: %+v", rejoinedHost)
|
||||
}
|
||||
oldHost.expect(relayTypePeerJoined)
|
||||
}
|
||||
|
||||
func TestTransferHostSurvivesSnapshotRestart(t *testing.T) {
|
||||
stateFile := filepath.Join(t.TempDir(), "rooms.json")
|
||||
hA := newRelayHarnessAt(t, t.TempDir(), stateFile)
|
||||
host, guest, hostToken, guestToken := createModernRoomWithGuest(t, hA, "XFER_RESTART", "6.3.5.1", "6.3.5.2")
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expect(relayTypeHostChanged)
|
||||
guest.expect(relayTypeHostChanged)
|
||||
|
||||
if err := hA.srv.snap.flushAndStop(2 * time.Second); err != nil {
|
||||
t.Fatalf("flush snapshot after transfer: %v", err)
|
||||
}
|
||||
|
||||
hB := newRelayHarnessAt(t, t.TempDir(), stateFile)
|
||||
hB.srv.mu.RLock()
|
||||
room := hB.srv.rooms["XFER_RESTART"]
|
||||
hB.srv.mu.RUnlock()
|
||||
if room == nil {
|
||||
t.Fatal("restored room missing")
|
||||
}
|
||||
newHostVerifier, ok := reconnectVerifierFromToken(guestToken)
|
||||
if !ok {
|
||||
t.Fatal("new host token has invalid shape")
|
||||
}
|
||||
room.mu.RLock()
|
||||
hostPeerID := room.HostPeerID
|
||||
hostAuthority := reconnectVerifierMatches(room.hostVerifier, newHostVerifier)
|
||||
_, oldHostReserved := room.peerReservations["H"]
|
||||
_, newHostReserved := room.peerReservations["G"]
|
||||
room.mu.RUnlock()
|
||||
if hostPeerID != "G" {
|
||||
t.Fatalf("restored hostPeerId=%q, want G", hostPeerID)
|
||||
}
|
||||
if !hostAuthority {
|
||||
t.Fatal("restored host verifier does not validate the new host's token")
|
||||
}
|
||||
if !oldHostReserved {
|
||||
t.Fatal("restored room lost the old host's guest reservation")
|
||||
}
|
||||
if newHostReserved {
|
||||
t.Fatal("restored room kept a guest reservation for the new host")
|
||||
}
|
||||
|
||||
newHost := hB.dial(t, "6.3.5.3")
|
||||
newHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RESTART",
|
||||
PeerID: "G",
|
||||
ReconnectToken: guestToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
newHost.expectAuthority(relayTypeJoined, "G")
|
||||
oldHost := hB.dial(t, "6.3.5.4")
|
||||
oldHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RESTART",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
oldHost.expectAuthority(relayTypeJoined, "G")
|
||||
newHost.expect(relayTypePeerJoined)
|
||||
}
|
||||
|
||||
func TestHostEndDeliversEndedAfterConcurrentGuestTraffic(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
endDeliveryReady := make(chan struct{})
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
relayTypePing = "ping"
|
||||
relayTypeLeave = "leave"
|
||||
relayTypeEndSession = "endSession"
|
||||
relayTypeTransferHost = "transferHost"
|
||||
relayTypeCreated = "created"
|
||||
relayTypeJoined = "joined"
|
||||
relayTypePeerJoined = "peerJoined"
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
relayTypePong = "pong"
|
||||
relayTypeLeft = "left"
|
||||
relayTypeEnded = "ended"
|
||||
relayTypeHostChanged = "hostChanged"
|
||||
relayErrorRateLimited = "rate_limited"
|
||||
relayErrorInvalidMessage = "invalid_message"
|
||||
relayErrorRoomExists = "room_exists"
|
||||
@@ -31,6 +33,8 @@ const (
|
||||
relayErrorAlreadyInRoom = "already_in_room"
|
||||
relayErrorPeerIdUnavailable = "peer_id_unavailable"
|
||||
relayErrorProtocolMismatch = "protocol_mismatch"
|
||||
relayErrorNotHost = "not_host"
|
||||
relayErrorPeerNotFound = "peer_not_found"
|
||||
|
||||
maxRoomSize = 8
|
||||
maxMessageSize = 65536
|
||||
|
||||
@@ -579,4 +579,81 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('host transfer', () {
|
||||
WatchSession sessionAs(SessionRole role) => WatchSession(
|
||||
sessionId: 'ROOM1',
|
||||
role: role,
|
||||
controlMode: ControlMode.hostOnly,
|
||||
state: SessionState.connected,
|
||||
hostPeerId: 'guest',
|
||||
);
|
||||
|
||||
test('mid-playback: the promoted guest re-anchors the room and the demoted host follows', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
async.elapse(const Duration(seconds: 3));
|
||||
expect(room.hostPlayer.state.playing, isTrue);
|
||||
expect(room.guestPlayer.state.playing, isTrue);
|
||||
final originalKey = room.lastHostState().mediaKey;
|
||||
final statesBefore = room.hostService.outgoingLog.where((m) => m.type == SyncMessageType.state).length;
|
||||
|
||||
// The relay reassigned authority: each side applies its own hostChanged.
|
||||
room.host.applyHostChange(sessionAs(SessionRole.guest));
|
||||
room.guest.applyHostChange(sessionAs(SessionRole.host));
|
||||
async.flushMicrotasks();
|
||||
|
||||
// The demoted host falls in line: it asks the new authority for state.
|
||||
expect(room.hostService.outgoingLog.any((m) => m.type == SyncMessageType.requestState), isTrue);
|
||||
|
||||
// The promoted guest re-runs the epoch gate and both players resume.
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
final adopted = room.guestService.outgoingLog.lastWhere((m) => m.type == SyncMessageType.state).state!;
|
||||
expect(adopted.phase, PlaybackPhase.playing);
|
||||
expect(adopted.mediaKey, originalKey);
|
||||
expect(room.hostPlayer.state.playing, isTrue);
|
||||
expect(room.guestPlayer.state.playing, isTrue);
|
||||
|
||||
// The old host's coordinator is gone — it authors no further states.
|
||||
expect(room.hostService.outgoingLog.where((m) => m.type == SyncMessageType.state).length, statesBefore);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('a paused room stays paused across the handoff', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
async.elapse(const Duration(seconds: 3));
|
||||
expect(room.hostPlayer.state.playing, isTrue);
|
||||
|
||||
// The host pauses the room before handing it over.
|
||||
room.hostPlayer.emitPlaying(false);
|
||||
async.flushMicrotasks();
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(room.lastHostState().phase, PlaybackPhase.paused);
|
||||
expect(room.guestPlayer.state.playing, isFalse);
|
||||
final guestPlays = room.guestPlayer.commandLog.where((c) => c == 'play').length;
|
||||
final hostPlays = room.hostPlayer.commandLog.where((c) => c == 'play').length;
|
||||
|
||||
room.host.applyHostChange(sessionAs(SessionRole.guest));
|
||||
room.guest.applyHostChange(sessionAs(SessionRole.host));
|
||||
async.flushMicrotasks();
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
|
||||
final adopted = room.guestService.outgoingLog.lastWhere((m) => m.type == SyncMessageType.state).state!;
|
||||
expect(adopted.phase, PlaybackPhase.paused, reason: 'a host change is not a play intent');
|
||||
expect(room.hostPlayer.state.playing, isFalse);
|
||||
expect(room.guestPlayer.state.playing, isFalse);
|
||||
expect(room.guestPlayer.commandLog.where((c) => c == 'play').length, guestPlays);
|
||||
expect(room.hostPlayer.commandLog.where((c) => c == 'play').length, hostPlays);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +111,56 @@ void main() {
|
||||
await tester.pump();
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('host promotes a guest from the session sheet', (tester) async {
|
||||
final harness = _OverlayHarness(isHost: true);
|
||||
harness.provider.extraParticipants = [const Participant(peerId: 'g1', displayName: 'Guest One', isHost: false)];
|
||||
addTearDown(harness.dispose);
|
||||
await tester.pumpWidget(harness.build());
|
||||
|
||||
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Guest One'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.watchTogether.makeHostQuestion), findsOneWidget);
|
||||
await tester.tap(find.text(t.watchTogether.transfer));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.provider.transferredTo.single.peerId, 'g1');
|
||||
expect(harness.sheetController.isOpen, isFalse, reason: 'the sheet closes once the transfer is requested');
|
||||
});
|
||||
|
||||
testWidgets('cancelling the promote confirmation transfers nothing', (tester) async {
|
||||
final harness = _OverlayHarness(isHost: true);
|
||||
harness.provider.extraParticipants = [const Participant(peerId: 'g1', displayName: 'Guest One', isHost: false)];
|
||||
addTearDown(harness.dispose);
|
||||
await tester.pumpWidget(harness.build());
|
||||
|
||||
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Guest One'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(t.common.cancel));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.provider.transferredTo, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('guests see fellow participants without a promote affordance', (tester) async {
|
||||
final harness = _OverlayHarness(isHost: false);
|
||||
harness.provider.extraParticipants = [const Participant(peerId: 'g1', displayName: 'Guest One', isHost: false)];
|
||||
addTearDown(harness.dispose);
|
||||
await tester.pumpWidget(harness.build());
|
||||
|
||||
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Guest One'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.watchTogether.makeHostQuestion), findsNothing);
|
||||
expect(harness.provider.transferredTo, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
||||
@@ -178,8 +228,22 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
@override
|
||||
List<Participant> get participants => [
|
||||
Participant(peerId: 'local', displayName: 'Local viewer', isHost: isHostValue),
|
||||
...extraParticipants,
|
||||
];
|
||||
|
||||
/// Participants appended after the local viewer row.
|
||||
List<Participant> extraParticipants = const [];
|
||||
|
||||
/// Transfer requests forwarded by the sheet.
|
||||
final List<Participant> transferredTo = [];
|
||||
|
||||
@override
|
||||
bool canTransferHostTo(Participant participant) =>
|
||||
isHostValue && !participant.isHost && participant.peerId != 'local';
|
||||
|
||||
@override
|
||||
void transferHost(Participant participant) => transferredTo.add(participant);
|
||||
|
||||
@override
|
||||
int get participantCount => participants.length;
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ void main() {
|
||||
() => service.sendTo('bad peer', const SyncMessage(type: SyncMessageType.requestState, timestamp: 0)),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(() => service.transferHost('bad peer'), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('host stores relay authority and uses a random routing ID', () async {
|
||||
@@ -1232,4 +1233,152 @@ void main() {
|
||||
|
||||
expect(reconnectCallbacks, 0);
|
||||
});
|
||||
|
||||
test('host transfer request reaches the relay and the broadcast demotes the sender', () async {
|
||||
late final _RelayServer relay;
|
||||
relay = await relayWith((_, socket, message) {
|
||||
if (message['type'] == 'create') {
|
||||
relay.send(socket, {
|
||||
'type': 'created',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': message['peerId'],
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
} else if (message['type'] == 'transferHost') {
|
||||
relay.send(socket, {
|
||||
'type': 'hostChanged',
|
||||
'sessionId': 'XFER1',
|
||||
'hostPeerId': message['to'],
|
||||
'from': message['peerId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
final service = serviceFor(relay);
|
||||
final changed = Completer<String>();
|
||||
final subscription = service.onHostChanged.listen((peerId) {
|
||||
if (!changed.isCompleted) changed.complete(peerId);
|
||||
});
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await service.createSession(sessionId: 'xfer1');
|
||||
service.transferHost('guest-1');
|
||||
|
||||
expect(await changed.future.timeout(const Duration(seconds: 5)), 'guest-1');
|
||||
expect(service.isHost, isFalse);
|
||||
expect(service.hostPeerId, 'guest-1');
|
||||
expect(relay.messages.single.last, {'type': 'transferHost', 'to': 'guest-1', 'protocolVersion': 2});
|
||||
});
|
||||
|
||||
test('a guest named in hostChanged adopts host authority', () async {
|
||||
late final _RelayServer relay;
|
||||
relay = await relayWith((_, socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {
|
||||
'type': 'joined',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': _relayHostId,
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
'peers': [_relayHostId],
|
||||
});
|
||||
}
|
||||
});
|
||||
final service = serviceFor(relay);
|
||||
final changed = Completer<String>();
|
||||
final subscription = service.onHostChanged.listen((peerId) {
|
||||
if (!changed.isCompleted) changed.complete(peerId);
|
||||
});
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await service.joinSession('xfer2');
|
||||
relay.send(relay.sockets.single, {
|
||||
'type': 'hostChanged',
|
||||
'sessionId': 'XFER2',
|
||||
'hostPeerId': service.myPeerId,
|
||||
'from': _relayHostId,
|
||||
});
|
||||
|
||||
expect(await changed.future.timeout(const Duration(seconds: 5)), service.myPeerId);
|
||||
expect(service.isHost, isTrue);
|
||||
expect(service.hostPeerId, service.myPeerId);
|
||||
});
|
||||
|
||||
test('invalid and duplicate hostChanged messages are ignored', () async {
|
||||
late final _RelayServer relay;
|
||||
relay = await relayWith((_, socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {
|
||||
'type': 'joined',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': _relayHostId,
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
'peers': [_relayHostId],
|
||||
});
|
||||
}
|
||||
});
|
||||
final service = serviceFor(relay);
|
||||
final observed = <String>[];
|
||||
final subscription = service.onHostChanged.listen(observed.add);
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await service.joinSession('xfer3');
|
||||
final socket = relay.sockets.single;
|
||||
// Wrong room, malformed peer, and a no-op "change" to the current host
|
||||
// must all be dropped; the valid change afterwards proves ordering.
|
||||
relay.send(socket, {'type': 'hostChanged', 'sessionId': 'OTHER', 'hostPeerId': 'guest-9'});
|
||||
relay.send(socket, {'type': 'hostChanged', 'sessionId': 'XFER3', 'hostPeerId': 'bad peer'});
|
||||
relay.send(socket, {'type': 'hostChanged', 'sessionId': 'XFER3', 'hostPeerId': _relayHostId});
|
||||
relay.send(socket, {'type': 'hostChanged', 'sessionId': 'XFER3', 'hostPeerId': 'guest-2'});
|
||||
|
||||
while (observed.isEmpty) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
expect(observed, ['guest-2']);
|
||||
expect(service.hostPeerId, 'guest-2');
|
||||
expect(service.isHost, isFalse);
|
||||
});
|
||||
|
||||
test('guest reconnect accepts the host identity pinned by a transfer', () async {
|
||||
late final _RelayServer relay;
|
||||
relay = await relayWith((connection, socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {
|
||||
'type': 'joined',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': connection == 0 ? _relayHostId : 'guest-2',
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
'peers': ['guest-2'],
|
||||
});
|
||||
}
|
||||
});
|
||||
final service = serviceFor(relay);
|
||||
final errors = <PeerError>[];
|
||||
final errorSubscription = service.onError.listen(errors.add);
|
||||
addTearDown(errorSubscription.cancel);
|
||||
final changed = Completer<String>();
|
||||
final subscription = service.onHostChanged.listen((peerId) {
|
||||
if (!changed.isCompleted) changed.complete(peerId);
|
||||
});
|
||||
addTearDown(subscription.cancel);
|
||||
final reconnected = Completer<void>();
|
||||
service.onReconnected = reconnected.complete;
|
||||
|
||||
await _withShortenedTimer(
|
||||
original: const Duration(seconds: 2),
|
||||
replacement: const Duration(milliseconds: 10),
|
||||
body: () => service.joinSession('xfer4'),
|
||||
);
|
||||
relay.send(relay.sockets.single, {'type': 'hostChanged', 'sessionId': 'XFER4', 'hostPeerId': 'guest-2'});
|
||||
await changed.future.timeout(const Duration(seconds: 5));
|
||||
|
||||
await relay.sockets.single.close();
|
||||
await reconnected.future.timeout(const Duration(seconds: 6));
|
||||
|
||||
expect(service.hostPeerId, 'guest-2');
|
||||
expect(service.isHost, isFalse);
|
||||
expect(errors, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,8 +32,13 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
final _messageController = StreamController<SyncMessage>.broadcast();
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
final _hostChangedController = StreamController<String>.broadcast();
|
||||
final List<SyncMessage> broadcasts = [];
|
||||
final List<(String, SyncMessage)> directMessages = [];
|
||||
final List<String> transferRequests = [];
|
||||
|
||||
/// Extra peers reported as connected on top of the modeled host link.
|
||||
final Set<String> connectedGuests = {};
|
||||
|
||||
String? _sessionId;
|
||||
String? _myPeerId;
|
||||
@@ -59,6 +64,9 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
@override
|
||||
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||
|
||||
@override
|
||||
Stream<String> get onHostChanged => _hostChangedController.stream;
|
||||
|
||||
@override
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
@@ -72,7 +80,10 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
bool get isHost => _isHost;
|
||||
|
||||
@override
|
||||
List<String> get connectedPeers => !_isHost && _sessionId != null && _hostConnected ? ['wt-$_sessionId'] : const [];
|
||||
List<String> get connectedPeers => [
|
||||
if (!_isHost && _sessionId != null && _hostConnected) 'wt-$_sessionId',
|
||||
...connectedGuests,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> createSession({String? sessionId}) {
|
||||
@@ -108,6 +119,15 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
directMessages.add((peerId, message));
|
||||
}
|
||||
|
||||
@override
|
||||
void transferHost(String peerId) => transferRequests.add(peerId);
|
||||
|
||||
void emitHostChanged(String newHostPeerId) {
|
||||
_hostPeerId = newHostPeerId;
|
||||
_isHost = newHostPeerId == _myPeerId;
|
||||
_hostChangedController.add(newHostPeerId);
|
||||
}
|
||||
|
||||
void emitError(PeerError error) => _errorController.add(error);
|
||||
void emitPeerConnected(String peerId) {
|
||||
if (peerId == _hostPeerId) _hostConnected = true;
|
||||
@@ -127,7 +147,8 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
_peerDisconnectedController.hasListener ||
|
||||
_messageController.hasListener ||
|
||||
_errorController.hasListener ||
|
||||
_sessionEndedController.hasListener;
|
||||
_sessionEndedController.hasListener ||
|
||||
_hostChangedController.hasListener;
|
||||
|
||||
bool get isDisposed => _disposed;
|
||||
bool get didDisconnect => _didDisconnect;
|
||||
@@ -162,6 +183,7 @@ class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
_messageController.close();
|
||||
_errorController.close();
|
||||
_sessionEndedController.close();
|
||||
_hostChangedController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -997,4 +1019,158 @@ void main() {
|
||||
expect(service.isDisposed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — host transfer', () {
|
||||
test('hostChanged demotes the host, updates participants, and toasts the new host', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
final events = <ParticipantEvent>[];
|
||||
final subscription = provider.participantEvents.listen(events.add);
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Host',
|
||||
sessionId: 'xfer1',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.connectedGuests.add('guest-a');
|
||||
service.emitMessage(SyncMessage.join(peerId: 'guest-a', displayName: 'Guest A', isHost: false));
|
||||
await _flushProviderEvents();
|
||||
|
||||
final guest = provider.participants.singleWhere((p) => p.peerId == 'guest-a');
|
||||
expect(provider.canTransferHostTo(guest), isTrue);
|
||||
provider.transferHost(guest);
|
||||
expect(service.transferRequests, ['guest-a']);
|
||||
|
||||
// Roles only flip when the relay's broadcast lands.
|
||||
expect(provider.isHost, isTrue);
|
||||
service.emitHostChanged('guest-a');
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(provider.isHost, isFalse);
|
||||
expect(provider.session?.hostPeerId, 'guest-a');
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(provider.participants.singleWhere((p) => p.peerId == 'guest-a').isHost, isTrue);
|
||||
expect(provider.participants.singleWhere((p) => p.peerId != 'guest-a').isHost, isFalse);
|
||||
final event = events.singleWhere((e) => e.type == ParticipantEventType.hostChanged);
|
||||
expect(event.displayName, 'Guest A');
|
||||
});
|
||||
|
||||
test('hostChanged promotion re-announces the room control mode as host', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
final events = <ParticipantEvent>[];
|
||||
final subscription = provider.participantEvents.listen(events.add);
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await provider.joinSession(
|
||||
'xfer2',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
// The host's join teaches guests the room's real control mode.
|
||||
service.emitMessage(
|
||||
SyncMessage.join(peerId: 'wt-XFER2', displayName: 'Host', isHost: true, controlMode: ControlMode.anyone),
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
expect(provider.controlMode, ControlMode.anyone);
|
||||
|
||||
service.emitHostChanged(service.myPeerId!);
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(provider.isHost, isTrue);
|
||||
expect(provider.session?.role, SessionRole.host);
|
||||
expect(provider.session?.hostPeerId, service.myPeerId);
|
||||
expect(provider.participants.singleWhere((p) => p.peerId == service.myPeerId).isHost, isTrue);
|
||||
expect(provider.participants.singleWhere((p) => p.peerId == 'wt-XFER2').isHost, isFalse);
|
||||
expect(events.map((e) => e.type), contains(ParticipantEventType.becameHost));
|
||||
// The promoted host re-broadcasts a join carrying the inherited mode.
|
||||
final announced = service.broadcasts.lastWhere((m) => m.type == SyncMessageType.join);
|
||||
expect(announced.isHost, isTrue);
|
||||
expect(announced.controlMode, ControlMode.anyone);
|
||||
// Reverse state: the demoted-side wiring is exercised by the demotion
|
||||
// test above; a promoted host can end the session (host-only path).
|
||||
expect(provider.canControl(), isTrue);
|
||||
});
|
||||
|
||||
test('a rejected transfer keeps the session connected and toasts the failure', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
final events = <ParticipantEvent>[];
|
||||
final subscription = provider.participantEvents.listen(events.add);
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Host',
|
||||
sessionId: 'xfer3',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.connectedGuests.add('guest-b');
|
||||
service.emitMessage(SyncMessage.join(peerId: 'guest-b', displayName: 'Guest B', isHost: false));
|
||||
await _flushProviderEvents();
|
||||
|
||||
provider.transferHost(provider.participants.singleWhere((p) => p.peerId == 'guest-b'));
|
||||
service.emitError(
|
||||
const PeerError(
|
||||
type: PeerErrorType.serverError,
|
||||
message: 'peer_not_found: Peer is not in the room',
|
||||
serverCode: 'peer_not_found',
|
||||
),
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(provider.isHost, isTrue);
|
||||
final event = events.singleWhere((e) => e.type == ParticipantEventType.hostTransferFailed);
|
||||
expect(event.displayName, 'Guest B');
|
||||
});
|
||||
|
||||
test('canTransferHostTo requires host role, a connected target, and protocol compatibility', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Host',
|
||||
sessionId: 'xfer4',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.connectedGuests.add('guest-new');
|
||||
service.emitMessage(SyncMessage.join(peerId: 'guest-new', displayName: 'New', isHost: false));
|
||||
// An old-protocol guest (version below the current sync protocol).
|
||||
service.connectedGuests.add('guest-old');
|
||||
service.emitMessage(
|
||||
SyncMessage.fromJson('{"t":"join","ts":0,"pid":"guest-old","name":"Old","host":false,"v":2}'),
|
||||
);
|
||||
// A guest the relay no longer reports as connected.
|
||||
service.emitMessage(SyncMessage.join(peerId: 'guest-gone', displayName: 'Gone', isHost: false));
|
||||
await _flushProviderEvents();
|
||||
|
||||
Participant byId(String id) => provider.participants.singleWhere((p) => p.peerId == id);
|
||||
expect(provider.canTransferHostTo(byId('guest-new')), isTrue);
|
||||
expect(provider.canTransferHostTo(byId('guest-old')), isFalse);
|
||||
expect(provider.canTransferHostTo(byId('guest-gone')), isFalse);
|
||||
expect(provider.canTransferHostTo(byId(service.myPeerId!)), isFalse);
|
||||
|
||||
// Ineligible targets never reach the relay.
|
||||
provider.transferHost(byId('guest-old'));
|
||||
expect(service.transferRequests, isEmpty);
|
||||
|
||||
// A guest can never initiate a transfer.
|
||||
service.emitHostChanged('guest-new');
|
||||
await _flushProviderEvents();
|
||||
expect(provider.isHost, isFalse);
|
||||
expect(provider.canTransferHostTo(byId('guest-old')), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user