From a5c7d5b52ab9ad3d7df00e23a7f08ed89c6fd840 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:42:38 +0200 Subject: [PATCH] refactor(watch-together): host-authoritative declarative sync protocol Replaces the imperative play/pause/seek/positionSync message soup with a single host-authored PlaybackState (seq-ordered, anchor-extrapolated, phase machine: loading/waitingForPeers/paused/playing) that doubles as the heartbeat, plus guest status reports and host-applied control requests. Fixes the guest seek-back loop while the host loads (readiness was keyed on a pre-load !buffering snapshot and heartbeats broadcast frozen positions), adds real group buffering coordination (stall grace, scheduled simultaneous resumes, 15s safety timeout), rate-nudge drift correction with passthrough-aware seek fallback, session-scoped message handling (no lost messages during episode-switch detach gaps), and an expected-state ledger replacing the racy remote-action flag. --- lib/i18n/en.i18n.json | 3 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 16 +- .../parts/episode_navigation.dart | 43 +- .../video_player/parts/playback_open.dart | 32 + .../video_player/parts/playback_start.dart | 33 +- .../video_player/parts/watch_together.dart | 34 +- lib/watch_together/models/playback_state.dart | 265 +++++ lib/watch_together/models/sync_message.dart | 272 ++--- .../providers/watch_together_provider.dart | 365 +++--- .../services/attached_player.dart | 259 ++++ lib/watch_together/services/clock_sync.dart | 119 ++ .../services/guest_playback_reconciler.dart | 598 ++++++++++ .../services/host_playback_coordinator.dart | 802 +++++++++++++ .../services/watch_together_controller.dart | 374 ++++++ .../services/watch_together_sync_manager.dart | 1052 ----------------- lib/watch_together/watch_together.dart | 3 +- .../widgets/watch_together_overlay.dart | 19 +- test/test_helpers/watch_together_fakes.dart | 329 ++++++ test/watch_together/attached_player_test.dart | 248 ++++ test/watch_together/clock_sync_test.dart | 138 +++ .../guest_playback_reconciler_test.dart | 554 +++++++++ .../host_playback_coordinator_test.dart | 590 +++++++++ test/watch_together/playback_state_test.dart | 138 +++ .../watch_together_controller_test.dart | 276 +++++ .../watch_together_provider_test.dart | 13 +- .../watch_together_sync_manager_test.dart | 338 ------ 27 files changed, 5099 insertions(+), 1816 deletions(-) create mode 100644 lib/watch_together/models/playback_state.dart create mode 100644 lib/watch_together/services/attached_player.dart create mode 100644 lib/watch_together/services/clock_sync.dart create mode 100644 lib/watch_together/services/guest_playback_reconciler.dart create mode 100644 lib/watch_together/services/host_playback_coordinator.dart create mode 100644 lib/watch_together/services/watch_together_controller.dart delete mode 100644 lib/watch_together/services/watch_together_sync_manager.dart create mode 100644 test/test_helpers/watch_together_fakes.dart create mode 100644 test/watch_together/attached_player_test.dart create mode 100644 test/watch_together/clock_sync_test.dart create mode 100644 test/watch_together/guest_playback_reconciler_test.dart create mode 100644 test/watch_together/host_playback_coordinator_test.dart create mode 100644 test/watch_together/playback_state_test.dart create mode 100644 test/watch_together/watch_together_controller_test.dart delete mode 100644 test/watch_together/watch_together_sync_manager_test.dart diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 07ff5dbe..b0086eed 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -977,7 +977,10 @@ "participantResumed": "${name} resumed", "participantSeeked": "${name} seeked", "participantBuffering": "${name} is buffering", + "participantNeedsUpdate": "${name} is on an older app version — sync unavailable", + "resumingWithout": "Resuming without ${name}", "waitingForParticipants": "Waiting for others to load...", + "waitingForName": "Waiting for ${name}...", "recentRooms": "Recent Rooms", "renameRoom": "Rename Room", "removeRoom": "Remove", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 7c49418e..1a74b274 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20384 (1274 per locale) +/// Strings: 20387 (1274 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a4ae6e90..84e06e6a 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2895,9 +2895,18 @@ class TranslationsWatchTogetherEn { /// en: '${name} is buffering' String participantBuffering({required Object name}) => '${name} is buffering'; + /// en: '${name} is on an older app version — sync unavailable' + String participantNeedsUpdate({required Object name}) => '${name} is on an older app version — sync unavailable'; + + /// en: 'Resuming without ${name}' + String resumingWithout({required Object name}) => 'Resuming without ${name}'; + /// en: 'Waiting for others to load...' String get waitingForParticipants => 'Waiting for others to load...'; + /// en: 'Waiting for ${name}...' + String waitingForName({required Object name}) => 'Waiting for ${name}...'; + /// en: 'Recent Rooms' String get recentRooms => 'Recent Rooms'; @@ -5378,7 +5387,10 @@ extension on Translations { 'watchTogether.participantResumed' => ({required Object name}) => '${name} resumed', 'watchTogether.participantSeeked' => ({required Object name}) => '${name} seeked', 'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering', + 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable', + 'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}', 'watchTogether.waitingForParticipants' => 'Waiting for others to load...', + 'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...', 'watchTogether.recentRooms' => 'Recent Rooms', 'watchTogether.renameRoom' => 'Rename Room', 'watchTogether.removeRoom' => 'Remove', @@ -5500,11 +5512,11 @@ extension on Translations { 'companionRemote.remote.tabRemote' => 'Remote', 'companionRemote.remote.tabPlay' => 'Play', 'companionRemote.remote.tabMore' => 'More', + _ => null, + } ?? switch (path) { 'companionRemote.remote.menu' => 'Menu', 'companionRemote.remote.tabNavigation' => 'Tab Navigation', 'companionRemote.remote.tabDiscover' => 'Discover', - _ => null, - } ?? switch (path) { 'companionRemote.remote.tabLibraries' => 'Libraries', 'companionRemote.remote.tabSearch' => 'Search', 'companionRemote.remote.tabDownloads' => 'Downloads', diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index f9c8777a..592e1e41 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -253,13 +253,15 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final playbackState = context.read(); final database = context.read(); final serverManager = context.read().serverManager; - // Sync readiness (playerReady/deferredPlay/firstPlay handshake) is - // per-item: cycle the Watch Together attachment across item changes, the - // same reset the old screen-swap flow got from dispose + re-attach. - // Same-item source switches keep the attachment (and readiness) intact. + // Cycle the Watch Together attachment across every reload: the reload's + // internal pause/open churn must not leak into the sync layer as user + // intents. Readiness re-handshakes on re-attach (item changes start a + // new media epoch; same-item source switches group-wait while we + // reload). final watchTogether = _activeWatchTogetherSession(); - final watchTogetherWasAttached = watchTogether?.syncManager?.hasPlayer ?? false; - final cycleWatchTogetherAttachment = watchTogetherWasAttached && isItemChange; + final watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false; + final cycleWatchTogetherAttachment = watchTogetherWasAttached; + final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); if (!isCurrentReload()) return true; @@ -387,7 +389,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { isTranscoding: result.isTranscoding, timing: openTiming, headers: result.usesLocalMedia ? null : streamHeaders, - play: !frameRatePlan.holdPlaybackStart && (attachesSubsAtOpen || !hasExternalSubs), + play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs), externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null, shouldContinue: isCurrentReload, onOpened: () { @@ -434,8 +436,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { trackManager: trackManager, externalSubtitles: result.externalSubtitles, // Same guard as the start path: don't resume a player a newer flow - // owns, and let a pending startup gate own the resume instead. - shouldResumeAfterSubtitleLoad: () => !frameRatePlan.holdPlaybackStart && mounted && player == currentPlayer, + // owns, and let a pending startup gate (or Watch Together's group + // start) own the resume instead. + shouldResumeAfterSubtitleLoad: () => + !frameRatePlan.holdPlaybackStart && !wtOwnsStart && mounted && player == currentPlayer, + applySelectionWhenResumeSkipped: wtOwnsStart && !frameRatePlan.holdPlaybackStart, ); if (!isCurrentReload()) return true; @@ -443,11 +448,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { currentPlayer: currentPlayer, settingsService: settingsService, plan: frameRatePlan, - resumeAfterStartupGate: (reason) => _resumeAfterFrameRateStartupGate( + resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( currentPlayer: currentPlayer, attachesSubsAtOpen: attachesSubsAtOpen, hasExternalSubs: hasExternalSubs, reason: reason, + wtOwnsStart: wtOwnsStart, ), ); if (!isCurrentReload()) return true; @@ -529,14 +535,25 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // Restore Watch Together sync on every exit: after a successful item // change (readiness re-handshakes for the new item), after a failed // reload (the still-playing old item must stay synced), and when the - // manager auto-detached itself on a mid-reload remote-action failure. + // controller auto-detached itself on a mid-reload player failure. + // _currentMetadata is correct on both the success and rollback paths + // by the time we get here. + final reattachServerId = _currentMetadata.serverId; if (watchTogetherWasAttached && watchTogether != null && watchTogether.isInSession && mounted && player == currentPlayer && - !(watchTogether.syncManager?.hasPlayer ?? true)) { - watchTogether.attachPlayer(currentPlayer); + reattachServerId != null && + !watchTogether.hasAttachedPlayer) { + watchTogether.attachPlayer( + currentPlayer, + ratingKey: _currentMetadata.id, + serverId: reattachServerId, + mediaTitle: _currentMetadata.displayTitle, + hasFirstFrame: _hasFirstFrame.value, + remoteSeek: _seekPlayback, + ); } } } diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 42e9cc04..98ab163c 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -282,6 +282,38 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { } } + /// Gate-release resume that yields to Watch Together when a session owns + /// the playback start: track selection is still armed, but instead of + /// playing, the sync readiness hold (if any) is released — the + /// coordinated group start unpauses later. Shared by the start and reload + /// flows. + Future _resumeAfterStartupGateOrYieldToWatchTogether({ + required Player currentPlayer, + required bool attachesSubsAtOpen, + required bool hasExternalSubs, + required String reason, + required bool wtOwnsStart, + Completer? wtStartupHold, + }) async { + if (!wtOwnsStart) { + return _resumeAfterFrameRateStartupGate( + currentPlayer: currentPlayer, + attachesSubsAtOpen: attachesSubsAtOpen, + hasExternalSubs: hasExternalSubs, + reason: reason, + ); + } + appLogger.d('Frame rate matching: yielding post-gate resume to Watch Together ($reason)'); + final trackManager = _trackManager; + if (trackManager != null && !attachesSubsAtOpen && hasExternalSubs) { + trackManager.waitingForExternalSubsTrackSelection = false; + trackManager.applyTrackSelectionWhenReady(); + } + if (wtStartupHold != null && !wtStartupHold.isCompleted) { + wtStartupHold.complete(); + } + } + /// Push the user's subtitle style to the native rendering layer (no-op on /// mpv backends, which style via `sub-*` properties). Must run after /// open() since that's when ExoPlayer initializes its subtitle views. diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 39e1abb0..43a21d61 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -210,6 +210,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { if (frameRatePlan == null) return; final shouldHoldPlaybackStart = frameRatePlan.holdPlaybackStart; + // When a Watch Together session is active the sync layer owns the + // start: open paused everywhere and let the host coordinate one + // simultaneous group start. + final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); + Completer? wtStartupHold; + // Open video through Player if (result.videoUrl != null) { // Reset first frame flag and frame rate retry counter for new video @@ -251,7 +257,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { isTranscoding: result.isTranscoding, ); - final shouldAutoPlay = !shouldHoldPlaybackStart && (attachesSubsAtOpen || !hasExternalSubs); + final shouldAutoPlay = !shouldHoldPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs); frameRatePlan.armStartupRefreshGate(currentPlayer); // ExoPlayer: attach external subs at open time so it discovers @@ -276,9 +282,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); if (!didOpen || !attempt.isCurrent) return; - // Attach player to Watch Together session for sync (if in session) + // Attach player to Watch Together session for sync (if in session). + // With a frame-rate startup gate pending, sync readiness waits for + // its release so the group start can't fire mid display switch. if (mounted && !_isOfflinePlayback) { - _attachToWatchTogetherSession(); + if (wtOwnsStart && shouldHoldPlaybackStart) { + wtStartupHold = Completer(); + } + _attachToWatchTogetherSession(startupHold: wtStartupHold?.future); _notifyWatchTogetherMediaChange(); } } @@ -344,21 +355,31 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { trackManager: _trackManager!, externalSubtitles: result.externalSubtitles, // When a startup gate below owns the resume, skip this one to - // avoid a double-play. - shouldResumeAfterSubtitleLoad: () => !shouldHoldPlaybackStart && mounted && player == currentPlayer, + // avoid a double-play. Watch Together stays paused for the group + // start, so selection is armed through the resume-skipped branch. + shouldResumeAfterSubtitleLoad: () => + !shouldHoldPlaybackStart && !wtOwnsStart && mounted && player == currentPlayer, + applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart, ); await _releaseFrameRateStartupGate( currentPlayer: currentPlayer, settingsService: settingsService, plan: frameRatePlan, - resumeAfterStartupGate: (reason) => _resumeAfterFrameRateStartupGate( + resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( currentPlayer: currentPlayer, attachesSubsAtOpen: attachesSubsAtOpen, hasExternalSubs: hasExternalSubs, reason: reason, + wtOwnsStart: wtOwnsStart, + wtStartupHold: wtStartupHold, ), ); + // Backstop: if the gate never ran its resume path (unmounted race), + // don't leave Watch Together readiness held forever. + if (wtStartupHold != null && !wtStartupHold.isCompleted) { + wtStartupHold.complete(); + } } } on PlaybackException catch (e, st) { appLogger.w('Playback initialization failed', error: e, stackTrace: st); diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index 519693c9..5894af61 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -1,13 +1,34 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { - /// Attach player to Watch Together session for playback sync - void _attachToWatchTogetherSession() { + /// Whether an active Watch Together session owns playback starts: media is + /// opened paused and the sync layer coordinates the (group) start. + bool _watchTogetherOwnsPlaybackStart() { + if (_isOfflinePlayback || widget.isLive) return false; + return _activeWatchTogetherSession() != null; + } + + /// Attach player to Watch Together session for playback sync. + /// + /// [startupHold] delays sync readiness until platform startup gates (e.g. + /// the Android frame-rate switch) release. + void _attachToWatchTogetherSession({Future? startupHold}) { try { final watchTogether = context.read(); _watchTogetherProvider = watchTogether; // Store reference for use in dispose - if (watchTogether.isInSession && player != null) { - watchTogether.attachPlayer(player!); + final serverId = _currentMetadata.serverId; + if (watchTogether.isInSession && player != null && serverId != null) { + watchTogether.attachPlayer( + player!, + ratingKey: _currentMetadata.id, + serverId: serverId, + mediaTitle: _currentMetadata.displayTitle, + hasFirstFrame: _hasFirstFrame.value, + startupHold: startupHold, + // Sync-issued seeks ride the screen's seek path so Plex transcode + // restarts keep working for out-of-buffer targets. + remoteSeek: _seekPlayback, + ); appLogger.d('WatchTogether: Player attached for sync'); // If guest, handle mediaSwitch internally for proper navigation context @@ -21,12 +42,13 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { } } - /// Detach player from Watch Together session + /// Detach player from Watch Together session (the user is leaving the + /// player, which ends the shared media epoch). void _detachFromWatchTogetherSession() { try { final watchTogether = _watchTogetherProvider ?? context.read(); if (watchTogether.isInSession) { - watchTogether.detachPlayer(); + watchTogether.detachPlayer(exiting: true); appLogger.d('WatchTogether: Player detached'); } watchTogether.onPlayerMediaSwitched = null; // Always clear player callback diff --git a/lib/watch_together/models/playback_state.dart b/lib/watch_together/models/playback_state.dart new file mode 100644 index 00000000..de758af4 --- /dev/null +++ b/lib/watch_together/models/playback_state.dart @@ -0,0 +1,265 @@ +import 'package:collection/collection.dart'; + +import 'watch_session.dart'; + +/// Playback lifecycle phase broadcast by the host. +/// +/// Serialized as the enum index — append new values only. +enum PlaybackPhase { loading, waitingForPeers, paused, playing } + +/// What caused a state transition (drives participant toasts). +/// +/// Serialized as the enum index — append new values only. +enum PlaybackActionHint { play, pause, seek, rate, mediaSwitch } + +/// Authoritative playback state, broadcast by the host on every transition +/// and as the periodic heartbeat. Receivers keep the highest [seq] seen and +/// drop anything older, so missed or reordered messages self-heal on the +/// next broadcast. +/// +/// A `phase == playing` state whose [anchorHostTimeMs] lies in the future is +/// a scheduled group start: [targetPositionMs] clamps elapsed time to >= 0, +/// so peers hold at [anchorPositionMs] until the start moment and then +/// extrapolate from a shared origin. +class PlaybackState { + final int seq; + final String ratingKey; + final String serverId; + final String? mediaTitle; + final PlaybackPhase phase; + + /// Timeline-adjusted position at [anchorHostTimeMs]. + final int anchorPositionMs; + + /// Host wall-clock time (Unix ms) the anchor was captured — or, when in + /// the future with `phase == playing`, the scheduled group-start moment. + final int anchorHostTimeMs; + + final double rate; + final ControlMode controlMode; + + /// Peers the room is currently waiting on (readiness or buffering). + final List waitingOn; + + /// Peer that caused this transition (host's own id for local actions). + final String? actorPeerId; + final PlaybackActionHint? actionHint; + + const PlaybackState({ + required this.seq, + required this.ratingKey, + required this.serverId, + required this.phase, + required this.anchorPositionMs, + required this.anchorHostTimeMs, + required this.rate, + required this.controlMode, + this.mediaTitle, + this.waitingOn = const [], + this.actorPeerId, + this.actionHint, + }); + + String get mediaKey => mediaKeyFor(ratingKey: ratingKey, serverId: serverId); + + static String mediaKeyFor({required String ratingKey, required String serverId}) => '$serverId:$ratingKey'; + + /// Where the room should be at [nowHostMs] (host clock). + int targetPositionMs(int nowHostMs) { + if (phase != PlaybackPhase.playing) return anchorPositionMs; + final elapsed = nowHostMs - anchorHostTimeMs; + if (elapsed <= 0) return anchorPositionMs; + return anchorPositionMs + (elapsed * rate).round(); + } + + PlaybackState copyWith({ + int? seq, + String? ratingKey, + String? serverId, + String? mediaTitle, + PlaybackPhase? phase, + int? anchorPositionMs, + int? anchorHostTimeMs, + double? rate, + ControlMode? controlMode, + List? waitingOn, + String? actorPeerId, + PlaybackActionHint? actionHint, + }) { + return PlaybackState( + seq: seq ?? this.seq, + ratingKey: ratingKey ?? this.ratingKey, + serverId: serverId ?? this.serverId, + mediaTitle: mediaTitle ?? this.mediaTitle, + phase: phase ?? this.phase, + anchorPositionMs: anchorPositionMs ?? this.anchorPositionMs, + anchorHostTimeMs: anchorHostTimeMs ?? this.anchorHostTimeMs, + rate: rate ?? this.rate, + controlMode: controlMode ?? this.controlMode, + waitingOn: waitingOn ?? this.waitingOn, + actorPeerId: actorPeerId ?? this.actorPeerId, + actionHint: actionHint ?? this.actionHint, + ); + } + + Map toMap() => { + 'q': seq, + 'rk': ratingKey, + 'sid': serverId, + if (mediaTitle != null) 'ti': mediaTitle, + 'ph': phase.index, + 'ap': anchorPositionMs, + 'at': anchorHostTimeMs, + 'r': rate, + 'cm': controlMode.index, + if (waitingOn.isNotEmpty) 'w': waitingOn, + if (actorPeerId != null) 'ab': actorPeerId, + if (actionHint != null) 'ah': actionHint!.index, + }; + + factory PlaybackState.fromMap(Map map) { + return PlaybackState( + seq: map['q'] as int, + ratingKey: map['rk'] as String, + serverId: map['sid'] as String, + mediaTitle: map['ti'] as String?, + phase: _enumFromIndex(PlaybackPhase.values, map['ph'] as int) ?? PlaybackPhase.paused, + anchorPositionMs: map['ap'] as int, + anchorHostTimeMs: map['at'] as int, + rate: (map['r'] as num).toDouble(), + controlMode: _enumFromIndex(ControlMode.values, map['cm'] as int) ?? ControlMode.hostOnly, + waitingOn: (map['w'] as List?)?.cast() ?? const [], + actorPeerId: map['ab'] as String?, + actionHint: map['ah'] != null ? _enumFromIndex(PlaybackActionHint.values, map['ah'] as int) : null, + ); + } + + @override + bool operator ==(Object other) => + other is PlaybackState && + other.seq == seq && + other.ratingKey == ratingKey && + other.serverId == serverId && + other.mediaTitle == mediaTitle && + other.phase == phase && + other.anchorPositionMs == anchorPositionMs && + other.anchorHostTimeMs == anchorHostTimeMs && + other.rate == rate && + other.controlMode == controlMode && + const ListEquality().equals(other.waitingOn, waitingOn) && + other.actorPeerId == actorPeerId && + other.actionHint == actionHint; + + @override + int get hashCode => Object.hash( + seq, + ratingKey, + serverId, + mediaTitle, + phase, + anchorPositionMs, + anchorHostTimeMs, + rate, + controlMode, + Object.hashAll(waitingOn), + actorPeerId, + actionHint, + ); + + @override + String toString() => + 'PlaybackState(seq: $seq, media: $mediaKey, phase: ${phase.name}, ' + 'anchor: ${anchorPositionMs}ms@$anchorHostTimeMs, rate: $rate, waitingOn: $waitingOn)'; +} + +/// A peer's report of its own player to the host. +class PeerStatus { + /// The media this peer currently has loaded or is loading. + final String mediaKey; + + /// File loaded and first frame rendered (plus startup gates cleared). + final bool ready; + final bool buffering; + final int positionMs; + + /// The peer's measured min RTT to the host (sizes scheduled-start delays). + final int? rttMs; + + const PeerStatus({ + required this.mediaKey, + required this.ready, + required this.buffering, + required this.positionMs, + this.rttMs, + }); + + Map toMap() => { + 'mk': mediaKey, + 'rdy': ready, + 'buf': buffering, + 'pos': positionMs, + if (rttMs != null) 'rtt': rttMs, + }; + + factory PeerStatus.fromMap(Map map) => PeerStatus( + mediaKey: map['mk'] as String, + ready: map['rdy'] as bool, + buffering: map['buf'] as bool, + positionMs: map['pos'] as int, + rttMs: map['rtt'] as int?, + ); + + @override + bool operator ==(Object other) => + other is PeerStatus && + other.mediaKey == mediaKey && + other.ready == ready && + other.buffering == buffering && + other.positionMs == positionMs && + other.rttMs == rttMs; + + @override + int get hashCode => Object.hash(mediaKey, ready, buffering, positionMs, rttMs); + + @override + String toString() => 'PeerStatus($mediaKey, ready: $ready, buffering: $buffering, pos: ${positionMs}ms)'; +} + +/// Serialized as the enum index — append new values only. +enum ControlRequestKind { play, pause, seek, rate } + +/// A guest's request for the host to apply a playback action (anyone mode). +class ControlRequest { + final ControlRequestKind kind; + final int? positionMs; + final double? rate; + + const ControlRequest({required this.kind, this.positionMs, this.rate}); + + Map toMap() => { + 'k': kind.index, + if (positionMs != null) 'pos': positionMs, + if (rate != null) 'r': rate, + }; + + factory ControlRequest.fromMap(Map map) => ControlRequest( + kind: _enumFromIndex(ControlRequestKind.values, map['k'] as int) ?? ControlRequestKind.pause, + positionMs: map['pos'] as int?, + rate: (map['r'] as num?)?.toDouble(), + ); + + @override + bool operator ==(Object other) => + other is ControlRequest && other.kind == kind && other.positionMs == positionMs && other.rate == rate; + + @override + int get hashCode => Object.hash(kind, positionMs, rate); + + @override + String toString() => 'ControlRequest(${kind.name}, pos: $positionMs, rate: $rate)'; +} + +/// Index-safe enum decode: out-of-range values (from a newer protocol +/// version) return null instead of throwing. +T? _enumFromIndex(List values, int index) => + index >= 0 && index < values.length ? values[index] : null; diff --git a/lib/watch_together/models/sync_message.dart b/lib/watch_together/models/sync_message.dart index 2e2dfa8f..7e26a9b6 100644 --- a/lib/watch_together/models/sync_message.dart +++ b/lib/watch_together/models/sync_message.dart @@ -1,26 +1,20 @@ import 'dart:convert'; -import 'watch_session.dart'; +import 'playback_state.dart'; -/// Types of sync messages sent over the WebRTC data channel +/// Types of sync messages sent over the relay data channel (protocol v2). enum SyncMessageType { - /// Start playback - play, + /// Authoritative playback state broadcast by the host + state, - /// Pause playback - pause, + /// A peer's player status report to the host + status, - /// Seek to position - seek, + /// A guest's playback control request to the host + control, - /// Buffering state changed - buffering, - - /// Periodic position update (for drift correction) - positionSync, - - /// Playback rate changed - rate, + /// Request the current playback state from the host + requestState, /// Participant joined the session join, @@ -28,45 +22,30 @@ enum SyncMessageType { /// Participant left the session leave, - /// Session configuration (sent by host on join) - sessionConfig, - - /// Ping for latency measurement + /// Ping for clock-offset measurement ping, /// Pong response pong, - /// Media switch (host changed content) - mediaSwitch, - /// Host exited the video player hostExitedPlayer, - - /// Player is ready (attached and loaded) - playerReady, - - /// Request session config from host (guest recovery) - requestSessionConfig, } -/// A message sent over the WebRTC data channel for synchronization +/// 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 = 2; + /// Type of this message final SyncMessageType type; - /// Timestamp when this message was created (Unix ms) + /// 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; - /// Position in milliseconds (for seek, positionSync) - final int? positionMs; - - /// Buffering state (for buffering message) - final bool? bufferingState; - - /// Playback rate (for rate message) - final double? rate; - /// Peer ID of the sender final String? peerId; @@ -76,98 +55,74 @@ class SyncMessage { /// Whether the sender is the host (for join message) final bool? isHost; - /// Control mode (for sessionConfig message) - final ControlMode? controlMode; - /// Ping ID for matching pong responses final int? pingId; - /// Rating key of the media (for mediaSwitch message) - final String? ratingKey; + /// Authoritative playback state (for state message) + final PlaybackState? state; - /// Server ID of the media (for mediaSwitch message) - final String? serverId; + /// Peer player status report (for status message) + final PeerStatus? status; - /// Title of the media (for mediaSwitch message) - final String? mediaTitle; + /// Playback control request (for control message) + final ControlRequest? control; - /// Whether playback is currently playing (for positionSync heartbeat) - final bool? isPlaying; + /// Sync protocol version (for join message) + final int? version; const SyncMessage({ required this.type, required this.timestamp, - this.positionMs, - this.bufferingState, - this.rate, this.peerId, this.displayName, this.isHost, - this.controlMode, this.pingId, - this.ratingKey, - this.serverId, - this.mediaTitle, - this.isPlaying, + this.state, + this.status, + this.control, + this.version, }); - /// Create a PLAY message - factory SyncMessage.play({String? peerId, Duration? position}) { + /// Create a STATE message carrying the host's authoritative playback state + factory SyncMessage.state(PlaybackState state, {String? peerId}) { return SyncMessage( - type: SyncMessageType.play, + type: SyncMessageType.state, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId, - positionMs: position?.inMilliseconds, + state: state, ); } - /// Create a PAUSE message - factory SyncMessage.pause({String? peerId}) { - return SyncMessage(type: SyncMessageType.pause, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId); + /// 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 SEEK message - factory SyncMessage.seek(Duration position, {String? peerId}) { + /// Create a CONTROL message requesting a playback action from the host + factory SyncMessage.control(ControlRequest control, {String? peerId}) { return SyncMessage( - type: SyncMessageType.seek, + 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, - positionMs: position.inMilliseconds, peerId: peerId, ); } - /// Create a BUFFERING message - factory SyncMessage.buffering(bool isBuffering, {String? peerId}) { - return SyncMessage( - type: SyncMessageType.buffering, - timestamp: DateTime.now().millisecondsSinceEpoch, - bufferingState: isBuffering, - peerId: peerId, - ); - } - - /// Create a POSITION_SYNC message (heartbeat with optional play/pause state) - factory SyncMessage.positionSync(Duration position, {String? peerId, bool? isPlaying}) { - return SyncMessage( - type: SyncMessageType.positionSync, - timestamp: DateTime.now().millisecondsSinceEpoch, - positionMs: position.inMilliseconds, - peerId: peerId, - isPlaying: isPlaying, - ); - } - - /// Create a RATE message - factory SyncMessage.rate(double playbackRate, {String? peerId}) { - return SyncMessage( - type: SyncMessageType.rate, - timestamp: DateTime.now().millisecondsSinceEpoch, - rate: playbackRate, - peerId: peerId, - ); - } - - /// Create a JOIN message + /// Create a JOIN message (carries the sender's protocol version) factory SyncMessage.join({required String peerId, required String displayName, required bool isHost}) { return SyncMessage( type: SyncMessageType.join, @@ -175,6 +130,7 @@ class SyncMessage { peerId: peerId, displayName: displayName, isHost: isHost, + version: protocolVersion, ); } @@ -183,44 +139,6 @@ class SyncMessage { return SyncMessage(type: SyncMessageType.leave, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId); } - /// Create a SESSION_CONFIG message (sent by host to new guests) - /// - /// Optionally includes current media info so guests can catch up - /// if they missed a mediaSwitch broadcast. - factory SyncMessage.sessionConfig({ - required ControlMode controlMode, - required Duration currentPosition, - required bool isPlaying, - required double playbackRate, - String? peerId, - String? ratingKey, - String? serverId, - String? mediaTitle, - }) { - return SyncMessage( - type: SyncMessageType.sessionConfig, - timestamp: DateTime.now().millisecondsSinceEpoch, - controlMode: controlMode, - positionMs: currentPosition.inMilliseconds, - isPlaying: isPlaying, - bufferingState: !isPlaying, // Legacy compat: false = playing, true = paused - rate: playbackRate, - peerId: peerId, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - ); - } - - /// Create a REQUEST_SESSION_CONFIG message (sent by guest to request current config from host) - factory SyncMessage.requestSessionConfig({String? peerId}) { - return SyncMessage( - type: SyncMessageType.requestSessionConfig, - timestamp: DateTime.now().millisecondsSinceEpoch, - peerId: peerId, - ); - } - /// Create a PING message factory SyncMessage.ping(int pingId, {String? peerId}) { return SyncMessage( @@ -241,23 +159,6 @@ class SyncMessage { ); } - /// Create a MEDIA_SWITCH message (sent by host when changing content) - factory SyncMessage.mediaSwitch({ - required String ratingKey, - required String serverId, - required String mediaTitle, - String? peerId, - }) { - return SyncMessage( - type: SyncMessageType.mediaSwitch, - timestamp: DateTime.now().millisecondsSinceEpoch, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - peerId: peerId, - ); - } - /// Create a HOST_EXITED_PLAYER message (sent by host when exiting video player) factory SyncMessage.hostExitedPlayer({String? peerId}) { return SyncMessage( @@ -267,59 +168,38 @@ class SyncMessage { ); } - /// Create a PLAYER_READY message (sent when player is attached and ready) - factory SyncMessage.playerReady({required String peerId, required bool ready}) { - return SyncMessage( - type: SyncMessageType.playerReady, - timestamp: DateTime.now().millisecondsSinceEpoch, - peerId: peerId, - bufferingState: ready, // Reuse bufferingState field for ready status - ); - } - - /// Position as Duration (convenience getter) - Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null; - SyncMessage copyWith({String? peerId}) { return SyncMessage( type: type, timestamp: timestamp, - positionMs: positionMs, - bufferingState: bufferingState, - rate: rate, peerId: peerId ?? this.peerId, displayName: displayName, isHost: isHost, - controlMode: controlMode, pingId: pingId, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - isPlaying: isPlaying, + state: state, + status: status, + control: control, + version: version, ); } - /// Serialize to JSON string for sending over data channel + /// Serialize to JSON string for sending over the data channel String toJson() { final map = {'t': type.name, 'ts': timestamp}; - if (positionMs != null) map['pos'] = positionMs; - if (bufferingState != null) map['buf'] = bufferingState; - if (rate != null) map['r'] = rate; if (peerId != null) map['pid'] = peerId; if (displayName != null) map['name'] = displayName; if (isHost != null) map['host'] = isHost; - if (controlMode != null) map['ctrl'] = controlMode!.index; if (pingId != null) map['ping'] = pingId; - if (ratingKey != null) map['rk'] = ratingKey; - if (serverId != null) map['sid'] = serverId; - if (mediaTitle != null) map['title'] = mediaTitle; - if (isPlaying != null) map['pl'] = isPlaying; + 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; return jsonEncode(map); } - /// Parse from JSON string received from data channel + /// Parse from JSON string received from the data channel factory SyncMessage.fromJson(String jsonString) { final map = jsonDecode(jsonString) as Map; @@ -330,24 +210,20 @@ class SyncMessage { return SyncMessage( type: type, timestamp: map['ts'] as int, - positionMs: map['pos'] as int?, - bufferingState: map['buf'] as bool?, - rate: (map['r'] as num?)?.toDouble(), peerId: map['pid'] as String?, displayName: map['name'] as String?, isHost: map['host'] as bool?, - controlMode: map['ctrl'] != null ? ControlMode.values[map['ctrl'] as int] : null, pingId: map['ping'] as int?, - ratingKey: map['rk'] as String?, - serverId: map['sid'] as String?, - mediaTitle: map['title'] as String?, - isPlaying: map['pl'] as bool?, + state: map['st'] != null ? PlaybackState.fromMap((map['st'] as Map).cast()) : null, + status: map['su'] != null ? PeerStatus.fromMap((map['su'] as Map).cast()) : null, + control: map['co'] != null ? ControlRequest.fromMap((map['co'] as Map).cast()) : null, + version: map['v'] as int?, ); } @override String toString() { - return 'SyncMessage(type: $type, timestamp: $timestamp, positionMs: $positionMs, ' - 'bufferingState: $bufferingState, rate: $rate, peerId: $peerId)'; + return 'SyncMessage(type: $type, timestamp: $timestamp, peerId: $peerId, ' + 'state: $state, status: $status, control: $control)'; } } diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 1d12971c..54202118 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -7,10 +7,11 @@ import 'package:flutter/foundation.dart'; import '../../mpv/mpv.dart'; import '../../services/settings_service.dart'; import '../../utils/app_logger.dart'; +import '../models/playback_state.dart'; import '../models/sync_message.dart'; import '../models/watch_session.dart'; +import '../services/watch_together_controller.dart'; import '../services/watch_together_peer_service.dart'; -import '../services/watch_together_sync_manager.dart'; /// Callback type for when media switches (for guest navigation) typedef MediaSwitchCallback = void Function(String ratingKey, ServerId serverId, String mediaTitle); @@ -26,10 +27,12 @@ typedef MediaSwitchCallback = void Function(String ratingKey, ServerId serverId, class WatchTogetherProvider with ChangeNotifier { WatchSession? _session; WatchTogetherPeerService? _peerService; - WatchTogetherSyncManager? _syncManager; + WatchTogetherController? _controller; final List _participants = []; bool _isSyncing = false; - bool _isDeferredPlay = false; + bool _isWaitingForPeers = false; + List _waitingOnPeerIds = const []; + PlaybackPhase? _playbackPhase; String _displayName = 'User'; String? _lastHandledCurrentPlaybackKey; @@ -87,15 +90,38 @@ class WatchTogetherProvider with ChangeNotifier { bool get isHost => _session?.isHost ?? false; bool get isConnected => _session?.isConnected ?? false; bool get isSyncing => _isSyncing; - bool get isDeferredPlay => _isDeferredPlay; WatchSession? get session => _session; List get participants => List.unmodifiable(_participants); int get participantCount => _participants.length; ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly; String? get sessionId => _session?.sessionId; - WatchTogetherSyncManager? get syncManager => _syncManager; bool get isWaitingForHostReconnect => _isWaitingForHostReconnect; + /// Whether the room is held up waiting on peers (readiness or stalls) — + /// drives the "Waiting for …" pill. + bool get isWaitingForPeers => _isWaitingForPeers; + + /// Display names of the peers the room is waiting on (excluding self). + List get waitingOnNames { + final myPeerId = _peerService?.myPeerId; + if (_waitingOnPeerIds.isEmpty) { + // Guests waiting on a still-loading host have an empty digest. + if (!isHost && _playbackPhase == PlaybackPhase.loading) { + final hostName = _participants.where((p) => p.isHost).map((p) => p.displayName).firstOrNull; + return [?hostName]; + } + return const []; + } + return [ + for (final peerId in _waitingOnPeerIds) + if (peerId != myPeerId) + _participants.where((p) => p.peerId == peerId).map((p) => p.displayName).firstOrNull ?? '?', + ]; + } + + /// Whether a player is currently attached to the sync controller. + bool get hasAttachedPlayer => _controller?.hasPlayer ?? false; + // Participant join/leave event stream final StreamController _participantEventController = StreamController.broadcast(); Stream get participantEvents => _participantEventController.stream; @@ -162,38 +188,96 @@ class WatchTogetherProvider with ChangeNotifier { } void requestCurrentPlaybackSnapshot() { - if (isHost || _peerService == null || _session == null || _peerService!.myPeerId == null) { - return; - } - - final request = SyncMessage.requestSessionConfig(peerId: _peerService!.myPeerId); - if (_session!.hostPeerId != null) { - appLogger.d('WatchTogether: Requesting current playback snapshot from host'); - _peerService!.sendTo(_session!.hostPeerId!, request); - } else { - appLogger.d('WatchTogether: Host peer unknown, broadcasting current playback snapshot request'); - _peerService!.broadcast(request); - } + if (isHost) return; + appLogger.d('WatchTogether: Requesting current playback state from host'); + _controller?.requestState(); } - /// Wire up reconnection handler to re-announce join and readiness after reconnect + /// Wire up reconnection handler to re-announce join and re-sync state void _wireReconnectHandler() { _peerService!.onReconnected = () { - _syncManager?.announceJoin(_displayName); - _syncManager?.reannounceReadyIfNeeded(); + _controller?.announceJoin(_displayName); + _controller?.onReconnected(); }; } - /// Wire up sync manager's state change callback to update provider state - void _wireSyncStateChanges() { - _syncManager!.onSyncStateChanged = (isSyncing) { - _isSyncing = isSyncing; + /// Wire the controller's callbacks into provider/UI state + void _wireController() { + final controller = _controller!; + + controller.onCorrectingChanged = (correcting) { + _isSyncing = correcting; notifyListeners(); }; - _syncManager!.onDeferredPlayChanged = (isDeferredPlay) { - _isDeferredPlay = isDeferredPlay; + + controller.onPhaseChanged = (phase) { + _playbackPhase = phase; + _updateWaitingState(); + }; + + controller.onWaitingOnChanged = (peerIds) { + _waitingOnPeerIds = peerIds; + for (var i = 0; i < _participants.length; i++) { + final isWaitedOn = peerIds.contains(_participants[i].peerId); + if (_participants[i].isBuffering != isWaitedOn) { + _participants[i] = _participants[i].copyWith(isBuffering: isWaitedOn); + if (isWaitedOn) { + _emitActionEvent(_participants[i].peerId, ParticipantEventType.buffering); + } + } + } + _updateWaitingState(); + }; + + controller.onControlModeReceived = (mode) { + if (isHost || _session == null) return; + if (_session!.controlMode == mode) return; + _session = _session!.copyWith(controlMode: mode); + controller.updateSession(_session!); notifyListeners(); }; + + controller.onMediaStateReceived = _handleMediaStateReceived; + + controller.onRemoteAction = (peerId, hint) { + final type = switch (hint) { + PlaybackActionHint.play => ParticipantEventType.resumed, + PlaybackActionHint.pause => ParticipantEventType.paused, + PlaybackActionHint.seek => ParticipantEventType.seeked, + PlaybackActionHint.rate || PlaybackActionHint.mediaSwitch => null, + }; + if (type != null) _emitActionEvent(peerId, type); + }; + + controller.onPeerNeedsUpdate = (peerId) { + final name = _participants.where((p) => p.peerId == peerId).map((p) => p.displayName).firstOrNull; + _participantEventController.add( + ParticipantEvent(displayName: name ?? peerId, type: ParticipantEventType.needsUpdate), + ); + }; + + controller.onResumedWithout = (peerIds) { + for (final peerId in peerIds) { + final name = _participants.where((p) => p.peerId == peerId).map((p) => p.displayName).firstOrNull; + if (name != null) { + _participantEventController.add( + ParticipantEvent(displayName: name, type: ParticipantEventType.resumedWithout), + ); + } + } + }; + } + + void _updateWaitingState() { + final phase = _playbackPhase; + final waiting = + phase == PlaybackPhase.waitingForPeers || + // Guests waiting on a still-loading host (no digest in that phase). + (!isHost && phase == PlaybackPhase.loading && hasCurrentPlayback); + if (waiting != _isWaitingForPeers) { + _isWaitingForPeers = waiting; + } + notifyListeners(); } /// Create a new watch together session as host @@ -230,13 +314,9 @@ class WatchTogetherProvider with ChangeNotifier { _displayName = displayName ?? _generateDisplayName(); _participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true)); - _syncManager = WatchTogetherSyncManager( - peerService: _peerService!, - session: _session!, - displayName: _displayName, - ); + _controller = WatchTogetherController(peerService: _peerService!, session: _session!); - _wireSyncStateChanges(); + _wireController(); _wireReconnectHandler(); notifyListeners(); @@ -274,26 +354,16 @@ class WatchTogetherProvider with ChangeNotifier { _displayName = displayName ?? _generateDisplayName(); - _syncManager = WatchTogetherSyncManager( - peerService: _peerService!, - session: _session!, - displayName: _displayName, - ); + _controller = WatchTogetherController(peerService: _peerService!, session: _session!); - _syncManager!.onSessionConfigReceived = (controlMode) { - _session = _session!.copyWith(controlMode: controlMode); - _syncManager!.updateSession(_session!); - notifyListeners(); - }; - - _wireSyncStateChanges(); + _wireController(); _wireReconnectHandler(); // Add self to participants _participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false)); // Announce join to other participants - _syncManager!.announceJoin(_displayName); + _controller!.announceJoin(_displayName); requestCurrentPlaybackSnapshot(); notifyListeners(); @@ -346,7 +416,7 @@ class WatchTogetherProvider with ChangeNotifier { appLogger.d('WatchTogether: Leaving session'); // Announce leave if connected - _syncManager?.announceLeave(); + _controller?.announceLeave(); // Clean up subscriptions unawaited(_peerConnectedSubscription?.cancel()); @@ -363,8 +433,8 @@ class WatchTogetherProvider with ChangeNotifier { _cancelHostReconnectGracePeriod(); // Clean up services - _syncManager?.dispose(); - _syncManager = null; + _controller?.dispose(); + _controller = null; await _peerService?.disconnect(); _peerService?.dispose(); @@ -373,7 +443,9 @@ class WatchTogetherProvider with ChangeNotifier { _session = null; _participants.clear(); _isSyncing = false; - _isDeferredPlay = false; + _isWaitingForPeers = false; + _waitingOnPeerIds = const []; + _playbackPhase = null; _lastHandledCurrentPlaybackKey = null; _lastActionEventMs.clear(); _hostIntentionallyLeft = false; @@ -382,30 +454,47 @@ class WatchTogetherProvider with ChangeNotifier { appLogger.d('WatchTogether: Session left'); } - /// Attach a player to the sync manager - void attachPlayer(Player player) { - if (_syncManager == null) { - appLogger.w('WatchTogether: Cannot attach player - no sync manager'); + /// Attach a player to the sync controller for the given media. + /// + /// [hasFirstFrame] is the screen's first-frame snapshot, [startupHold] + /// delays sync readiness past platform startup gates (frame-rate switch), + /// and [remoteSeek] routes sync-issued seeks through the screen's seek + /// path (Plex transcode restarts). + void attachPlayer( + Player player, { + required String ratingKey, + required String serverId, + String? mediaTitle, + bool hasFirstFrame = false, + Future? startupHold, + Future Function(Duration target)? remoteSeek, + }) { + if (_controller == null) { + appLogger.w('WatchTogether: Cannot attach player - no sync controller'); return; } - // Initialize sync manager with existing participants (may have joined before player attached) - final peerIds = _participants.map((p) => p.peerId).toList(); - _syncManager!.initializeParticipants(peerIds); - - _syncManager!.attachPlayer(player); - appLogger.d('WatchTogether: Player attached to sync manager'); + _controller!.attachPlayer( + player, + ratingKey: ratingKey, + serverId: serverId, + mediaTitle: mediaTitle, + hasFirstFrame: hasFirstFrame, + startupHold: startupHold, + remoteSeek: remoteSeek, + ); } - /// Detach the player from the sync manager - void detachPlayer() { - _syncManager?.detachPlayer(); - appLogger.d('WatchTogether: Player detached from sync manager'); + /// Detach the player from the sync controller. [exiting] means the user + /// left the video player (ends the media epoch); episode switches detach + /// without exiting. + void detachPlayer({bool exiting = false}) { + _controller?.detachPlayer(exiting: exiting); } - /// Suppress position sync while the app is backgrounded. + /// Suppress sync heartbeats/corrections while the app is backgrounded. void setBackgrounded(bool value) { - _syncManager?.setBackgrounded(value); + _controller?.setBackgrounded(value); } /// Set up listeners for peer service events @@ -432,8 +521,8 @@ class WatchTogetherProvider with ChangeNotifier { // Capture display name before removal for notification final disconnectedName = _participants.where((p) => p.peerId == peerId).map((p) => p.displayName).firstOrNull; + // The sync controller observes peer disconnects itself. _participants.removeWhere((p) => p.peerId == peerId); - unawaited(_syncManager?.handlePeerDisconnected(peerId)); // If host disconnected unexpectedly, start grace period for reconnection. // Skip if the host already sent a deliberate leave message. @@ -525,61 +614,13 @@ class WatchTogetherProvider with ChangeNotifier { } break; - case SyncMessageType.buffering: - if (message.peerId != null) { - final index = _participants.indexWhere((p) => p.peerId == message.peerId); - if (index >= 0) { - final newState = message.bufferingState ?? false; - if (_participants[index].isBuffering != newState) { - _participants[index] = _participants[index].copyWith(isBuffering: newState); - if (newState) { - _emitActionEvent(message.peerId, ParticipantEventType.buffering); - } - notifyListeners(); - } - } - } - break; - - case SyncMessageType.positionSync: - if (message.peerId != null && message.position != null) { - final index = _participants.indexWhere((p) => p.peerId == message.peerId); - if (index >= 0) { - _participants[index] = _participants[index].copyWith(lastKnownPosition: message.position!); - // Don't notify for position updates - too frequent - } - } - break; - - case SyncMessageType.mediaSwitch: - _handleMediaSwitch(message); - break; - case SyncMessageType.hostExitedPlayer: _handleHostExitedPlayer(message); break; - case SyncMessageType.sessionConfig: - _handleSessionConfig(message); - break; - - case SyncMessageType.requestSessionConfig: - // Handled at sync manager level (host responds with config) - break; - - case SyncMessageType.play: - _emitActionEvent(message.peerId, ParticipantEventType.resumed); - break; - - case SyncMessageType.pause: - _emitActionEvent(message.peerId, ParticipantEventType.paused); - break; - - case SyncMessageType.seek: - _emitActionEvent(message.peerId, ParticipantEventType.seeked); - break; - default: + // Playback sync messages (state/status/control/...) are handled by + // the session controller. break; } } @@ -600,43 +641,31 @@ class WatchTogetherProvider with ChangeNotifier { } } - /// Handle session config from host (guest only) - /// This is handled at provider level so it's processed even before player is attached - void _handleSessionConfig(SyncMessage message) { - if (isHost) return; // Host doesn't need to process config + /// Handle current-media info carried in the host's playback state + /// (guest only). Processed even when no player is attached so guests can + /// navigate into (or between) playback. + void _handleMediaStateReceived(String ratingKey, String serverId, String? mediaTitle) { + if (isHost) return; - if (message.controlMode != null) { - appLogger.d('WatchTogether: Received session config, controlMode: ${message.controlMode}'); - _session = _session!.copyWith(controlMode: message.controlMode!); - _syncManager?.updateSession(_session!); // Update sync manager if it exists - notifyListeners(); - } + final playbackKey = _buildPlaybackKey(ratingKey, serverIdOrNull(serverId)); + final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; - if (message.ratingKey != null && message.serverId != null && message.mediaTitle != null) { - final playbackKey = _buildPlaybackKey(message.ratingKey, serverIdOrNull(message.serverId)); - final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; + _updateCurrentPlaybackSnapshot(ratingKey: ratingKey, serverId: ServerId(serverId), mediaTitle: mediaTitle ?? ''); + notifyListeners(); - _updateCurrentPlaybackSnapshot( - ratingKey: message.ratingKey!, - serverId: ServerId(message.serverId!), - mediaTitle: message.mediaTitle!, + if (shouldDispatch) { + _dispatchCurrentPlayback( + ratingKey: ratingKey, + serverId: ServerId(serverId), + mediaTitle: mediaTitle ?? '', + source: 'playback state', ); - notifyListeners(); - - if (shouldDispatch) { - _dispatchCurrentPlayback( - ratingKey: message.ratingKey!, - serverId: ServerId(message.serverId!), - mediaTitle: message.mediaTitle!, - source: 'session config', - ); - } } } - /// Called when user seeks locally (to broadcast to peers) + /// Called when user seeks locally (to sync with peers) void onLocalSeek(Duration position) { - _syncManager?.onLocalSeek(position); + _controller?.onLocalSeek(position); } /// Whether the current user can control playback @@ -661,52 +690,12 @@ class WatchTogetherProvider with ChangeNotifier { // Update session with new media info _session = _session!.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle); - // Broadcast media switch to all guests - _peerService!.broadcast( - SyncMessage.mediaSwitch( - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - peerId: _peerService!.myPeerId, - ), - ); + // The controller broadcasts the new media epoch in its playback state. + _controller?.setCurrentMedia(ratingKey: ratingKey, serverId: serverId, mediaTitle: mediaTitle); notifyListeners(); } - /// Handle media switch message from host (guest only) - void _handleMediaSwitch(SyncMessage message) { - if (isHost) return; // Host doesn't need to handle their own switch - - if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) { - appLogger.w('WatchTogether: Received incomplete media switch message'); - return; - } - - final playbackKey = _buildPlaybackKey(message.ratingKey, serverIdOrNull(message.serverId)); - final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; - - _updateCurrentPlaybackSnapshot( - ratingKey: message.ratingKey!, - serverId: ServerId(message.serverId!), - mediaTitle: message.mediaTitle!, - ); - notifyListeners(); - - if (!shouldDispatch) { - appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}'); - return; - } - - appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}'); - _dispatchCurrentPlayback( - ratingKey: message.ratingKey!, - serverId: ServerId(message.serverId!), - mediaTitle: message.mediaTitle!, - source: 'media switch', - ); - } - /// Notify guests that host is exiting the video player /// /// Call this from video player dispose when host exits. @@ -782,7 +771,7 @@ class WatchTogetherProvider with ChangeNotifier { } /// Type of participant event -enum ParticipantEventType { joined, left, paused, resumed, seeked, buffering } +enum ParticipantEventType { joined, left, paused, resumed, seeked, buffering, needsUpdate, resumedWithout } /// Event emitted when a participant joins or leaves class ParticipantEvent { diff --git a/lib/watch_together/services/attached_player.dart b/lib/watch_together/services/attached_player.dart new file mode 100644 index 00000000..154d44dd --- /dev/null +++ b/lib/watch_together/services/attached_player.dart @@ -0,0 +1,259 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../mpv/mpv.dart'; +import '../../utils/app_logger.dart'; + +enum _ExpectationKind { playing, rate } + +class _Expectation { + final _ExpectationKind kind; + final bool? playingValue; + final double? rateValue; + final int deadlineMs; + + _Expectation.playing(bool value, this.deadlineMs) + : kind = _ExpectationKind.playing, + playingValue = value, + rateValue = null; + + _Expectation.rate(double value, this.deadlineMs) + : kind = _ExpectationKind.rate, + playingValue = null, + rateValue = value; +} + +/// One player attachment to a Watch Together session. +/// +/// Wraps the screen's [Player] with: +/// - **Guarded commands** that survive player teardown races: recoverable +/// failures ([StateError], `COMMAND_FAILED`/`NOT_INITIALIZED` +/// [PlatformException]s) report `false` and fire [AttachedPlayer.new]'s +/// `onLost` once instead of throwing. +/// - An **expected-state ledger** separating command acks from user intents +/// on the playing/rate streams. Property events arrive *after* the command +/// future resolves, so a boolean "remote action in progress" flag misses +/// them; the ledger matches observed transitions against outstanding +/// expectations instead. +/// - Fresh snapshot reads for sync math ([position] uses +/// [Player.currentPosition], not the throttled state). +/// +/// The session controller creates one instance per attachment and disposes +/// it on detach — instance lifecycle *is* the staleness guard. +class AttachedPlayer { + AttachedPlayer({required Player player, required this._onLost, this._remoteSeek, int Function()? nowMs}) + : _player = player, + _nowMs = nowMs ?? _systemNowMs { + _lastPlaying = player.state.playing; + _lastBuffering = player.state.buffering; + _lastRate = player.state.rate; + + _subscriptions.add(player.streams.playing.listen(_onPlayingEvent)); + _subscriptions.add(player.streams.buffering.listen(_onBufferingEvent)); + _subscriptions.add(player.streams.rate.listen(_onRateEvent)); + _subscriptions.add( + player.streams.playbackRestart.listen((_) { + if (!_disposed) _loadedSignalsController.add(null); + }), + ); + } + + static int _systemNowMs() => DateTime.now().millisecondsSinceEpoch; + + /// How long an issued command may wait for its property event before the + /// expectation is considered dead (covers silently-swallowed commands). + static const int _expectationTtlMs = 3000; + + final Player _player; + final void Function() _onLost; + final Future Function(Duration target)? _remoteSeek; + final int Function() _nowMs; + + final List> _subscriptions = []; + final List<_Expectation> _expectations = []; + + final _playingIntentsController = StreamController.broadcast(); + final _rateIntentsController = StreamController.broadcast(); + final _bufferingChangesController = StreamController.broadcast(); + final _loadedSignalsController = StreamController.broadcast(); + + late bool _lastPlaying; + late bool _lastBuffering; + late double _lastRate; + bool _disposed = false; + bool _lostFired = false; + + /// User-initiated play/pause transitions (command acks are filtered out). + Stream get playingIntents => _playingIntentsController.stream; + + /// User-initiated rate changes (command acks are filtered out). + Stream get rateIntents => _rateIntentsController.stream; + + /// Raw buffering transitions (`paused-for-cache`). + Stream get bufferingChanges => _bufferingChangesController.stream; + + /// `playback-restart` events: first frame rendered after load and after + /// every seek. + Stream get loadedSignals => _loadedSignalsController.stream; + + bool get usable => !_disposed && !_player.disposed; + + // Fresh snapshots. + Duration get position => _player.currentPosition; + bool get playing => _player.state.playing; + bool get buffering => _player.state.buffering; + bool get completed => _player.state.completed; + bool get seekable => _player.state.seekable; + Duration get duration => _player.state.duration; + double get rate => _player.state.rate; + bool get passthroughActive => _player.audioPassthroughActive; + + /// Demuxer cache ahead of the playhead, or null when the backend hasn't + /// reported a cache position. + Duration? get bufferAhead { + final buffer = _player.state.buffer; + if (buffer == Duration.zero) return null; + final ahead = buffer - position; + return ahead.isNegative ? Duration.zero : ahead; + } + + /// Start or resume playback. Records a ledger expectation so the resulting + /// playing event is consumed as an ack. + Future play() { + final expectation = _expect(_Expectation.playing(true, _nowMs() + _expectationTtlMs)); + return _guarded('play', (player) => player.play(), expectation); + } + + Future pause() { + final expectation = _expect(_Expectation.playing(false, _nowMs() + _expectationTtlMs)); + return _guarded('pause', (player) => player.pause(), expectation); + } + + Future setRate(double rate) { + final expectation = _expect(_Expectation.rate(rate, _nowMs() + _expectationTtlMs)); + return _guarded('setRate', (player) => player.setRate(rate), expectation); + } + + /// Seek issued by the sync layer. Routed through the screen's seek + /// delegate when provided (Plex transcode restarts need the full path), + /// falling back to a plain player seek. + Future seek(Duration target) { + return _guarded('seek', (player) async { + final delegate = _remoteSeek; + if (delegate != null) { + try { + await delegate(target); + return; + } catch (e) { + appLogger.w('AttachedPlayer: seek delegate failed, falling back to player.seek', error: e); + } + } + await player.seek(target); + }); + } + + _Expectation _expect(_Expectation expectation) { + _expectations.add(expectation); + return expectation; + } + + Future _guarded( + String actionName, + Future Function(Player player) command, [ + _Expectation? expectation, + ]) async { + if (!usable) { + _expectations.remove(expectation); + _handleLost(actionName, StateError('Player became unavailable')); + return false; + } + + try { + await command(_player); + } on StateError catch (e) { + _expectations.remove(expectation); + _handleLost(actionName, e); + return false; + } on PlatformException catch (e) { + _expectations.remove(expectation); + if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { + _handleLost(actionName, e); + return false; + } + rethrow; + } + + if (!usable) { + _expectations.remove(expectation); + if (!_disposed) _handleLost(actionName, StateError('Player became unavailable')); + return false; + } + return true; + } + + void _handleLost(String actionName, Object error) { + if (_disposed || _lostFired) return; + _lostFired = true; + appLogger.w('AttachedPlayer: $actionName failed because the player became unavailable', error: error); + _onLost(); + } + + void _pruneExpired() { + final now = _nowMs(); + _expectations.removeWhere((e) => now > e.deadlineMs); + } + + bool _consumePlayingExpectation(bool value) { + _pruneExpired(); + final index = _expectations.indexWhere((e) => e.kind == _ExpectationKind.playing && e.playingValue == value); + if (index < 0) return false; + _expectations.removeAt(index); + return true; + } + + bool _consumeRateExpectation(double value) { + _pruneExpired(); + final index = _expectations.indexWhere( + (e) => e.kind == _ExpectationKind.rate && (e.rateValue! - value).abs() < 0.001, + ); + if (index < 0) return false; + _expectations.removeAt(index); + return true; + } + + void _onPlayingEvent(bool value) { + if (_disposed || value == _lastPlaying) return; + _lastPlaying = value; + if (_consumePlayingExpectation(value)) return; + _playingIntentsController.add(value); + } + + void _onRateEvent(double value) { + if (_disposed || value == _lastRate) return; + _lastRate = value; + if (_consumeRateExpectation(value)) return; + _rateIntentsController.add(value); + } + + void _onBufferingEvent(bool value) { + if (_disposed || value == _lastBuffering) return; + _lastBuffering = value; + _bufferingChangesController.add(value); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + _expectations.clear(); + final subscriptions = List>.of(_subscriptions); + _subscriptions.clear(); + for (final subscription in subscriptions) { + unawaited(subscription.cancel()); + } + await _playingIntentsController.close(); + await _rateIntentsController.close(); + await _bufferingChangesController.close(); + await _loadedSignalsController.close(); + } +} diff --git a/lib/watch_together/services/clock_sync.dart b/lib/watch_together/services/clock_sync.dart new file mode 100644 index 00000000..c88c77e3 --- /dev/null +++ b/lib/watch_together/services/clock_sync.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import '../../utils/app_logger.dart'; + +/// NTP-style clock-offset estimation against the session host (guest side). +/// +/// Sends pings through [sendPing] (the controller wraps them into sync +/// messages addressed to the host) and consumes pongs via [onPong]. Keeps a +/// rolling window of samples and reports the offset of the lowest-RTT sample +/// — a single clean exchange beats an average polluted by jittery ones. +/// +/// All time reads go through the injected [nowMs] so tests can virtualize +/// time alongside `fakeAsync`. +class ClockSync { + ClockSync({required this._sendPing, int Function()? nowMs}) : _nowMs = nowMs ?? _systemNowMs; + + static int _systemNowMs() => DateTime.now().millisecondsSinceEpoch; + + static const int _windowSize = 8; + static const int _maxAcceptedRttMs = 1000; + static const Duration _interval = Duration(seconds: 5); + static const Duration _burstSpacing = Duration(milliseconds: 500); + static const int _burstCount = 3; + static const int _pendingExpiryMs = 10000; + + final void Function(int pingId) _sendPing; + final int Function() _nowMs; + + /// In-flight pings: pingId -> local send time. Multiple may be pending. + final Map _pending = {}; + + /// Accepted samples, oldest first. + final List<({int offsetMs, int rttMs})> _samples = []; + + Timer? _timer; + Timer? _burstTimer; + bool _started = false; + + /// How far ahead the host's clock is vs ours, or null before any sample. + int? get offsetMs => _best?.offsetMs; + + /// Lowest RTT to the host in the sample window, or null before any sample. + int? get minRttMs => _best?.rttMs; + + ({int offsetMs, int rttMs})? get _best { + if (_samples.isEmpty) return null; + var best = _samples.first; + for (final sample in _samples.skip(1)) { + if (sample.rttMs < best.rttMs) best = sample; + } + return best; + } + + /// Local time translated into the host's clock (identity until a sample + /// arrives — callers needing a guarantee should check [offsetMs]). + int hostNowMs() => _nowMs() + (offsetMs ?? 0); + + /// Begin measuring: a short convergence burst, then a steady interval. + void start() { + if (_started) return; + _started = true; + + var sent = 0; + _ping(); + sent++; + _burstTimer = Timer.periodic(_burstSpacing, (timer) { + if (sent >= _burstCount) { + timer.cancel(); + return; + } + _ping(); + sent++; + }); + + _timer = Timer.periodic(_interval, (_) => _ping()); + } + + void stop() { + _started = false; + _timer?.cancel(); + _timer = null; + _burstTimer?.cancel(); + _burstTimer = null; + _pending.clear(); + } + + void _ping() { + final now = _nowMs(); + _pending.removeWhere((_, sentAt) => now - sentAt > _pendingExpiryMs); + // The ping id doubles as the send timestamp; nudge to keep ids unique + // when two pings land on the same millisecond. + var pingId = now; + while (_pending.containsKey(pingId)) { + pingId++; + } + _pending[pingId] = now; + _sendPing(pingId); + } + + /// Feed a pong from the host. [remoteTimestampMs] is the host's clock when + /// it created the pong. + void onPong(int pingId, int remoteTimestampMs) { + final sentAt = _pending.remove(pingId); + if (sentAt == null) return; // Not ours or already expired. + + final now = _nowMs(); + final rtt = now - sentAt; + if (rtt < 0 || rtt > _maxAcceptedRttMs) { + appLogger.d('ClockSync: discarding sample with RTT=${rtt}ms'); + return; + } + + final offset = remoteTimestampMs - sentAt - (rtt ~/ 2); + _samples.add((offsetMs: offset, rttMs: rtt)); + if (_samples.length > _windowSize) { + _samples.removeAt(0); + } + } +} diff --git a/lib/watch_together/services/guest_playback_reconciler.dart b/lib/watch_together/services/guest_playback_reconciler.dart new file mode 100644 index 00000000..968d1c02 --- /dev/null +++ b/lib/watch_together/services/guest_playback_reconciler.dart @@ -0,0 +1,598 @@ +import 'dart:async'; + +import '../../utils/app_logger.dart'; +import '../models/playback_state.dart'; +import '../models/sync_message.dart'; +import '../models/watch_session.dart'; +import 'attached_player.dart'; +import 'clock_sync.dart'; + +/// Callbacks the reconciler surfaces to the provider/UI layer. +class GuestReconcilerCallbacks { + /// The host's state names media we don't have loaded — navigate/reload. + final void Function(String ratingKey, String serverId, String? mediaTitle)? onMediaSwitchNeeded; + + final void Function(ControlMode mode)? onControlModeChanged; + final void Function(PlaybackPhase phase)? onPhaseChanged; + final void Function(List waitingOn)? onWaitingOnChanged; + + /// A hard correction is in flight (drives the syncing pill). + final void Function(bool correcting)? onCorrectingChanged; + + /// Another peer caused a transition (drives action toasts). + final void Function(String peerId, PlaybackActionHint hint)? onRemoteAction; + + const GuestReconcilerCallbacks({ + this.onMediaSwitchNeeded, + this.onControlModeChanged, + this.onPhaseChanged, + this.onWaitingOnChanged, + this.onCorrectingChanged, + this.onRemoteAction, + }); +} + +/// Guest-side reconciliation loop: converges the local player onto the +/// host's authoritative [PlaybackState]. +/// +/// Small drift is corrected invisibly with a brief playback-rate nudge +/// (skipped while audio passthrough is active — rate changes tear bitstream +/// output down); large drift hard-seeks with a post-seek settle window so we +/// never measure mid-seek positions. Local user actions become +/// [ControlRequest]s in anyone-mode (with a short optimistic window so the +/// next heartbeat doesn't undo them before the host confirms) and snap back +/// in host-only mode. +class GuestPlaybackReconciler { + GuestPlaybackReconciler({ + required this.myPeerId, + required this._sendToHost, + required ClockSync clockSync, + this._callbacks = const GuestReconcilerCallbacks(), + int Function()? nowMs, + }) : _clock = clockSync, + _nowMs = nowMs ?? _systemNowMs; + + static int _systemNowMs() => DateTime.now().millisecondsSinceEpoch; + + // Tuning constants. + static const int tickMs = 500; + static const int deadbandMs = 350; + static const int nudgeExitMs = 150; + static const double nudgeFactor = 0.04; + static const int hardSeekThresholdMs = 2000; + static const int hardSeekLeadMs = 250; + static const int hardSeekCooldownMs = 2000; + static const int pausedSeekThresholdMs = 500; + static const int settleExtraMs = 250; + static const int settleTimeoutMs = 1500; + static const int optimisticWindowMs = 2000; + static const int nudgeConfirmMs = 500; + static const int bufferingStatusRefreshMs = 5000; + static const int eofClampMs = 200; + static const int eofToleranceMs = 1000; + + final String myPeerId; + final void Function(SyncMessage message) _sendToHost; + final ClockSync _clock; + final GuestReconcilerCallbacks _callbacks; + final int Function() _nowMs; + + PlaybackState? _latestState; + int _lastSeq = -1; + PlaybackPhase? _reportedPhase; + List _reportedWaitingOn = const []; + ControlMode? _reportedControlMode; + + AttachedPlayer? _player; + final List> _playerSubscriptions = []; + String? _attachedMediaKey; + bool _localReady = false; + bool _firstFrameSeen = false; + bool _startupHoldResolved = true; + + Timer? _tickTimer; + bool _backgrounded = false; + bool _disposed = false; + + // Correction state. + bool _settling = false; + Timer? _settleTimer; + bool _correcting = false; + bool _nudging = false; + bool _nudgeDisabled = false; + bool _nudgeConfirmed = false; + Timer? _nudgeConfirmTimer; + int _lastHardSeekMs = -hardSeekCooldownMs; + final List _driftSamples = []; + + // Scheduled group start. + Timer? _scheduledStartTimer; + int? _scheduledStartSeq; + + // Optimistic window after sending a control request. + int? _optimisticUntilSeq; + int _optimisticDeadlineMs = 0; + + // Status reporting. + PeerStatus? _lastSentStatus; + Timer? _statusRefreshTimer; + + PlaybackState? get latestState => _latestState; + bool get isCorrecting => _correcting; + + // --------------------------------------------------------------------- + // Public inputs + // --------------------------------------------------------------------- + + void attach( + AttachedPlayer player, { + required String ratingKey, + required String serverId, + bool hasFirstFrame = false, + Future? startupHold, + }) { + detachPlayer(); + _player = player; + _attachedMediaKey = PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId); + _firstFrameSeen = hasFirstFrame; + _startupHoldResolved = startupHold == null; + + if (startupHold != null) { + startupHold.then((_) { + if (_disposed || !identical(_player, player)) return; + _startupHoldResolved = true; + _maybeBecomeReady(); + }); + } + + _playerSubscriptions.add( + player.loadedSignals.listen((_) { + if (_settling) { + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: settleExtraMs), _endSettle); + } + if (!_firstFrameSeen) { + _firstFrameSeen = true; + _maybeBecomeReady(); + } + }), + ); + _playerSubscriptions.add( + player.bufferingChanges.listen((_) { + _sendStatus(); + }), + ); + _playerSubscriptions.add(player.playingIntents.listen(_onLocalPlayingIntent)); + _playerSubscriptions.add(player.rateIntents.listen(_onLocalRateIntent)); + + _tickTimer = Timer.periodic(const Duration(milliseconds: tickMs), (_) => _onTick()); + if (_firstFrameSeen && _startupHoldResolved) { + _localReady = true; + appLogger.d('WatchTogether: Guest player ready for $_attachedMediaKey'); + } + _sendStatus(); + if (_localReady) _reconcile(); + } + + void _maybeBecomeReady() { + if (_localReady || !_firstFrameSeen || !_startupHoldResolved) return; + _localReady = true; + appLogger.d('WatchTogether: Guest player ready for $_attachedMediaKey'); + _sendStatus(); + _reconcile(); + } + + void detachPlayer() { + for (final subscription in _playerSubscriptions) { + unawaited(subscription.cancel()); + } + _playerSubscriptions.clear(); + + // Tell the host we're no longer ready on this media (it re-gates us for + // the next epoch start instead of waiting on a stale "ready"). + if (_player != null && _attachedMediaKey != null) { + _lastSentStatus = null; + _sendToHost( + SyncMessage.status( + PeerStatus(mediaKey: _attachedMediaKey!, ready: false, buffering: false, positionMs: 0), + peerId: myPeerId, + ), + ); + } + + _player = null; + _attachedMediaKey = null; + _localReady = false; + _firstFrameSeen = false; + _startupHoldResolved = true; + _tickTimer?.cancel(); + _tickTimer = null; + _settleTimer?.cancel(); + _settleTimer = null; + _settling = false; + _nudging = false; + _nudgeConfirmTimer?.cancel(); + _nudgeConfirmTimer = null; + _scheduledStartTimer?.cancel(); + _scheduledStartTimer = null; + _scheduledStartSeq = null; + _statusRefreshTimer?.cancel(); + _statusRefreshTimer = null; + _driftSamples.clear(); + _setCorrecting(false); + } + + /// Latest authoritative state from the host (already host-authenticated). + void onState(PlaybackState state) { + if (state.seq <= _lastSeq) return; // Stale or reordered. + _lastSeq = state.seq; + final previous = _latestState; + _latestState = state; + + if (state.controlMode != _reportedControlMode) { + _reportedControlMode = state.controlMode; + _callbacks.onControlModeChanged?.call(state.controlMode); + } + if (state.phase != _reportedPhase) { + _reportedPhase = state.phase; + _callbacks.onPhaseChanged?.call(state.phase); + } + if (!_listEquals(state.waitingOn, _reportedWaitingOn)) { + _reportedWaitingOn = state.waitingOn; + _callbacks.onWaitingOnChanged?.call(state.waitingOn); + } + if (state.actionHint != null && state.actorPeerId != null && state.actorPeerId != myPeerId) { + _callbacks.onRemoteAction?.call(state.actorPeerId!, state.actionHint!); + } + + // Close the optimistic window only on an explicit transition (the host + // applied our request — or someone else's superseding one). A plain + // heartbeat that was already in flight when we sent the request still + // carries the pre-request anchor and must not yank us back. + if (_optimisticUntilSeq != null && (state.actorPeerId == myPeerId || state.actionHint != null)) { + _optimisticUntilSeq = null; + } + + // Self-heal: the host thinks it's waiting on us but we're healthy. + final player = _player; + if (state.waitingOn.contains(myPeerId) && _localReady && player != null && !player.buffering) { + _sendStatus(force: true); + } + + // The host moved to media we don't have — hand off to the switch flow. + if (_attachedMediaKey != null && state.mediaKey != _attachedMediaKey) { + _callbacks.onMediaSwitchNeeded?.call(state.ratingKey, state.serverId, state.mediaTitle); + return; + } + if (previous?.mediaKey != state.mediaKey && _attachedMediaKey == null) { + // Not in the player yet — let the provider navigate. + _callbacks.onMediaSwitchNeeded?.call(state.ratingKey, state.serverId, state.mediaTitle); + return; + } + + _reconcile(); + } + + /// User seek on this guest (the screen already executed it locally). + void onLocalSeekIntent(Duration position) { + if (_latestState == null) return; + if (_canControl) { + _sendControl(ControlRequest(kind: ControlRequestKind.seek, positionMs: position.inMilliseconds)); + } else { + _reconcile(); // Snap back. + } + } + + void setBackgrounded(bool value) { + if (_backgrounded == value) return; + _backgrounded = value; + if (!value) _reconcile(); + } + + /// Host session restarted (fresh join observed) — accept its new counter. + void resetSequence() { + _lastSeq = -1; + } + + void onReconnected() { + _sendStatus(force: true); + } + + void dispose() { + _disposed = true; + detachPlayer(); + } + + // --------------------------------------------------------------------- + // Local intents + // --------------------------------------------------------------------- + + bool get _canControl => _latestState?.controlMode == ControlMode.anyone; + + void _onLocalPlayingIntent(bool playing) { + if (_latestState == null) return; + if (_canControl) { + _sendControl( + ControlRequest( + kind: playing ? ControlRequestKind.play : ControlRequestKind.pause, + positionMs: _player?.position.inMilliseconds, + ), + ); + } else { + _reconcile(); // Snap back to the room state. + } + } + + void _onLocalRateIntent(double rate) { + if (_latestState == null) return; + if (_canControl) { + _sendControl(ControlRequest(kind: ControlRequestKind.rate, rate: rate)); + } else { + _reconcile(); + } + } + + void _sendControl(ControlRequest request) { + _sendToHost(SyncMessage.control(request, peerId: myPeerId)); + _optimisticUntilSeq = _lastSeq; + _optimisticDeadlineMs = _nowMs() + optimisticWindowMs; + } + + bool get _optimisticWindowActive => _optimisticUntilSeq != null && _nowMs() < _optimisticDeadlineMs; + + // --------------------------------------------------------------------- + // Reconciliation + // --------------------------------------------------------------------- + + void _onTick() { + _reconcile(); + // Keep the host's view fresh while we're the one buffering. + final player = _player; + if (player != null && player.buffering && _statusRefreshTimer == null) { + _statusRefreshTimer = Timer(const Duration(milliseconds: bufferingStatusRefreshMs), () { + _statusRefreshTimer = null; + if (_player?.buffering ?? false) _sendStatus(force: true); + }); + } + } + + void _reconcile() { + if (_disposed || _backgrounded || _settling) return; + final state = _latestState; + final player = _player; + if (state == null || player == null || !_localReady) return; + if (state.mediaKey != _attachedMediaKey) return; + if (_optimisticWindowActive) return; + + switch (state.phase) { + case PlaybackPhase.loading: + // Host is still loading — its anchor is meaningless. Just hold. + _exitNudgeIfNeeded(state); + _ensurePaused(player); + break; + + case PlaybackPhase.waitingForPeers: + case PlaybackPhase.paused: + _exitNudgeIfNeeded(state); + _ensurePaused(player); + _alignWhileStopped(player, state); + break; + + case PlaybackPhase.playing: + _reconcilePlaying(player, state); + break; + } + } + + void _reconcilePlaying(AttachedPlayer player, PlaybackState state) { + final hostNow = _clock.hostNowMs(); + + // Scheduled group start: hold at the anchor, then start on the dot. + if (state.anchorHostTimeMs > hostNow) { + if (_scheduledStartSeq != state.seq) { + _scheduledStartTimer?.cancel(); + _scheduledStartSeq = state.seq; + final delay = state.anchorHostTimeMs - hostNow; + appLogger.d('WatchTogether: Group start in ${delay}ms at ${state.anchorPositionMs}ms'); + _scheduledStartTimer = Timer(Duration(milliseconds: delay), () { + _scheduledStartTimer = null; + _scheduledStartSeq = null; + final currentPlayer = _player; + if (currentPlayer == null || _latestState?.seq != state.seq) return; + unawaited(currentPlayer.play()); + }); + } + _exitNudgeIfNeeded(state); + _ensurePaused(player); + _alignWhileStopped(player, state); + return; + } + if (_scheduledStartSeq != null && _scheduledStartSeq != state.seq) { + _scheduledStartTimer?.cancel(); + _scheduledStartTimer = null; + _scheduledStartSeq = null; + } + + final durationMs = player.duration.inMilliseconds; + var targetMs = state.targetPositionMs(hostNow); + if (durationMs > 0 && targetMs > durationMs - eofClampMs) { + targetMs = durationMs - eofClampMs; + } + + // Both of us rolled into the credits — don't fight EOF. + if (player.completed && durationMs > 0 && targetMs >= durationMs - eofToleranceMs) { + return; + } + + if (!player.playing) { + if (player.completed) { + // Fell off the end while the room plays on — rejoin via seek+play. + if (player.seekable && _cooldownElapsed) { + _hardSeek(player, targetMs, thenPlay: true); + } + return; + } + unawaited(player.play()); + } + + // Base rate alignment (never while nudging — the nudge owns the rate). + if (!_nudging && (player.rate - state.rate).abs() > 0.001) { + unawaited(player.setRate(state.rate)); + } + + if (!player.seekable) return; // Live: play/pause/rate only. + + final drift = _smoothedDrift(player.position.inMilliseconds - targetMs); + if (drift == null) return; + + final magnitude = drift.abs(); + if (magnitude <= (_nudging ? nudgeExitMs : deadbandMs)) { + _exitNudgeIfNeeded(state); + return; + } + + if (magnitude <= deadbandMs) return; // Inside deadband, still nudging. + + if (magnitude <= hardSeekThresholdMs) { + _maybeNudge(player, state, drift); + return; + } + + // Hard correction. + _exitNudgeIfNeeded(state); + if (!_cooldownElapsed) return; + _hardSeek(player, targetMs + hardSeekLeadMs); + } + + bool get _cooldownElapsed => _nowMs() - _lastHardSeekMs >= hardSeekCooldownMs; + + void _hardSeek(AttachedPlayer player, int targetMs, {bool thenPlay = false}) { + _lastHardSeekMs = _nowMs(); + _driftSamples.clear(); + _setCorrecting(true); + _beginSettle(); + appLogger.d('WatchTogether: Hard sync seek to ${targetMs}ms'); + unawaited( + player.seek(Duration(milliseconds: targetMs.clamp(0, 1 << 48))).then((didSeek) async { + if (didSeek && thenPlay) await player.play(); + }), + ); + } + + void _alignWhileStopped(AttachedPlayer player, PlaybackState state) { + if (!player.seekable) return; + final offBy = (player.position.inMilliseconds - state.anchorPositionMs).abs(); + if (offBy > pausedSeekThresholdMs && _cooldownElapsed) { + _hardSeek(player, state.anchorPositionMs); + } + } + + void _ensurePaused(AttachedPlayer player) { + if (player.playing) { + unawaited(player.pause()); + } + } + + void _maybeNudge(AttachedPlayer player, PlaybackState state, int drift) { + if (_nudgeDisabled || player.passthroughActive) return; // Tolerate up to the seek band. + + // Ahead of the room → slow down; behind → speed up. + final factor = drift > 0 ? (1 - nudgeFactor) : (1 + nudgeFactor); + final targetRate = state.rate * factor; + if (_nudging && (player.rate - targetRate).abs() < 0.001) return; + + _nudging = true; + unawaited(player.setRate(targetRate)); + + // Arm the capability check once per (un-confirmed) nudge episode — a + // re-issued nudge must not keep pushing the deadline out. + if (!_nudgeConfirmed && _nudgeConfirmTimer == null) { + _nudgeConfirmTimer = Timer(const Duration(milliseconds: nudgeConfirmMs), () { + _nudgeConfirmTimer = null; + final currentPlayer = _player; + if (currentPlayer == null || !_nudging) return; + if ((currentPlayer.rate - targetRate).abs() > 0.005) { + appLogger.w('WatchTogether: Rate nudges not taking effect — disabling for this session'); + _nudgeDisabled = true; + _nudging = false; + unawaited(currentPlayer.setRate(_latestState?.rate ?? 1.0)); + } else { + _nudgeConfirmed = true; + } + }); + } + } + + void _exitNudgeIfNeeded(PlaybackState state) { + if (!_nudging) return; + _nudging = false; + final player = _player; + if (player != null) { + unawaited(player.setRate(state.rate)); + } + } + + int? _smoothedDrift(int rawDrift) { + _driftSamples.add(rawDrift); + if (_driftSamples.length > 3) _driftSamples.removeAt(0); + if (_driftSamples.length < 2) return null; // One sample can be a fluke. + final sorted = List.of(_driftSamples)..sort(); + return sorted[sorted.length ~/ 2]; + } + + void _beginSettle() { + _settling = true; + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: settleTimeoutMs), _endSettle); + } + + void _endSettle() { + if (!_settling) return; + _settling = false; + _settleTimer?.cancel(); + _settleTimer = null; + _driftSamples.clear(); + _setCorrecting(false); + } + + void _setCorrecting(bool value) { + if (_correcting == value) return; + _correcting = value; + _callbacks.onCorrectingChanged?.call(value); + } + + // --------------------------------------------------------------------- + // Status reporting + // --------------------------------------------------------------------- + + void _sendStatus({bool force = false}) { + final mediaKey = _attachedMediaKey; + if (mediaKey == null || _disposed) return; + final player = _player; + final status = PeerStatus( + mediaKey: mediaKey, + ready: _localReady, + buffering: player?.buffering ?? false, + positionMs: player?.position.inMilliseconds ?? 0, + rttMs: _clock.minRttMs, + ); + final last = _lastSentStatus; + if (!force && + last != null && + last.mediaKey == status.mediaKey && + last.ready == status.ready && + last.buffering == status.buffering) { + return; + } + _lastSentStatus = status; + _sendToHost(SyncMessage.status(status, peerId: myPeerId)); + } + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart new file mode 100644 index 00000000..9bf360f0 --- /dev/null +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -0,0 +1,802 @@ +import 'dart:async'; +import 'dart:math'; + +import '../../utils/app_logger.dart'; +import '../models/playback_state.dart'; +import '../models/watch_session.dart'; +import 'attached_player.dart'; + +/// Callbacks the coordinator surfaces to the provider/UI layer. +class HostCoordinatorCallbacks { + /// Phase transitions (drives the waiting pill and chrome). + final void Function(PlaybackPhase phase)? onPhaseChanged; + + /// The set of peers the room is waiting on changed. + final void Function(List peerIds)? onWaitingOnChanged; + + /// The safety timeout excused these peers and the room resumed. + final void Function(List peerIds)? onResumedWithout; + + /// A guest's control request was applied (drives action toasts). + final void Function(String peerId, PlaybackActionHint hint)? onRemoteAction; + + const HostCoordinatorCallbacks({ + this.onPhaseChanged, + this.onWaitingOnChanged, + this.onResumedWithout, + this.onRemoteAction, + }); +} + +/// Host-side policy engine: owns the authoritative [PlaybackState]. +/// +/// Inputs are local player signals (via [AttachedPlayer]'s intent-classified +/// streams), peer status reports, control requests, and roster changes; the +/// output is a state broadcast through [sendState] plus commands to the +/// host's own player (the host delays its own start to the scheduled moment +/// just like every guest). +/// +/// Pure Dart and clock-injected so the full scenario matrix runs under +/// `fakeAsync`. +class HostPlaybackCoordinator { + HostPlaybackCoordinator({ + required this.myPeerId, + required this._controlMode, + required this._sendState, + this._callbacks = const HostCoordinatorCallbacks(), + int Function()? nowMs, + }) : _nowMs = nowMs ?? _systemNowMs; + + static int _systemNowMs() => DateTime.now().millisecondsSinceEpoch; + + // Tuning constants. + static const int stallGraceMs = 500; + static const int recoveryHysteresisMs = 400; + static const int safetyTimeoutMs = 15000; + static const int heartbeatPlayingMs = 2000; + static const int heartbeatIdleMs = 5000; + static const int startDelayMinMs = 750; + static const int startDelayMaxMs = 2000; + static const int defaultPeerRttMs = 500; + static const int seekDebounceMs = 200; + static const int implicitJumpThresholdMs = 1500; + static const int selfRecoveryMinBufferAheadMs = 2000; + + final String myPeerId; + final void Function(PlaybackState state, {String? toPeerId}) _sendState; + final HostCoordinatorCallbacks _callbacks; + final int Function() _nowMs; + + ControlMode _controlMode; + + // Media epoch. + String? _ratingKey; + String? _serverId; + String? _mediaTitle; + bool get hasActiveEpoch => _ratingKey != null && _serverId != null; + String? get _mediaKey => + hasActiveEpoch ? PlaybackState.mediaKeyFor(ratingKey: _ratingKey!, serverId: _serverId!) : null; + + // Player attachment. + AttachedPlayer? _player; + final List> _playerSubscriptions = []; + bool _localReady = false; + bool _startupHoldResolved = true; + bool _localStalled = false; + bool _recoveringFromSelfStall = false; + + // Room state. + PlaybackPhase _phase = PlaybackPhase.loading; + bool _intendedPlaying = false; + double _rate = 1.0; + bool _firstStartCompleted = false; + int _seq = 0; + PlaybackState? _lastBroadcast; + bool _backgrounded = false; + + // Peer tracking. + final Set _knownPeers = {}; + final Set _incompatiblePeers = {}; + final Set _excused = {}; + final Set _stalledPeers = {}; + final Map _peerStatuses = {}; + final Map _peerStallGraceTimers = {}; + + // Pending actions. + Timer? _selfStallGraceTimer; + Timer? _allReadyCheckTimer; + Timer? _safetyTimer; + Timer? _heartbeatTimer; + Timer? _pendingStartTimer; + int? _pendingStartAtMs; + int? _pendingStartPositionMs; + Timer? _seekDebounceTimer; + int? _pendingSeekTargetMs; + String? _pendingActor; + bool _disposed = false; + + PlaybackPhase get phase => _phase; + Set get incompatiblePeers => Set.unmodifiable(_incompatiblePeers); + + // --------------------------------------------------------------------- + // Public inputs + // --------------------------------------------------------------------- + + /// Host switched (or initially picked) media — a new epoch. Safe to call + /// repeatedly with the same media; only an actual change broadcasts. + void setLocalMedia({required String ratingKey, required String serverId, String? mediaTitle}) { + final newKey = PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId); + if (newKey == _mediaKey) { + if (mediaTitle != null && mediaTitle != _mediaTitle) _mediaTitle = mediaTitle; + return; + } + + _ratingKey = ratingKey; + _serverId = serverId; + _mediaTitle = mediaTitle; + _localReady = false; + _localStalled = false; + _recoveringFromSelfStall = false; + _firstStartCompleted = false; + _intendedPlaying = true; // Opening media implies the room wants to play. + _excused.clear(); + _stalledPeers.clear(); + _cancelPendingStart(); + _cancelSafety(); + _cancelStallTimers(); + _setPhase(PlaybackPhase.loading); + _broadcast(hint: PlaybackActionHint.mediaSwitch, actor: myPeerId); + appLogger.d('WatchTogether: Host epoch -> $newKey'); + } + + /// Attach the host's player for the given media. [hasFirstFrame] is the + /// 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). + void attach( + AttachedPlayer player, { + required String ratingKey, + required String serverId, + String? mediaTitle, + bool hasFirstFrame = false, + Future? startupHold, + }) { + detachPlayer(); + final sameEpoch = + hasActiveEpoch && PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId) == _mediaKey; + setLocalMedia(ratingKey: ratingKey, serverId: serverId, mediaTitle: mediaTitle); + + _player = player; + _rate = player.rate; + + // Same-media re-attach with a reloading player (quality/version switch): + // group-wait at the last known spot until we render again, then the + // normal all-ready resolution resumes the room. + if (sameEpoch && !hasFirstFrame && _phase == PlaybackPhase.playing) { + _intendedPlaying = true; + _cancelPendingStart(); + _setPhase(PlaybackPhase.waitingForPeers); + _broadcast(anchorPositionOverrideMs: _lastBroadcast?.anchorPositionMs); + _armSafetyIfGated(); + } + + _startupHoldResolved = startupHold == null; + if (startupHold != null) { + startupHold.then((_) { + if (_disposed || !identical(_player, player)) return; + _startupHoldResolved = true; + _maybeLocalLoaded(); + }); + } + + _playerSubscriptions.add(player.loadedSignals.listen((_) => _onLoadedSignal())); + _playerSubscriptions.add(player.bufferingChanges.listen(_onSelfBuffering)); + _playerSubscriptions.add(player.playingIntents.listen(_onLocalPlayingIntent)); + _playerSubscriptions.add(player.rateIntents.listen(_onLocalRateIntent)); + + if (hasFirstFrame) { + _localReady = true; + _maybeLocalLoaded(); + } + _restartHeartbeat(); + } + + /// Detach the player (episode switch keeps the session; [exiting] ends the + /// epoch because the host left the video player). + void detachPlayer({bool exiting = false}) { + for (final subscription in _playerSubscriptions) { + unawaited(subscription.cancel()); + } + _playerSubscriptions.clear(); + _player = null; + _localReady = false; + _localStalled = false; + _recoveringFromSelfStall = false; + _startupHoldResolved = true; + _cancelPendingStart(); + _cancelStallTimers(); + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + if (exiting) { + _ratingKey = null; + _serverId = null; + _mediaTitle = null; + _cancelSafety(); + _setPhase(PlaybackPhase.loading); + } + } + + void setBackgrounded(bool value) { + if (_backgrounded == value) return; + _backgrounded = value; + if (!value && hasActiveEpoch && _player != null) { + _onHeartbeat(); + } + } + + void updateControlMode(ControlMode mode) { + if (_controlMode == mode) return; + _controlMode = mode; + if (hasActiveEpoch) _broadcast(); + } + + void onPeerJoined(String peerId, {required bool compatible}) { + if (peerId == myPeerId) return; + if (!compatible) { + _incompatiblePeers.add(peerId); + _knownPeers.remove(peerId); + return; + } + _incompatiblePeers.remove(peerId); + _knownPeers.add(peerId); + if (hasActiveEpoch) { + _broadcast(toPeerId: peerId); + } + } + + void onPeerLeft(String peerId) { + _knownPeers.remove(peerId); + _incompatiblePeers.remove(peerId); + _excused.remove(peerId); + _stalledPeers.remove(peerId); + _peerStatuses.remove(peerId); + _peerStallGraceTimers.remove(peerId)?.cancel(); + _scheduleAllReadyCheck(0); + } + + void onPeerStatus(String peerId, PeerStatus status) { + if (peerId == myPeerId || _incompatiblePeers.contains(peerId)) return; + _knownPeers.add(peerId); + final previous = _peerStatuses[peerId]; + _peerStatuses[peerId] = status; + + final onCurrentEpoch = status.mediaKey == _mediaKey; + + // A previously-excused peer that is healthy again rejoins the gate set. + if (onCurrentEpoch && status.ready && !status.buffering) { + _excused.remove(peerId); + } + + if (!onCurrentEpoch) { + _peerStallGraceTimers.remove(peerId)?.cancel(); + _stalledPeers.remove(peerId); + _scheduleAllReadyCheck(0); + return; + } + + // Stall detection: a ready peer that reports buffering while the room + // plays gets a short grace window before pausing everyone. + if (status.ready && status.buffering) { + if (_phase == PlaybackPhase.playing && !_stalledPeers.contains(peerId)) { + _peerStallGraceTimers[peerId] ??= Timer(const Duration(milliseconds: stallGraceMs), () { + _peerStallGraceTimers.remove(peerId); + final latest = _peerStatuses[peerId]; + if (latest == null || !latest.buffering || latest.mediaKey != _mediaKey) return; + if (_phase != PlaybackPhase.playing) return; + _stalledPeers.add(peerId); + _enterWaiting(); + }); + } else if (_phase == PlaybackPhase.waitingForPeers && !_stalledPeers.contains(peerId)) { + // Already waiting on someone else — fold this stall in immediately. + _stalledPeers.add(peerId); + _scheduleAllReadyCheck(0); + } + } else { + _peerStallGraceTimers.remove(peerId)?.cancel(); + final wasStalled = _stalledPeers.remove(peerId); + final becameReady = status.ready && (previous == null || !previous.ready || previous.mediaKey != _mediaKey); + if (wasStalled) { + _scheduleAllReadyCheck(recoveryHysteresisMs); + } else if (becameReady) { + _scheduleAllReadyCheck(0); + } + } + } + + void onControlRequest(String peerId, ControlRequest request) { + if (!hasActiveEpoch) return; + switch (request.kind) { + case ControlRequestKind.play: + _requestPlay(actor: peerId); + break; + case ControlRequestKind.pause: + _requestPause(actor: peerId); + break; + case ControlRequestKind.seek: + if (request.positionMs != null) { + _applyRemoteSeek(request.positionMs!, actor: peerId); + } + break; + case ControlRequestKind.rate: + if (request.rate != null) { + _applyRemoteRate(request.rate!, actor: peerId); + } + break; + } + } + + /// User seek on the host (the screen already executed it on the player). + void onLocalSeekIntent(Duration position) { + if (!hasActiveEpoch) return; + _pendingSeekTargetMs = position.inMilliseconds; + _seekDebounceTimer?.cancel(); + _seekDebounceTimer = Timer(const Duration(milliseconds: seekDebounceMs), () { + final target = _pendingSeekTargetMs; + _pendingSeekTargetMs = null; + if (target == null || !hasActiveEpoch) return; + _afterHostSeek(target, actor: myPeerId); + }); + } + + void onStateRequested(String peerId) { + if (!hasActiveEpoch) return; + _broadcast(toPeerId: peerId); + } + + void onReconnected() { + if (hasActiveEpoch) _broadcast(); + } + + void dispose() { + _disposed = true; + detachPlayer(exiting: true); + _allReadyCheckTimer?.cancel(); + _seekDebounceTimer?.cancel(); + _peerStatuses.clear(); + _knownPeers.clear(); + } + + // --------------------------------------------------------------------- + // Local player signals + // --------------------------------------------------------------------- + + void _onLoadedSignal() { + if (_localReady) return; + _localReady = true; + _maybeLocalLoaded(); + } + + void _maybeLocalLoaded() { + if (!_localReady || !_startupHoldResolved || !hasActiveEpoch) return; + final player = _player; + if (player == null) return; + + appLogger.d('WatchTogether: Host player ready for $_mediaKey'); + + if (_phase == PlaybackPhase.loading) { + // The sync layer owns the start — undo anything that slipped into play. + if (player.playing) { + unawaited(player.pause()); + } + _setPhase(PlaybackPhase.waitingForPeers); + _broadcast(); + _armSafetyIfGated(); + } + _scheduleAllReadyCheck(0); + + // A play latched while we were loading (paused room) resumes now. + if (_phase == PlaybackPhase.paused && _intendedPlaying) { + _requestPlay(actor: _pendingActor ?? myPeerId); + } + } + + void _onSelfBuffering(bool buffering) { + if (!_localReady) return; // Pre-ready buffering is the loading flow. + + if (buffering) { + _recoveringFromSelfStall = false; + if (_phase != PlaybackPhase.playing || _localStalled) return; + _selfStallGraceTimer?.cancel(); + _selfStallGraceTimer = Timer(const Duration(milliseconds: stallGraceMs), () { + final player = _player; + if (player == null || !player.buffering || _phase != PlaybackPhase.playing) return; + _localStalled = true; + // Unlike a remote stall we leave the host player unpaused so mpv can + // refill its cache and recover on its own; its clock is frozen anyway. + _enterWaiting(); + }); + } else { + _selfStallGraceTimer?.cancel(); + _selfStallGraceTimer = null; + if (_localStalled) { + _localStalled = false; + _recoveringFromSelfStall = true; + _scheduleAllReadyCheck(recoveryHysteresisMs); + } + } + } + + void _onLocalPlayingIntent(bool playing) { + if (!hasActiveEpoch) return; + if (playing) { + _requestPlay(actor: myPeerId); + } else { + _requestPause(actor: myPeerId); + } + } + + void _onLocalRateIntent(double rate) { + if (!hasActiveEpoch) return; + _rate = rate; + _broadcast(hint: PlaybackActionHint.rate, actor: myPeerId); + } + + // --------------------------------------------------------------------- + // Play / pause / seek / rate policy + // --------------------------------------------------------------------- + + void _requestPlay({required String actor}) { + if (_phase == PlaybackPhase.playing) return; + _intendedPlaying = true; + _pendingActor = actor; + if (actor != myPeerId) _callbacks.onRemoteAction?.call(actor, PlaybackActionHint.play); + + final player = _player; + if (!_localReady) { + // Still loading: latch the intent, undo any local unpause, and stay in + // the loading phase — its anchor would be meaningless to guests. + if (player != null && player.playing) { + unawaited(player.pause()); + } + return; + } + + final gating = _gatingPeers(); + if (gating.isEmpty) { + _scheduleStart(actor: actor); + } else { + // Want to play but can't yet — hold (and undo a local unpause). + if (player != null && player.playing) { + unawaited(player.pause()); + } + if (_phase != PlaybackPhase.waitingForPeers) { + _setPhase(PlaybackPhase.waitingForPeers); + _broadcast(actor: actor); + _armSafetyIfGated(); + } + } + } + + void _requestPause({required String actor}) { + _intendedPlaying = false; + _pendingActor = null; + _cancelPendingStart(); + _cancelSafety(); + if (actor != myPeerId) _callbacks.onRemoteAction?.call(actor, PlaybackActionHint.pause); + + final player = _player; + if (player != null && player.playing) { + unawaited(player.pause()); + } + // While loading, only latch the intent — the all-ready resolution after + // local readiness lands on paused because _intendedPlaying is false. + if (_phase == PlaybackPhase.loading) return; + _setPhase(PlaybackPhase.paused); + _broadcast(hint: PlaybackActionHint.pause, actor: actor); + } + + void _applyRemoteSeek(int targetMs, {required String actor}) { + final player = _player; + if (player == null) return; + _callbacks.onRemoteAction?.call(actor, PlaybackActionHint.seek); + unawaited( + player.seek(Duration(milliseconds: targetMs)).then((didSeek) { + if (didSeek) _afterHostSeek(targetMs, actor: actor); + }), + ); + } + + void _afterHostSeek(int targetMs, {required String actor}) { + // Re-anchor at the seek target. If a scheduled start is pending, move + // its position too so the start fires from the new spot. + if (_pendingStartAtMs != null) { + _pendingStartPositionMs = targetMs; + } + _broadcast(hint: PlaybackActionHint.seek, actor: actor, anchorPositionOverrideMs: targetMs); + } + + void _applyRemoteRate(double rate, {required String actor}) { + final player = _player; + if (player == null) return; + _callbacks.onRemoteAction?.call(actor, PlaybackActionHint.rate); + unawaited( + player.setRate(rate).then((didSet) { + if (!didSet) return; + _rate = rate; + _broadcast(hint: PlaybackActionHint.rate, actor: actor); + }), + ); + } + + // --------------------------------------------------------------------- + // Readiness / group-wait machinery + // --------------------------------------------------------------------- + + /// Peers (including self) the room cannot play without right now. + Set _gatingPeers() { + final gating = {}; + final mediaKey = _mediaKey; + if (mediaKey == null) return gating; + + for (final peerId in _knownPeers) { + if (_excused.contains(peerId)) continue; + final status = _peerStatuses[peerId]; + if (status == null || status.mediaKey != mediaKey) { + // Never reported for this epoch: gate only the initial start — + // mid-session they're late joiners who catch up on their own. + if (!_firstStartCompleted) gating.add(peerId); + continue; + } + if (!status.ready) { + if (!_firstStartCompleted) gating.add(peerId); + continue; + } + if (_stalledPeers.contains(peerId)) gating.add(peerId); + } + if (!_localReady || _localStalled) gating.add(myPeerId); + return gating; + } + + void _enterWaiting() { + if (_phase == PlaybackPhase.waitingForPeers) return; + final player = _player; + // Anchor where the room stops. Pause our player unless the stall is our + // own (mpv recovers paused-for-cache by itself). + if (player != null && player.playing && !_localStalled) { + unawaited(player.pause()); + } + _intendedPlaying = true; // A stall interrupts playback we intend to resume. + _setPhase(PlaybackPhase.waitingForPeers); + _broadcast(); + _armSafetyIfGated(); + } + + void _scheduleAllReadyCheck(int delayMs) { + _allReadyCheckTimer?.cancel(); + _allReadyCheckTimer = null; + if (delayMs <= 0) { + _checkAllReady(); + } else { + _allReadyCheckTimer = Timer(Duration(milliseconds: delayMs), _checkAllReady); + } + } + + void _checkAllReady() { + if (_disposed || _phase != PlaybackPhase.waitingForPeers) return; + final gating = _gatingPeers(); + if (gating.isNotEmpty) { + _broadcastIfWaitingOnChanged(gating); + return; + } + + // After our own stall, require some cache headroom before resuming so we + // don't immediately drag the room back into a stall. + final player = _player; + if (_recoveringFromSelfStall && player != null) { + if (player.buffering) return; // A new stall event will re-drive us. + final ahead = player.bufferAhead; + if (ahead != null && ahead.inMilliseconds < selfRecoveryMinBufferAheadMs) { + _scheduleAllReadyCheck(500); + return; + } + } + _recoveringFromSelfStall = false; + _resolveAllReady(); + } + + void _resolveAllReady() { + _cancelSafety(); + if (_intendedPlaying) { + _scheduleStart(actor: _pendingActor ?? myPeerId); + } else { + _setPhase(PlaybackPhase.paused); + _broadcast(); + } + _pendingActor = null; + } + + void _scheduleStart({required String actor}) { + final player = _player; + if (player == null || !_localReady) return; + _cancelPendingStart(); + + final otherPeers = _knownPeers.where((p) => !_excused.contains(p)).toList(); + int delayMs; + if (otherPeers.isEmpty) { + delayMs = 0; + } else { + var maxRtt = 0; + for (final peerId in otherPeers) { + maxRtt = max(maxRtt, _peerStatuses[peerId]?.rttMs ?? defaultPeerRttMs); + } + delayMs = max(startDelayMinMs, min((maxRtt * 1.5).round(), startDelayMaxMs)); + } + + final startAt = _nowMs() + delayMs; + final startPositionMs = player.position.inMilliseconds; + _pendingStartAtMs = startAt; + _pendingStartPositionMs = startPositionMs; + _firstStartCompleted = true; + _setPhase(PlaybackPhase.playing); + _broadcast(hint: PlaybackActionHint.play, actor: actor); + + void fireStart() { + _pendingStartTimer = null; + _pendingStartAtMs = null; + final startPos = _pendingStartPositionMs; + _pendingStartPositionMs = null; + final currentPlayer = _player; + if (currentPlayer == null || _phase != PlaybackPhase.playing) return; + if (startPos != null && (currentPlayer.position.inMilliseconds - startPos).abs() > 250) { + unawaited(currentPlayer.seek(Duration(milliseconds: startPos)).then((_) => currentPlayer.play())); + } else { + unawaited(currentPlayer.play()); + } + } + + if (delayMs <= 0 && player.playing) { + // Solo resume of an already-playing player: nothing to do. + _pendingStartTimer = null; + _pendingStartAtMs = null; + _pendingStartPositionMs = null; + } else { + // The host waits for the group moment like everyone else — undo a + // user-initiated unpause until the scheduled start fires. + if (player.playing) { + unawaited(player.pause()); + } + _pendingStartTimer = Timer(Duration(milliseconds: delayMs), fireStart); + } + } + + void _armSafetyIfGated() { + _cancelSafety(); + if (_gatingPeers().difference({myPeerId}).isEmpty) return; + _safetyTimer = Timer(const Duration(milliseconds: safetyTimeoutMs), () { + if (_phase != PlaybackPhase.waitingForPeers) return; + final gating = _gatingPeers()..remove(myPeerId); + if (gating.isEmpty) return; + _excused.addAll(gating); + _stalledPeers.removeAll(gating); + appLogger.w('WatchTogether: Resuming without ${gating.join(', ')} after ${safetyTimeoutMs ~/ 1000}s'); + _callbacks.onResumedWithout?.call(gating.toList()..sort()); + _scheduleAllReadyCheck(0); + }); + } + + void _cancelPendingStart() { + _pendingStartTimer?.cancel(); + _pendingStartTimer = null; + _pendingStartAtMs = null; + _pendingStartPositionMs = null; + } + + void _cancelSafety() { + _safetyTimer?.cancel(); + _safetyTimer = null; + } + + void _cancelStallTimers() { + _selfStallGraceTimer?.cancel(); + _selfStallGraceTimer = null; + for (final timer in _peerStallGraceTimers.values) { + timer.cancel(); + } + _peerStallGraceTimers.clear(); + } + + // --------------------------------------------------------------------- + // Heartbeat & broadcasting + // --------------------------------------------------------------------- + + void _restartHeartbeat() { + _heartbeatTimer?.cancel(); + if (_player == null) return; + final interval = _phase == PlaybackPhase.playing ? heartbeatPlayingMs : heartbeatIdleMs; + _heartbeatTimer = Timer.periodic(Duration(milliseconds: interval), (_) => _onHeartbeat()); + } + + void _onHeartbeat() { + if (_backgrounded || _disposed || !hasActiveEpoch) return; + final player = _player; + if (player == null) return; + + // Implicit-jump detection: a position far from where the last broadcast + // predicts, with no seek intent in flight, means something seeked the + // player behind our back (OS remote, EOF jump) — re-anchor with a seek + // hint so guests snap instead of nudging. + PlaybackActionHint? hint; + final last = _lastBroadcast; + if (last != null && _pendingStartAtMs == null && _pendingSeekTargetMs == null && !player.buffering) { + final expected = last.targetPositionMs(_nowMs()); + if ((player.position.inMilliseconds - expected).abs() > implicitJumpThresholdMs) { + hint = PlaybackActionHint.seek; + } + } + _broadcast(hint: hint, actor: hint != null ? myPeerId : null); + } + + void _broadcastIfWaitingOnChanged(Set gating) { + final last = _lastBroadcast; + if (last == null) return; + final current = gating.toList()..sort(); + if (current.length == last.waitingOn.length && last.waitingOn.toSet().containsAll(current)) return; + _broadcast(); + } + + void _setPhase(PlaybackPhase phase) { + if (_phase == phase) return; + _phase = phase; + _callbacks.onPhaseChanged?.call(phase); + _restartHeartbeat(); + } + + void _broadcast({PlaybackActionHint? hint, String? actor, String? toPeerId, int? anchorPositionOverrideMs}) { + if (_disposed || !hasActiveEpoch) return; + + final player = _player; + int anchorPositionMs; + int anchorHostTimeMs; + if (_pendingStartAtMs != null && _phase == PlaybackPhase.playing) { + anchorPositionMs = _pendingStartPositionMs ?? player?.position.inMilliseconds ?? 0; + anchorHostTimeMs = _pendingStartAtMs!; + } else { + anchorPositionMs = anchorPositionOverrideMs ?? player?.position.inMilliseconds ?? 0; + anchorHostTimeMs = _nowMs(); + } + + final waitingOn = _phase == PlaybackPhase.waitingForPeers ? (_gatingPeers().toList()..sort()) : const []; + + final state = PlaybackState( + seq: ++_seq, + ratingKey: _ratingKey!, + serverId: _serverId!, + mediaTitle: _mediaTitle, + phase: _phase, + anchorPositionMs: anchorPositionMs, + anchorHostTimeMs: anchorHostTimeMs, + rate: _rate, + controlMode: _controlMode, + waitingOn: waitingOn, + actorPeerId: actor, + actionHint: hint, + ); + + if (toPeerId == null) { + final previousWaiting = _lastBroadcast?.waitingOn ?? const []; + _lastBroadcast = state; + if (!_listEquals(previousWaiting, waitingOn)) { + _callbacks.onWaitingOnChanged?.call(waitingOn); + } + } + _sendState(state, toPeerId: toPeerId); + } + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/watch_together/services/watch_together_controller.dart b/lib/watch_together/services/watch_together_controller.dart new file mode 100644 index 00000000..5d78451c --- /dev/null +++ b/lib/watch_together/services/watch_together_controller.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import '../../mpv/mpv.dart'; +import '../../utils/app_logger.dart'; +import '../models/playback_state.dart'; +import '../models/sync_message.dart'; +import '../models/watch_session.dart'; +import 'attached_player.dart'; +import 'clock_sync.dart'; +import 'guest_playback_reconciler.dart'; +import 'host_playback_coordinator.dart'; +import 'watch_together_peer_service.dart'; + +/// Session-scoped playback-sync controller. +/// +/// Lives for the whole Watch Together session (created at create/join, not +/// at player attach), so no sync message is ever dropped during episode +/// switches or other attach gaps — the player attachment is just an output +/// binding the role engine reconciles against. +/// +/// Routes the v2 protocol between the relay and the role engine: +/// host → [HostPlaybackCoordinator] (single writer of [PlaybackState]), +/// guest → [GuestPlaybackReconciler] (+ [ClockSync] against the host). +class WatchTogetherController { + WatchTogetherController({ + required WatchTogetherPeerService peerService, + required WatchSession session, + int Function()? nowMs, + }) : _peerService = peerService, + _session = session, + _nowMs = nowMs ?? _systemNowMs { + if (session.isHost) { + _coordinator = HostPlaybackCoordinator( + myPeerId: peerService.myPeerId ?? '', + controlMode: session.controlMode, + sendState: _sendState, + callbacks: HostCoordinatorCallbacks( + onPhaseChanged: (phase) => onPhaseChanged?.call(phase), + onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers), + onResumedWithout: (peers) => onResumedWithout?.call(peers), + onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint), + ), + nowMs: _nowMs, + ); + } else { + _clockSync = ClockSync(sendPing: _sendClockPing, nowMs: _nowMs); + _reconciler = GuestPlaybackReconciler( + myPeerId: peerService.myPeerId ?? '', + sendToHost: _sendToHost, + clockSync: _clockSync!, + callbacks: GuestReconcilerCallbacks( + 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(); + } + + _subscriptions.add(peerService.onMessageReceived.listen(_enqueueMessage)); + _subscriptions.add(peerService.onPeerDisconnected.listen(_handlePeerDisconnected)); + } + + static int _systemNowMs() => DateTime.now().millisecondsSinceEpoch; + + final WatchTogetherPeerService _peerService; + final int Function() _nowMs; + WatchSession _session; + + HostPlaybackCoordinator? _coordinator; + GuestPlaybackReconciler? _reconciler; + ClockSync? _clockSync; + + AttachedPlayer? _attachedPlayer; + final List> _subscriptions = []; + Future _messageQueue = Future.value(); + bool _disposed = false; + + /// Protocol versions learned from join messages (absent ⇒ v1). + final Map _peerVersions = {}; + final Set _updateToastShown = {}; + + // Provider-facing callbacks. + void Function(PlaybackPhase phase)? onPhaseChanged; + void Function(List peerIds)? onWaitingOnChanged; + void Function(bool correcting)? onCorrectingChanged; + void Function(ControlMode mode)? onControlModeReceived; + void Function(String ratingKey, String serverId, String? mediaTitle)? onMediaStateReceived; + void Function(String peerId, PlaybackActionHint hint)? onRemoteAction; + void Function(String peerId)? onPeerNeedsUpdate; + void Function(List peerIds)? onResumedWithout; + + bool get hasPlayer => _attachedPlayer != null; + + PlaybackPhase? get phase => _session.isHost ? _coordinator?.phase : _reconciler?.latestState?.phase; + + /// Update the session (e.g. when the control mode changes). + void updateSession(WatchSession session) { + _session = session; + _coordinator?.updateControlMode(session.controlMode); + } + + // --------------------------------------------------------------------- + // Player attachment + // --------------------------------------------------------------------- + + /// Attach the local player for [ratingKey]/[serverId]. + /// + /// [hasFirstFrame] is the screen's first-frame snapshot; [startupHold] + /// delays readiness until platform startup gates (frame-rate switch) + /// release; [remoteSeek] routes sync seeks through the screen's seek path + /// (Plex transcode restarts). + void attachPlayer( + Player player, { + required String ratingKey, + required String serverId, + String? mediaTitle, + bool hasFirstFrame = false, + Future? startupHold, + Future Function(Duration target)? remoteSeek, + }) { + detachPlayer(); + + final attached = AttachedPlayer( + player: player, + onLost: () { + appLogger.w('WatchTogether: Player attachment lost, detaching from sync'); + detachPlayer(); + }, + remoteSeek: remoteSeek, + nowMs: _nowMs, + ); + _attachedPlayer = attached; + + if (_session.isHost) { + _coordinator!.attach( + attached, + ratingKey: ratingKey, + serverId: serverId, + mediaTitle: mediaTitle, + hasFirstFrame: hasFirstFrame, + startupHold: startupHold, + ); + } else { + _reconciler!.attach( + attached, + ratingKey: ratingKey, + serverId: serverId, + hasFirstFrame: hasFirstFrame, + startupHold: startupHold, + ); + } + appLogger.d('WatchTogether: Player attached (host: ${_session.isHost})'); + } + + /// Detach the player. [exiting] means the user left the video player (the + /// epoch ends); an episode switch keeps the session and epoch flow. + void detachPlayer({bool exiting = false}) { + final attached = _attachedPlayer; + if (attached == null) return; + _attachedPlayer = null; + _coordinator?.detachPlayer(exiting: exiting); + _reconciler?.detachPlayer(); + unawaited(attached.dispose()); + appLogger.d('WatchTogether: Player detached (exiting: $exiting)'); + } + + // --------------------------------------------------------------------- + // Provider inputs + // --------------------------------------------------------------------- + + /// Host switched media (also called right after attach with the same key, + /// which is a no-op). + void setCurrentMedia({required String ratingKey, required String serverId, String? mediaTitle}) { + _coordinator?.setLocalMedia(ratingKey: ratingKey, serverId: serverId, mediaTitle: mediaTitle); + } + + /// User seek executed locally (screen hook). + void onLocalSeek(Duration position) { + if (_session.isHost) { + _coordinator?.onLocalSeekIntent(position); + } else { + _reconciler?.onLocalSeekIntent(position); + } + } + + void setBackgrounded(bool value) { + _coordinator?.setBackgrounded(value); + _reconciler?.setBackgrounded(value); + } + + void announceJoin(String displayName) { + final peerId = _peerService.myPeerId; + if (peerId == null) return; + _peerService.broadcast(SyncMessage.join(peerId: peerId, displayName: displayName, isHost: _session.isHost)); + } + + void announceLeave() { + final peerId = _peerService.myPeerId; + if (peerId == null) return; + _peerService.broadcast(SyncMessage.leave(peerId: peerId)); + } + + /// Ask the host to (re-)send its current state. + void requestState() { + if (_session.isHost) return; + final request = SyncMessage.requestState(peerId: _peerService.myPeerId); + final hostPeerId = _session.hostPeerId; + if (hostPeerId != null) { + _peerService.sendTo(hostPeerId, request); + } else { + _peerService.broadcast(request); + } + } + + /// Relay reconnect completed: re-establish mutual state. + void onReconnected() { + if (_session.isHost) { + _coordinator?.onReconnected(); + } else { + _reconciler?.onReconnected(); + requestState(); + } + } + + void dispose() { + _disposed = true; + detachPlayer(exiting: true); + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + _clockSync?.stop(); + _coordinator?.dispose(); + _reconciler?.dispose(); + } + + // --------------------------------------------------------------------- + // Transport plumbing + // --------------------------------------------------------------------- + + void _sendState(PlaybackState state, {String? toPeerId}) { + final message = SyncMessage.state(state, peerId: _peerService.myPeerId); + if (toPeerId != null) { + _peerService.sendTo(toPeerId, message); + } else { + _peerService.broadcast(message); + } + } + + void _sendToHost(SyncMessage message) { + final hostPeerId = _session.hostPeerId; + if (hostPeerId != null) { + _peerService.sendTo(hostPeerId, message); + } else { + _peerService.broadcast(message); + } + } + + void _sendClockPing(int pingId) { + _sendToHost(SyncMessage.ping(pingId, peerId: _peerService.myPeerId)); + } + + void _enqueueMessage(SyncMessage message) { + _messageQueue = _messageQueue.then((_) => _handleMessage(message)).catchError(( + Object error, + StackTrace stackTrace, + ) { + appLogger.e('WatchTogether: Failed to handle ${message.type.name} message', error: error, stackTrace: stackTrace); + }); + } + + Future _handleMessage(SyncMessage message) async { + if (_disposed) return; + final senderId = message.peerId; + if (senderId == null || senderId == _peerService.myPeerId) return; + + switch (message.type) { + case SyncMessageType.state: + // Only the host may author room state. + if (_session.isHost || senderId != _session.hostPeerId) return; + final state = message.state; + if (state != null) _reconciler?.onState(state); + break; + + case SyncMessageType.status: + final status = message.status; + if (_session.isHost && status != null) { + _coordinator?.onPeerStatus(senderId, status); + } + break; + + case SyncMessageType.control: + if (!_session.isHost) return; + // In host-only mode nobody else gets a say. + if (_session.controlMode == ControlMode.hostOnly) return; + if (_isIncompatible(senderId)) return; + final control = message.control; + if (control != null) _coordinator?.onControlRequest(senderId, control); + break; + + case SyncMessageType.requestState: + if (_session.isHost) _coordinator?.onStateRequested(senderId); + break; + + case SyncMessageType.ping: + if (message.pingId != null) { + // The pong timestamp is "host clock now" for the guest's offset + // math — it must come from the same clock as the state anchors. + _peerService.sendTo( + senderId, + SyncMessage( + type: SyncMessageType.pong, + timestamp: _nowMs(), + pingId: message.pingId, + peerId: _peerService.myPeerId, + ), + ); + } + break; + + case SyncMessageType.pong: + if (message.pingId != null && !_session.isHost) { + _clockSync?.onPong(message.pingId!, message.timestamp); + } + break; + + case SyncMessageType.join: + _handleJoin(senderId, message); + break; + + case SyncMessageType.leave: + _peerVersions.remove(senderId); + _coordinator?.onPeerLeft(senderId); + break; + + case SyncMessageType.hostExitedPlayer: + // Handled at the provider level. + break; + } + } + + bool _isIncompatible(String peerId) => (_peerVersions[peerId] ?? 1) != SyncMessage.protocolVersion; + + void _handleJoin(String senderId, SyncMessage message) { + final version = message.version ?? 1; + final firstSighting = !_peerVersions.containsKey(senderId); + _peerVersions[senderId] = version; + final compatible = version == SyncMessage.protocolVersion; + + if (!compatible && _updateToastShown.add(senderId)) { + appLogger.w('WatchTogether: Peer $senderId speaks protocol v$version (ours: ${SyncMessage.protocolVersion})'); + onPeerNeedsUpdate?.call(senderId); + } + + if (_session.isHost) { + _coordinator?.onPeerJoined(senderId, compatible: compatible); + } else if (senderId == _session.hostPeerId && firstSighting) { + // A fresh host join can mean a restarted host app with a reset + // sequence counter — accept its numbering from scratch. + _reconciler?.resetSequence(); + } + } + + void _handlePeerDisconnected(String peerId) { + _peerVersions.remove(peerId); + _updateToastShown.remove(peerId); + _coordinator?.onPeerLeft(peerId); + } +} diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart deleted file mode 100644 index 38391322..00000000 --- a/lib/watch_together/services/watch_together_sync_manager.dart +++ /dev/null @@ -1,1052 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; - -import '../../mpv/mpv.dart'; -import '../../utils/app_logger.dart'; -import '../models/sync_message.dart'; -import '../models/watch_session.dart'; -import 'watch_together_peer_service.dart'; - -/// Callback type for when session configuration is received -typedef SessionConfigCallback = void Function(ControlMode controlMode); - -/// Callback type for when sync state changes -typedef SyncStateCallback = void Function(bool isSyncing); - -/// Callback type for when deferred play state changes -typedef DeferredPlayCallback = void Function(bool isDeferredPlay); - -/// Manages playback synchronization between peers -/// -/// This class: -/// - Subscribes to player stream events -/// - Broadcasts local playback actions to peers -/// - Applies remote playback actions to the local player -/// - Handles drift correction -class WatchTogetherSyncManager { - final WatchTogetherPeerService _peerService; - final String displayName; - WatchSession _session; - - Player? _player; - bool _isRemoteAction = false; // Flag to prevent echo - bool _isSyncing = false; // Flag for UI indicator during sync - - // Stream subscriptions (cancelled together in detachPlayer) - final List> _subscriptions = []; - Future _messageQueue = Future.value(); - int _playerAttachmentGeneration = 0; - - // Position sync timer (host broadcasts position periodically) - Timer? _positionSyncTimer; - - // Drift correction constants - static const Duration maxAllowedDrift = Duration(seconds: 2); - static const Duration positionSyncInterval = Duration(seconds: 3); - static const Duration excessiveDrift = Duration(seconds: 10); - - // Peer readiness state (peer ID -> hasPlayerReady) - final Map _peerReady = {}; - - // Whether play is deferred until all peers are ready (initial load gate) - bool _deferredPlay = false; - - // Position to seek to when deferred play triggers - Duration? _deferredPlayPosition; - - // Whether the first coordinated play has completed (after this, late joiners catch up via positionSync) - bool _firstPlayCompleted = false; - - // Clock offset estimation (NTP-style) - // Offset = how far ahead the host's clock is vs ours (in ms) - int _clockOffset = 0; - bool _hasClockOffset = false; - int? _pendingPingTimestamp; - Timer? _clockSyncTimer; - static const Duration _clockSyncInterval = Duration(seconds: 5); - - // Timer for clearing sync indicator (prevents flicker from overlapping corrections) - Timer? _syncingTimer; - - // Debounce timer for buffering broadcasts - Timer? _bufferingDebounceTimer; - - // Track last known state to avoid duplicate broadcasts - bool _lastKnownPlaying = false; - double _lastKnownRate = 1.0; - - // Whether we've announced our player as ready (first buffering: false) - bool _hasAnnouncedReady = false; - - // Whether the app is backgrounded (suppress heartbeats to avoid stale positions) - bool _backgrounded = false; - - // Callbacks - SessionConfigCallback? onSessionConfigReceived; - SyncStateCallback? onSyncStateChanged; - DeferredPlayCallback? onDeferredPlayChanged; - - WatchTogetherSyncManager({required this._peerService, required this._session, required this.displayName}); - - /// Update the session (e.g., when control mode changes) - void updateSession(WatchSession session) { - _session = session; - } - - /// Whether this manager has a player attached - bool get hasPlayer => _player != null; - - /// Whether all tracked peers have their player ready - bool get isAllReady { - if (_peerReady.isEmpty) return true; - return _peerReady.values.every((ready) => ready); - } - - /// Whether sync is in progress (for UI indicator) - bool get isSyncing => _isSyncing; - - /// Attach a player to sync - void attachPlayer(Player player) { - if (_player != null) { - detachPlayer(); - } - - _playerAttachmentGeneration++; - _player = player; - _lastKnownPlaying = player.state.playing; - _lastKnownRate = player.state.rate; - if (player.state.playing) { - _firstPlayCompleted = true; - } - - _setupPlayerSubscriptions(); - _setupMessageSubscription(); - - // If host, start broadcasting position periodically - if (_session.isHost) { - _startPositionSync(); - } - - // If the video is already loaded (buffering stream already fired before we - // subscribed), announce ready now so peers aren't stuck waiting. - if (!player.state.buffering && !_hasAnnouncedReady) { - _hasAnnouncedReady = true; - _peerReady[_peerService.myPeerId!] = true; - _peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true)); - appLogger.d('WatchTogether: Video already loaded on attach, announcing ready'); - if (_session.isHost) { - _sendSessionConfig(); - } - } - - // If guest, request current session config from host in case we missed - // a mediaSwitch broadcast (e.g., host switched episodes while we were - // popping out of the previous player). - if (!_session.isHost) { - _peerService.broadcast(SyncMessage.requestSessionConfig(peerId: _peerService.myPeerId)); - _startClockSync(); - } - - appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}'); - } - - /// Initialize participant tracking from existing session participants - /// Call this before attachPlayer() to ensure we know about participants who joined before - void initializeParticipants(List peerIds) { - // Clear stale entries (e.g. host's own peerId left over from a previous detachPlayer) - _peerReady.clear(); - for (final peerId in peerIds) { - if (peerId != _peerService.myPeerId) { - if (_session.isHost) { - // Host waits for each peer to load their video before allowing play. - _peerReady[peerId] = false; - } else { - // Guests use optimistic defaults — the host coordinates readiness - // and will broadcast pause/play as needed. - _peerReady[peerId] = true; - } - } - } - final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length; - appLogger.d('WatchTogether: Initialized $otherCount existing participants (host=${_session.isHost})'); - } - - /// Remove readiness tracking for a peer that dropped at the relay level. - Future handlePeerDisconnected(String peerId) async { - if (_peerReady.remove(peerId) != null) { - appLogger.d('WatchTogether: Removed disconnected peer readiness: $peerId'); - await _resumeDeferredPlayIfReady(_playerAttachmentGeneration); - } - } - - /// Detach the player and stop sync - void detachPlayer() { - _playerAttachmentGeneration++; - _player = null; - _isRemoteAction = false; - _setSyncing(false); - - // Announce that our player is no longer ready - if (_peerService.myPeerId != null) { - _peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: false)); - _peerReady[_peerService.myPeerId!] = false; - } - _hasAnnouncedReady = false; - _setDeferredPlay(false); - _deferredPlayPosition = null; - _firstPlayCompleted = false; - _syncingTimer?.cancel(); - _syncingTimer = null; - _bufferingDebounceTimer?.cancel(); - _bufferingDebounceTimer = null; - _clockSyncTimer?.cancel(); - _clockSyncTimer = null; - _clockOffset = 0; - _hasClockOffset = false; - _pendingPingTimestamp = null; - - final subscriptions = List>.of(_subscriptions); - _subscriptions.clear(); - for (final subscription in subscriptions) { - unawaited(subscription.cancel()); - } - _positionSyncTimer?.cancel(); - _positionSyncTimer = null; - _backgrounded = false; - - appLogger.d('WatchTogether: Player detached'); - } - - /// Set up subscriptions to player streams - void _setupPlayerSubscriptions() { - // Listen to playing state changes - _subscriptions.add( - _player!.streams.playing.listen((isPlaying) async { - if (_isRemoteAction) return; - if (isPlaying == _lastKnownPlaying) return; - final player = _player; - final attachmentGeneration = _playerAttachmentGeneration; - if (player == null || !_isPlayerAttachmentCurrent(player, attachmentGeneration)) return; - - _lastKnownPlaying = isPlaying; - - if (isPlaying && !isAllReady && !_firstPlayCompleted) { - // Defer until all peers have loaded video (initial sync only) - _setDeferredPlay(true); - _deferredPlayPosition = player.state.position; - _isRemoteAction = true; - try { - final didPause = await _runGuardedPlayerCommand( - actionName: 'deferred play pause', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.pause(), - ); - if (!didPause) return; - - _lastKnownPlaying = false; - } finally { - _isRemoteAction = false; - } - // Don't broadcast play — deferred play will broadcast when all peers are ready. - return; - } - - if (isPlaying && !_firstPlayCompleted) _firstPlayCompleted = true; - if (!isPlaying) _setDeferredPlay(false); - _broadcastPlayPause(isPlaying); - }), - ); - - // Listen to buffering state changes - _subscriptions.add( - _player!.streams.buffering.listen((isBuffering) async { - if (_isRemoteAction) return; - - // Announce ready when we stop buffering for the first time (video loaded) - if (!isBuffering && !_hasAnnouncedReady) { - _hasAnnouncedReady = true; - _peerReady[_peerService.myPeerId!] = true; - _peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true)); - appLogger.d('WatchTogether: Video loaded, announcing player ready'); - - if (_session.isHost) { - _sendSessionConfig(); - } - } - - // Broadcast for UI (peer buffering indicators) — debounced to avoid churn - _bufferingDebounceTimer?.cancel(); - _bufferingDebounceTimer = Timer(const Duration(milliseconds: 300), () { - _peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId)); - }); - }), - ); - - // Listen to rate changes - _subscriptions.add( - _player!.streams.rate.listen((rate) { - if (_isRemoteAction) return; - - if (rate != _lastKnownRate) { - _lastKnownRate = rate; - if (_canControl()) { - _peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId)); - } - } - }), - ); - } - - /// Set up subscription to incoming sync messages - void _setupMessageSubscription() { - _subscriptions.add( - _peerService.onMessageReceived.listen((message) { - final queuedAttachmentGeneration = _playerAttachmentGeneration; - _messageQueue = _messageQueue.then((_) => _handleMessage(message, queuedAttachmentGeneration)).catchError(( - Object error, - StackTrace stackTrace, - ) { - appLogger.e( - 'WatchTogether: Failed to handle ${message.type.name} message', - error: error, - stackTrace: stackTrace, - ); - }); - }), - ); - } - - /// Start periodic position sync (host only) - /// Includes play/pause state for eventual consistency - void _startPositionSync() { - _positionSyncTimer?.cancel(); - _positionSyncTimer = Timer.periodic(positionSyncInterval, (_) { - if (_player != null && _session.isHost && !_backgrounded) { - _peerService.broadcast( - SyncMessage.positionSync( - _player!.state.position, - peerId: _peerService.myPeerId, - isPlaying: _player!.state.playing, - ), - ); - } - }); - } - - /// Start NTP-style clock offset measurement (guest only) - void _startClockSync() { - _clockSyncTimer?.cancel(); - _hasClockOffset = false; - _clockOffset = 0; - _pendingPingTimestamp = null; - - // Initial burst of 2 pings for convergence, with wider spacing to reduce - // main-thread pressure during the join event storm - int burstCount = 0; - Timer.periodic(const Duration(milliseconds: 500), (timer) { - if (burstCount >= 2 || _player == null) { - timer.cancel(); - return; - } - _sendClockPing(); - burstCount++; - }); - - // Then continue at regular interval - _clockSyncTimer = Timer.periodic(_clockSyncInterval, (_) { - if (_player != null) _sendClockPing(); - }); - } - - /// Send a clock-sync ping (guest only) - void _sendClockPing() { - final now = DateTime.now().millisecondsSinceEpoch; - _pendingPingTimestamp = now; - _peerService.broadcast(SyncMessage.ping(now, peerId: _peerService.myPeerId)); - } - - /// Process a clock-sync pong and update clock offset (guest only) - void _processClockPong(SyncMessage message) { - if (_pendingPingTimestamp == null || message.pingId != _pendingPingTimestamp) { - return; // Not our ping, or stale - } - _pendingPingTimestamp = null; - - final t1 = message.pingId!; // Our original send timestamp - final t2 = message.timestamp; // Host's timestamp when it created the pong - final t3 = DateTime.now().millisecondsSinceEpoch; - - final rtt = t3 - t1; - if (rtt < 0 || rtt > 10_000) { - appLogger.w('WatchTogether: Discarding clock sample with RTT=${rtt}ms'); - return; - } - - // clockOffset = how far ahead host's clock is relative to ours - final sampleOffset = t2 - t1 - (rtt ~/ 2); - - if (!_hasClockOffset) { - _clockOffset = sampleOffset; - _hasClockOffset = true; - appLogger.d('WatchTogether: Initial clock offset: ${_clockOffset}ms (RTT: ${rtt}ms)'); - } else { - // Exponential moving average - const alpha = 0.3; - _clockOffset = (_clockOffset + (alpha * (sampleOffset - _clockOffset)).round()); - } - } - - /// Check if this peer can control playback - bool _canControl() { - if (_session.controlMode == ControlMode.anyone) { - return true; - } - return _session.isHost; - } - - /// Check if a remote control message should be applied based on control mode - bool _shouldApplyRemoteControl(SyncMessage message) { - if (_session.controlMode == ControlMode.anyone) { - return true; - } - // In hostOnly mode, only apply control messages from the host - return message.peerId == _session.hostPeerId; - } - - bool _isRecoverablePlayerException(PlatformException error) { - return error.code == 'COMMAND_FAILED' || error.code == 'NOT_INITIALIZED'; - } - - bool _isPlayerAttachmentCurrent(Player player, int attachmentGeneration) { - return identical(_player, player) && _playerAttachmentGeneration == attachmentGeneration; - } - - bool _isPlayerAttachmentUsable(Player player, int attachmentGeneration) { - return _isPlayerAttachmentCurrent(player, attachmentGeneration) && !player.disposed; - } - - void _handleRecoverableRemoteActionFailure( - String actionName, - Object error, { - required Player player, - required int attachmentGeneration, - }) { - appLogger.w('WatchTogether: Remote $actionName skipped because player became unavailable', error: error); - if (_isPlayerAttachmentCurrent(player, attachmentGeneration)) { - detachPlayer(); - } - } - - Future _runGuardedPlayerCommand({ - required String actionName, - required Player player, - required int attachmentGeneration, - required Future Function(Player player) command, - }) async { - if (!_isPlayerAttachmentUsable(player, attachmentGeneration)) { - if (_isPlayerAttachmentCurrent(player, attachmentGeneration)) { - _handleRecoverableRemoteActionFailure( - actionName, - StateError('Player became unavailable'), - player: player, - attachmentGeneration: attachmentGeneration, - ); - } - return false; - } - - try { - await command(player); - } on StateError catch (e) { - _handleRecoverableRemoteActionFailure(actionName, e, player: player, attachmentGeneration: attachmentGeneration); - return false; - } on PlatformException catch (e) { - if (_isRecoverablePlayerException(e)) { - _handleRecoverableRemoteActionFailure( - actionName, - e, - player: player, - attachmentGeneration: attachmentGeneration, - ); - return false; - } - rethrow; - } - - if (!_isPlayerAttachmentUsable(player, attachmentGeneration)) { - if (_isPlayerAttachmentCurrent(player, attachmentGeneration)) { - _handleRecoverableRemoteActionFailure( - actionName, - StateError('Player became unavailable'), - player: player, - attachmentGeneration: attachmentGeneration, - ); - } - return false; - } - - return true; - } - - Future _runGuardedRemoteAction({ - required String actionName, - required Future Function(Player player, int attachmentGeneration) action, - int? expectedAttachmentGeneration, - }) async { - final player = _player; - if (player == null) return false; - - final attachmentGeneration = _playerAttachmentGeneration; - if (expectedAttachmentGeneration != null && expectedAttachmentGeneration != attachmentGeneration) { - return false; - } - - if (!_isPlayerAttachmentUsable(player, attachmentGeneration)) { - _handleRecoverableRemoteActionFailure( - actionName, - StateError('Player became unavailable'), - player: player, - attachmentGeneration: attachmentGeneration, - ); - return false; - } - - _isRemoteAction = true; - try { - return await action(player, attachmentGeneration); - } on StateError catch (e) { - _handleRecoverableRemoteActionFailure(actionName, e, player: player, attachmentGeneration: attachmentGeneration); - return false; - } on PlatformException catch (e) { - if (_isRecoverablePlayerException(e)) { - _handleRecoverableRemoteActionFailure( - actionName, - e, - player: player, - attachmentGeneration: attachmentGeneration, - ); - return false; - } - rethrow; - } finally { - _isRemoteAction = false; - } - } - - /// Broadcast play/pause state - void _broadcastPlayPause(bool isPlaying) { - if (!_canControl()) return; - - if (isPlaying) { - final position = _player?.state.position ?? Duration.zero; - _peerService.broadcast(SyncMessage.play(peerId: _peerService.myPeerId, position: position)); - } else { - _peerService.broadcast(SyncMessage.pause(peerId: _peerService.myPeerId)); - } - } - - /// Called when user seeks locally - void onLocalSeek(Duration position) { - if (!_canControl()) return; - - _peerService.broadcast(SyncMessage.seek(position, peerId: _peerService.myPeerId)); - } - - /// Handle incoming sync messages - Future _handleMessage(SyncMessage message, int queuedAttachmentGeneration) async { - // Ignore our own messages - if (message.peerId == _peerService.myPeerId) { - return; - } - - // In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig) - if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) { - final isHostMessage = message.peerId == _session.hostPeerId; - final isMetaMessage = - message.type == SyncMessageType.join || - message.type == SyncMessageType.leave || - message.type == SyncMessageType.sessionConfig || - message.type == SyncMessageType.buffering || - message.type == SyncMessageType.ping || - message.type == SyncMessageType.pong || - message.type == SyncMessageType.mediaSwitch; - - if (!isHostMessage && !isMetaMessage) return; - } - - switch (message.type) { - case SyncMessageType.play: - if (!_shouldApplyRemoteControl(message)) break; - await _applyRemotePlay(position: message.position, expectedAttachmentGeneration: queuedAttachmentGeneration); - break; - - case SyncMessageType.pause: - if (!_shouldApplyRemoteControl(message)) break; - _setDeferredPlay(false); - await _applyRemotePause(expectedAttachmentGeneration: queuedAttachmentGeneration); - break; - - case SyncMessageType.seek: - if (!_shouldApplyRemoteControl(message)) break; - if (message.position != null) { - await _applyRemoteSeek(message.position!, expectedAttachmentGeneration: queuedAttachmentGeneration); - } - break; - - case SyncMessageType.buffering: - // Buffering state used for UI only, not playback control - break; - - case SyncMessageType.positionSync: - if (message.position != null) { - await _checkAndCorrectDrift(message.position!, message.timestamp, queuedAttachmentGeneration); - } - // Reconcile play/pause state if host sent it and we diverged - // This provides eventual consistency for play/pause state - final player = _player; - if (message.isPlaying != null && player != null && !_session.isHost) { - if (!_isPlayerAttachmentCurrent(player, queuedAttachmentGeneration)) { - break; - } - - final localPlaying = player.state.playing; - if (message.isPlaying! && !localPlaying) { - await _applyRemotePlay( - position: message.position, - expectedAttachmentGeneration: queuedAttachmentGeneration, - ); - } else if (!message.isPlaying! && localPlaying) { - await _applyRemotePause(expectedAttachmentGeneration: queuedAttachmentGeneration); - } - } - break; - - case SyncMessageType.rate: - if (!_shouldApplyRemoteControl(message)) break; - if (message.rate != null) { - await _applyRemoteRate(message.rate!, expectedAttachmentGeneration: queuedAttachmentGeneration); - } - break; - - case SyncMessageType.join: - _handlePeerJoin(message); - break; - - case SyncMessageType.leave: - if (message.peerId != null) { - _peerReady.remove(message.peerId); - await _resumeDeferredPlayIfReady(queuedAttachmentGeneration); - } - break; - - case SyncMessageType.sessionConfig: - await _handleSessionConfig(message, queuedAttachmentGeneration); - break; - - case SyncMessageType.ping: - if (message.pingId != null) { - final pong = SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId); - if (message.peerId != null) { - _peerService.sendTo(message.peerId!, pong); - } else { - _peerService.broadcast(pong); - } - } - break; - - case SyncMessageType.pong: - if (message.pingId != null && !_session.isHost) { - _processClockPong(message); - } - break; - - case SyncMessageType.mediaSwitch: - // Handled at the provider level, not in sync manager - break; - - case SyncMessageType.hostExitedPlayer: - // Handled at the provider level, not in sync manager - break; - - case SyncMessageType.playerReady: - if (message.peerId != null) { - _peerReady[message.peerId!] = message.bufferingState ?? false; - appLogger.d('WatchTogether: Peer ${message.peerId} player ready: ${message.bufferingState}'); - - await _resumeDeferredPlayIfReady(queuedAttachmentGeneration); - } - break; - - case SyncMessageType.requestSessionConfig: - // Guest is requesting current session config (recovery after missed mediaSwitch) - if (_session.isHost && _hasAnnouncedReady && message.peerId != null) { - appLogger.d('WatchTogether: Guest ${message.peerId} requested session config, sending'); - _sendSessionConfig(toPeerId: message.peerId); - } - break; - } - } - - /// Apply remote play command - Future _applyRemotePlay({Duration? position, int? expectedAttachmentGeneration}) async { - return _runGuardedRemoteAction( - actionName: 'play', - expectedAttachmentGeneration: expectedAttachmentGeneration, - action: (player, attachmentGeneration) async { - if (position != null) { - final didSeek = await _runGuardedPlayerCommand( - actionName: 'play seek', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.seek(position), - ); - if (!didSeek) return false; - } - - final didPlay = await _runGuardedPlayerCommand( - actionName: 'play', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.play(), - ); - if (!didPlay) return false; - - _firstPlayCompleted = true; - _lastKnownPlaying = true; - return true; - }, - ); - } - - Future _resumeDeferredPlayIfReady(int expectedAttachmentGeneration) async { - if (!_deferredPlay || !isAllReady) return; - - _setDeferredPlay(false); - _firstPlayCompleted = true; - final pos = _deferredPlayPosition; - _deferredPlayPosition = null; - await _applyRemotePlay(position: pos, expectedAttachmentGeneration: expectedAttachmentGeneration); - // Broadcast play to all peers now that everyone is ready. - _broadcastPlayPause(true); - } - - /// Apply remote pause command - Future _applyRemotePause({int? expectedAttachmentGeneration}) async { - return _runGuardedRemoteAction( - actionName: 'pause', - expectedAttachmentGeneration: expectedAttachmentGeneration, - action: (player, attachmentGeneration) async { - final didPause = await _runGuardedPlayerCommand( - actionName: 'pause', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.pause(), - ); - if (!didPause) return false; - - _lastKnownPlaying = false; - return true; - }, - ); - } - - /// Apply remote seek command - Future _applyRemoteSeek(Duration position, {int? expectedAttachmentGeneration}) async { - return _runGuardedRemoteAction( - actionName: 'seek', - expectedAttachmentGeneration: expectedAttachmentGeneration, - action: (player, attachmentGeneration) { - return _runGuardedPlayerCommand( - actionName: 'seek', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.seek(position), - ); - }, - ); - } - - /// Apply remote rate change - Future _applyRemoteRate(double rate, {int? expectedAttachmentGeneration}) async { - return _runGuardedRemoteAction( - actionName: 'rate', - expectedAttachmentGeneration: expectedAttachmentGeneration, - action: (player, attachmentGeneration) async { - final didSetRate = await _runGuardedPlayerCommand( - actionName: 'rate', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.setRate(rate), - ); - if (!didSetRate) return false; - - _lastKnownRate = rate; - return true; - }, - ); - } - - /// Check and correct position drift - Future _checkAndCorrectDrift( - Duration remotePosition, - int remoteTimestamp, - int expectedAttachmentGeneration, - ) async { - if (_session.isHost) return; - - final player = _player; - if (player == null || !_isPlayerAttachmentCurrent(player, expectedAttachmentGeneration)) return; - - final localPosition = player.state.position; - final now = DateTime.now().millisecondsSinceEpoch; - - // Translate host's timestamp to our local time frame using clock offset - // _clockOffset = hostClock - localClock, so localEquivalent = remoteTimestamp - _clockOffset - final adjustedRemoteTimestamp = remoteTimestamp - _clockOffset; - final rawDelay = now - adjustedRemoteTimestamp; - - // Before clock offset is available, use 0 (compare positions directly) - final networkDelay = _hasClockOffset ? rawDelay.clamp(0, 5000) : 0; - - // Estimate where remote should be now, accounting for playback time elapsed - var estimatedRemoteNow = remotePosition; - if (player.state.playing && networkDelay > 0) { - // If playing, account for time elapsed during network transit - // Multiply by rate in case playback speed is different - estimatedRemoteNow = remotePosition + Duration(milliseconds: (networkDelay * player.state.rate).round()); - } - - final drift = (localPosition - estimatedRemoteNow).abs(); - - if (drift > excessiveDrift) { - // Excessive drift - force sync with indicator - appLogger.w('WatchTogether: Excessive drift (${drift.inSeconds}s), force syncing'); - _setSyncing(true); - final didSeek = await _applyRemoteSeek( - estimatedRemoteNow, - expectedAttachmentGeneration: expectedAttachmentGeneration, - ); - if (!didSeek) { - _setSyncing(false); - return; - } - _syncingTimer?.cancel(); - _syncingTimer = Timer(const Duration(milliseconds: 500), () => _setSyncing(false)); - } else if (drift > maxAllowedDrift) { - _setSyncing(true); - final didSeek = await _applyRemoteSeek( - estimatedRemoteNow, - expectedAttachmentGeneration: expectedAttachmentGeneration, - ); - if (!didSeek) { - _setSyncing(false); - return; - } - _syncingTimer?.cancel(); - _syncingTimer = Timer(const Duration(milliseconds: 300), () => _setSyncing(false)); - } - } - - /// Handle peer join message - void _handlePeerJoin(SyncMessage message) { - appLogger.d('WatchTogether: Peer joined: ${message.displayName}'); - - if (message.peerId != null) { - if (_session.isHost) { - _peerReady[message.peerId!] = false; - } else if (!_peerReady.containsKey(message.peerId!)) { - _peerReady[message.peerId!] = true; - } - } - - // If we're the host, send session config AND our own join info to the new peer - if (_session.isHost && message.peerId != null) { - // Only send config if our video is loaded (we know the correct position) - if (_hasAnnouncedReady) { - _sendSessionConfig(toPeerId: message.peerId); - - _peerService.sendTo(message.peerId!, SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true)); - } - } - } - - /// Handle session config from host - Future _handleSessionConfig(SyncMessage message, int expectedAttachmentGeneration) async { - if (_session.isHost) return; // Host doesn't need to process config - - appLogger.d('WatchTogether: Received session config'); - - // The host only sends sessionConfig after its player is ready, so we - // can safely mark it as ready. - if (message.peerId != null) { - _peerReady[message.peerId!] = true; - } - - // Update control mode - if (message.controlMode != null) { - onSessionConfigReceived?.call(message.controlMode!); - } - - final applied = await _runGuardedRemoteAction( - actionName: 'session config', - expectedAttachmentGeneration: expectedAttachmentGeneration, - action: (player, attachmentGeneration) async { - // Always seek to host's position first - if (message.position != null) { - final didSeek = await _runGuardedPlayerCommand( - actionName: 'session config seek', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.seek(message.position!), - ); - if (!didSeek) return false; - } - - // Match playback rate - if (message.rate != null) { - final didSetRate = await _runGuardedPlayerCommand( - actionName: 'session config rate', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.setRate(message.rate!), - ); - if (!didSetRate) return false; - - _lastKnownRate = message.rate!; - } - - // Match play/pause state (prefer isPlaying, fall back to legacy bufferingState encoding) - final hostIsPlaying = message.isPlaying ?? (message.bufferingState == false); - if (hostIsPlaying) { - // Host was playing — defer until our video is loaded - _setDeferredPlay(true); - _deferredPlayPosition = message.position; - if (_hasAnnouncedReady) { - _setDeferredPlay(false); - _firstPlayCompleted = true; - final didPlay = await _runGuardedPlayerCommand( - actionName: 'session config play', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.play(), - ); - if (!didPlay) return false; - - _lastKnownPlaying = true; - } - } else { - final didPause = await _runGuardedPlayerCommand( - actionName: 'session config pause', - player: player, - attachmentGeneration: attachmentGeneration, - command: (player) => player.pause(), - ); - if (!didPause) return false; - - _lastKnownPlaying = false; - } - - return true; - }, - ); - - if (applied) { - _reannounceReady(reason: 'session config'); - } - } - - /// Set syncing state and notify listeners - void _setSyncing(bool isSyncing) { - if (_isSyncing != isSyncing) { - _isSyncing = isSyncing; - onSyncStateChanged?.call(isSyncing); - } - } - - /// Set deferred play state and notify listeners - void _setDeferredPlay(bool value) { - if (_deferredPlay != value) { - _deferredPlay = value; - onDeferredPlayChanged?.call(value); - } - } - - /// Suppress heartbeats while the app is backgrounded. - /// - /// macOS App Nap can throttle the event loop, causing stale position reads. - /// Guests would drift-correct to the stale position every heartbeat, making - /// playback loop. Pausing heartbeats avoids this; drift correction catches - /// up when the app returns to the foreground. - void setBackgrounded(bool value) { - _backgrounded = value; - } - - void _reannounceReady({required String reason}) { - if (_hasAnnouncedReady && _peerService.myPeerId != null) { - _peerReady[_peerService.myPeerId!] = true; - _peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true)); - appLogger.d('WatchTogether: Re-announced player ready after $reason'); - } - } - - /// Re-announce player readiness after reconnect. - /// - /// During reconnect the host resets our _peerReady entry to false via - /// _handlePeerJoin, but our _hasAnnouncedReady flag is still true (never - /// reset because the player stays attached). Re-broadcast so the host - /// doesn't stay stuck in the deferred-play gate. - void reannounceReadyIfNeeded() { - _reannounceReady(reason: 'reconnect'); - } - - /// Send join announcement to all peers - void announceJoin(String displayName) { - _peerService.broadcast( - SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: _session.isHost), - ); - } - - /// Send leave announcement to all peers - void announceLeave() { - if (_peerService.myPeerId != null) { - _peerService.broadcast(SyncMessage.leave(peerId: _peerService.myPeerId!)); - } - } - - /// Send current session configuration to peers - void _sendSessionConfig({String? toPeerId}) { - if (!_session.isHost || _peerService.myPeerId == null) return; - - final position = _player?.state.position ?? Duration.zero; - final isPlaying = _player?.state.playing ?? false; - final rate = _player?.state.rate ?? 1.0; - - final configMessage = SyncMessage.sessionConfig( - controlMode: _session.controlMode, - currentPosition: position, - isPlaying: isPlaying, - playbackRate: rate, - peerId: _peerService.myPeerId, - ratingKey: _session.mediaRatingKey, - serverId: _session.mediaServerId, - mediaTitle: _session.mediaTitle, - ); - - if (toPeerId != null) { - _peerService.sendTo(toPeerId, configMessage); - } else { - _peerService.broadcast(configMessage); - } - } - - /// Dispose resources - void dispose() { - _clockSyncTimer?.cancel(); - _syncingTimer?.cancel(); - detachPlayer(); - _peerReady.clear(); - _hasAnnouncedReady = false; - } -} diff --git a/lib/watch_together/watch_together.dart b/lib/watch_together/watch_together.dart index 98a4f831..cab60ee1 100644 --- a/lib/watch_together/watch_together.dart +++ b/lib/watch_together/watch_together.dart @@ -1,10 +1,11 @@ // Models export 'models/watch_session.dart'; export 'models/sync_message.dart'; +export 'models/playback_state.dart'; // Services export 'services/watch_together_peer_service.dart'; -export 'services/watch_together_sync_manager.dart'; +export 'services/watch_together_controller.dart'; // Providers export 'providers/watch_together_provider.dart'; diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 081720b5..a38ebd36 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -338,6 +338,8 @@ class _ParticipantNotificationOverlayState extends State t.watchTogether.participantResumed(name: n.event.displayName), ParticipantEventType.seeked => t.watchTogether.participantSeeked(name: n.event.displayName), 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), }; return Container( key: ValueKey(n.id), @@ -380,13 +382,20 @@ class SyncingIndicator extends StatelessWidget { class WaitingForParticipantsIndicator extends StatelessWidget { const WaitingForParticipantsIndicator({super.key}); + static String _label(List names) { + if (names.isEmpty) return t.watchTogether.waitingForParticipants; + final shown = names.length <= 2 ? names.join(', ') : '${names.take(2).join(', ')} +${names.length - 2}'; + return t.watchTogether.waitingForName(name: shown); + } + @override Widget build(BuildContext context) { - return Selector( - selector: (_, provider) => provider.isDeferredPlay, - builder: (context, isDeferredPlay, child) { - if (!isDeferredPlay) return const SizedBox.shrink(); - return _StatusPill(tvIcon: Symbols.hourglass_empty_rounded, label: t.watchTogether.waitingForParticipants); + return Selector)>( + selector: (_, provider) => (provider.isWaitingForPeers, provider.waitingOnNames), + builder: (context, value, child) { + final (isWaiting, names) = value; + if (!isWaiting) return const SizedBox.shrink(); + return _StatusPill(tvIcon: Symbols.hourglass_empty_rounded, label: _label(names)); }, ); } diff --git a/test/test_helpers/watch_together_fakes.dart b/test/test_helpers/watch_together_fakes.dart new file mode 100644 index 00000000..4e8bcb12 --- /dev/null +++ b/test/test_helpers/watch_together_fakes.dart @@ -0,0 +1,329 @@ +import 'dart:async'; + +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/watch_together/models/sync_message.dart'; +import 'package:plezy/watch_together/services/watch_together_peer_service.dart'; + +/// Rich fake [Player] for Watch Together sync tests. +/// +/// Commands mutate state and emit the corresponding property events on a +/// microtask (mirroring the real command-ack-then-property-event ordering); +/// drive externally-caused transitions with the `emit*` helpers. Designed to +/// run under `fakeAsync` — nothing here uses wall-clock time. +class FakeSyncPlayer implements Player { + FakeSyncPlayer({ + bool playing = false, + bool buffering = false, + Duration position = Duration.zero, + Duration duration = const Duration(minutes: 45), + bool seekable = true, + double rate = 1.0, + }) : _state = PlayerState( + playing: playing, + buffering: buffering, + position: position, + duration: duration, + seekable: seekable, + rate: rate, + ); + + PlayerState _state; + bool _disposed = false; + + /// When set, the next command throws this and clears the field. + Object? nextCommandError; + + /// Simulates bitstream audio ignoring rate changes: setRate succeeds but + /// neither state nor the rate stream reflect it. + bool ignoreRateChanges = false; + + /// Whether seeks emit a playback-restart event (first frame after seek). + bool emitRestartOnSeek = true; + + @override + bool audioPassthroughActive = false; + + final commandLog = []; + + final _playingController = StreamController.broadcast(); + final _bufferingController = StreamController.broadcast(); + final _rateController = StreamController.broadcast(); + final _playbackRestartController = StreamController.broadcast(); + final _durationController = StreamController.broadcast(); + + @override + PlayerState get state => _state; + + @override + Duration get currentPosition => _state.position; + + @override + PlayerStreams get streams => PlayerStreams( + playing: _playingController.stream, + completed: const Stream.empty(), + buffering: _bufferingController.stream, + position: const Stream.empty(), + duration: _durationController.stream, + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: const Stream.empty(), + rate: _rateController.stream, + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: _playbackRestartController.stream, + backendSwitched: const Stream.empty(), + ); + + void _maybeThrow() { + final error = nextCommandError; + if (error != null) { + nextCommandError = null; + throw error; + } + } + + @override + Future play() async { + commandLog.add('play'); + _maybeThrow(); + if (_state.playing) return; + _state = _state.copyWith(playing: true); + _playingController.add(true); + } + + @override + Future pause() async { + commandLog.add('pause'); + _maybeThrow(); + if (!_state.playing) return; + _state = _state.copyWith(playing: false); + _playingController.add(false); + } + + @override + Future seek(Duration position) async { + commandLog.add('seek:${position.inMilliseconds}'); + _maybeThrow(); + _state = _state.copyWith(position: position); + if (emitRestartOnSeek) _playbackRestartController.add(null); + } + + @override + Future setRate(double rate) async { + commandLog.add('rate:$rate'); + _maybeThrow(); + if (ignoreRateChanges || _state.rate == rate) return; + _state = _state.copyWith(rate: rate); + _rateController.add(rate); + } + + /// Externally-caused playing transition (e.g. user pressed a media key). + void emitPlaying(bool value) { + if (_state.playing == value) return; + _state = _state.copyWith(playing: value); + _playingController.add(value); + } + + /// Externally-caused buffering transition (paused-for-cache). + void emitBuffering(bool value) { + if (_state.buffering == value) return; + _state = _state.copyWith(buffering: value); + _bufferingController.add(value); + } + + /// Externally-caused rate transition. + void emitRate(double value) { + _state = _state.copyWith(rate: value); + _rateController.add(value); + } + + /// First frame rendered (after load). + void emitPlaybackRestart() => _playbackRestartController.add(null); + + void emitDuration(Duration value) { + _state = _state.copyWith(duration: value); + _durationController.add(value); + } + + void setPosition(Duration position) { + _state = _state.copyWith(position: position); + } + + /// Advance the playhead as if [elapsed] of playback happened. + void advanceBy(Duration elapsed) { + if (!_state.playing || _state.buffering) return; + setPosition(_state.position + elapsed * _state.rate); + } + + void setBuffer(Duration bufferEnd) { + _state = _state.copyWith(buffer: bufferEnd); + } + + void setCompleted(bool completed) { + _state = _state.copyWith(completed: completed); + } + + @override + bool get disposed => _disposed; + + @override + Future dispose({bool preserveDisplayMode = false}) async { + if (_disposed) return; + _disposed = true; + await _playingController.close(); + await _bufferingController.close(); + await _rateController.close(); + await _playbackRestartController.close(); + await _durationController.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Standalone recording fake peer service (no relay behind it). +class FakeWatchTogetherPeerService extends WatchTogetherPeerService { + FakeWatchTogetherPeerService({required this.peerId}) : super(customBaseUrl: 'http://localhost'); + + final String peerId; + final _messages = StreamController.broadcast(); + final _peerConnected = StreamController.broadcast(); + final _peerDisconnected = StreamController.broadcast(); + + final List broadcasts = []; + final Map> sent = {}; + + @override + String? get myPeerId => peerId; + + @override + Stream get onMessageReceived => _messages.stream; + + @override + Stream get onPeerConnected => _peerConnected.stream; + + @override + Stream get onPeerDisconnected => _peerDisconnected.stream; + + @override + void broadcast(SyncMessage message) { + broadcasts.add(message); + } + + @override + void sendTo(String peerId, SyncMessage message) { + sent.putIfAbsent(peerId, () => []).add(message); + } + + /// All recorded outgoing messages of [type], broadcast and targeted. + Iterable outgoing(SyncMessageType type) => + [...broadcasts, ...sent.values.expand((m) => m)].where((m) => m.type == type); + + void emit(SyncMessage message) => _messages.add(message); + + void emitPeerConnected(String peerId) => _peerConnected.add(peerId); + + void emitPeerDisconnected(String peerId) => _peerDisconnected.add(peerId); + + Future close() async { + await _messages.close(); + await _peerConnected.close(); + await _peerDisconnected.close(); + } +} + +/// In-memory relay linking [HubPeerService]s for duplex end-to-end tests. +/// +/// Mirrors the real relay's contract: broadcasts fan out to every other +/// peer, sendTo targets one, the sender id is stamped server-side +/// ([SyncMessage.copyWith]), and registration/disconnection emit +/// peerJoined/peerLeft events to the others. +class FakeRelayHub { + final Map _peers = {}; + + HubPeerService register(String peerId) { + final service = HubPeerService._(peerId, this); + for (final existing in _peers.values) { + existing._peerConnected.add(peerId); + service._peerConnected.add(existing.peerId); + } + _peers[peerId] = service; + return service; + } + + void disconnect(String peerId) { + if (_peers.remove(peerId) == null) return; + for (final other in _peers.values) { + other._peerDisconnected.add(peerId); + } + } + + void _broadcast(String from, SyncMessage message) { + final stamped = message.peerId == from ? message : message.copyWith(peerId: from); + for (final entry in _peers.entries) { + if (entry.key == from) continue; + entry.value._messages.add(stamped); + } + } + + void _sendTo(String from, String to, SyncMessage message) { + final stamped = message.peerId == from ? message : message.copyWith(peerId: from); + _peers[to]?._messages.add(stamped); + } + + Future dispose() async { + final peers = _peers.values.toList(); + _peers.clear(); + for (final peer in peers) { + await peer.closeHub(); + } + } +} + +class HubPeerService extends WatchTogetherPeerService { + HubPeerService._(this.peerId, this._hub) : super(customBaseUrl: 'http://localhost'); + + final String peerId; + final FakeRelayHub _hub; + final _messages = StreamController.broadcast(); + final _peerConnected = StreamController.broadcast(); + final _peerDisconnected = StreamController.broadcast(); + + /// Outgoing log (in addition to hub routing), for assertions. + final List outgoingLog = []; + + @override + String? get myPeerId => peerId; + + @override + Stream get onMessageReceived => _messages.stream; + + @override + Stream get onPeerConnected => _peerConnected.stream; + + @override + Stream get onPeerDisconnected => _peerDisconnected.stream; + + @override + void broadcast(SyncMessage message) { + outgoingLog.add(message); + _hub._broadcast(peerId, message); + } + + @override + void sendTo(String peerId, SyncMessage message) { + outgoingLog.add(message); + _hub._sendTo(this.peerId, peerId, message); + } + + Future closeHub() async { + await _messages.close(); + await _peerConnected.close(); + await _peerDisconnected.close(); + } +} diff --git a/test/watch_together/attached_player_test.dart b/test/watch_together/attached_player_test.dart new file mode 100644 index 00000000..87fe07a4 --- /dev/null +++ b/test/watch_together/attached_player_test.dart @@ -0,0 +1,248 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/services/attached_player.dart'; + +import '../test_helpers/watch_together_fakes.dart'; + +void main() { + (AttachedPlayer, FakeSyncPlayer, List) build( + FakeAsync async, { + bool playing = false, + Future Function(Duration)? remoteSeek, + }) { + final player = FakeSyncPlayer(playing: playing); + final lostEvents = []; + final attached = AttachedPlayer( + player: player, + onLost: () => lostEvents.add('lost'), + remoteSeek: remoteSeek, + nowMs: () => async.elapsed.inMilliseconds, + ); + return (attached, player, lostEvents); + } + + group('expected-state ledger', () { + test('command-induced transitions are consumed as acks, not intents', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final intents = []; + attached.playingIntents.listen(intents.add); + + attached.play(); + async.flushMicrotasks(); + + expect(player.state.playing, isTrue); + expect(intents, isEmpty); + attached.dispose(); + }); + }); + + test('late property events (after the command future) are still acks', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final intents = []; + attached.playingIntents.listen(intents.add); + + // Simulate the real backend: command ack now, property event later. + player.emitRestartOnSeek = false; + attached.pause(); // No-op: already paused — expectation lingers. + async.flushMicrotasks(); + attached.play(); + async.flushMicrotasks(); + expect(intents, isEmpty); + attached.dispose(); + }); + }); + + test('user transitions with no matching expectation are intents', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final intents = []; + attached.playingIntents.listen(intents.add); + + player.emitPlaying(true); + async.flushMicrotasks(); + player.emitPlaying(false); + async.flushMicrotasks(); + + expect(intents, [true, false]); + attached.dispose(); + }); + }); + + test('expired expectations no longer absorb user transitions', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final intents = []; + attached.playingIntents.listen(intents.add); + + // Command is silently swallowed (no event) — e.g. seek-before-load. + player.nextCommandError = null; + attached.pause(); // Already paused: no event, expectation parked. + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 4)); // Past the 3s TTL. + player.emitPlaying(true); + player.emitPlaying(false); // User pause must NOT be eaten. + async.flushMicrotasks(); + + expect(intents, [true, false]); + attached.dispose(); + }); + }); + + test('rate acks are consumed, user rate changes are intents', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final intents = []; + attached.rateIntents.listen(intents.add); + + attached.setRate(1.04); + async.flushMicrotasks(); + expect(intents, isEmpty); + + player.emitRate(2.0); + async.flushMicrotasks(); + expect(intents, [2.0]); + attached.dispose(); + }); + }); + }); + + group('guarded commands', () { + test('recoverable PlatformException reports failure and fires onLost once', () { + fakeAsync((async) { + final (attached, player, lostEvents) = build(async); + + player.nextCommandError = PlatformException(code: 'COMMAND_FAILED'); + bool? result; + attached.play().then((v) => result = v); + async.flushMicrotasks(); + expect(result, isFalse); + expect(lostEvents, hasLength(1)); + + player.nextCommandError = PlatformException(code: 'NOT_INITIALIZED'); + attached.pause().then((v) => result = v); + async.flushMicrotasks(); + expect(result, isFalse); + expect(lostEvents, hasLength(1)); // Still once. + attached.dispose(); + }); + }); + + test('non-recoverable PlatformException rethrows', () { + fakeAsync((async) { + final (attached, player, lostEvents) = build(async); + + player.nextCommandError = PlatformException(code: 'SOMETHING_ELSE'); + Object? error; + attached.play().catchError((Object e) { + error = e; + return false; + }); + async.flushMicrotasks(); + expect(error, isA()); + expect(lostEvents, isEmpty); + attached.dispose(); + }); + }); + + test('commands against a disposed player fail and fire onLost', () { + fakeAsync((async) { + final (attached, player, lostEvents) = build(async); + player.dispose(); + async.flushMicrotasks(); + + bool? result; + attached.play().then((v) => result = v); + async.flushMicrotasks(); + expect(result, isFalse); + expect(lostEvents, hasLength(1)); + attached.dispose(); + }); + }); + + test('disposing the attachment does not fire onLost', () { + fakeAsync((async) { + final (attached, _, lostEvents) = build(async); + attached.dispose(); + async.flushMicrotasks(); + + bool? result; + attached.play().then((v) => result = v); + async.flushMicrotasks(); + expect(result, isFalse); + expect(lostEvents, isEmpty); + }); + }); + }); + + group('seek routing', () { + test('uses the remote-seek delegate when provided', () { + fakeAsync((async) { + final delegated = []; + final (attached, player, _) = build(async, remoteSeek: (target) async => delegated.add(target)); + + attached.seek(const Duration(seconds: 30)); + async.flushMicrotasks(); + + expect(delegated, [const Duration(seconds: 30)]); + expect(player.commandLog.where((c) => c.startsWith('seek:')), isEmpty); + attached.dispose(); + }); + }); + + test('falls back to player.seek when the delegate throws', () { + fakeAsync((async) { + final (attached, player, lostEvents) = build(async, remoteSeek: (_) async => throw StateError('screen gone')); + + bool? result; + attached.seek(const Duration(seconds: 30)).then((v) => result = v); + async.flushMicrotasks(); + + expect(result, isTrue); + expect(player.state.position, const Duration(seconds: 30)); + expect(lostEvents, isEmpty); + attached.dispose(); + }); + }); + }); + + group('signals and snapshots', () { + test('forwards buffering transitions and playback-restart signals', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + final buffering = []; + var loaded = 0; + attached.bufferingChanges.listen(buffering.add); + attached.loadedSignals.listen((_) => loaded++); + + player.emitBuffering(true); + player.emitBuffering(true); // Duplicate suppressed. + player.emitBuffering(false); + player.emitPlaybackRestart(); + async.flushMicrotasks(); + + expect(buffering, [true, false]); + expect(loaded, 1); + attached.dispose(); + }); + }); + + test('bufferAhead is null when unknown and clamps at zero', () { + fakeAsync((async) { + final (attached, player, _) = build(async); + expect(attached.bufferAhead, isNull); + + player.setPosition(const Duration(seconds: 10)); + player.setBuffer(const Duration(seconds: 18)); + expect(attached.bufferAhead, const Duration(seconds: 8)); + + player.setBuffer(const Duration(seconds: 5)); + expect(attached.bufferAhead, Duration.zero); + attached.dispose(); + }); + }); + }); +} diff --git a/test/watch_together/clock_sync_test.dart b/test/watch_together/clock_sync_test.dart new file mode 100644 index 00000000..e7ca66ab --- /dev/null +++ b/test/watch_together/clock_sync_test.dart @@ -0,0 +1,138 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/services/clock_sync.dart'; + +void main() { + // Drives ClockSync with a virtual clock anchored to fakeAsync's elapsed time. + (ClockSync, List) build(FakeAsync async, {int epochMs = 1000000}) { + final pings = []; + final sync = ClockSync(sendPing: pings.add, nowMs: () => epochMs + async.elapsed.inMilliseconds); + return (sync, pings); + } + + test('sends a convergence burst then settles into the steady interval', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + expect(pings.length, 1); // Immediate first ping. + + async.elapse(const Duration(milliseconds: 1100)); + expect(pings.length, 3); // Burst of 3 total. + + async.elapse(const Duration(seconds: 10)); + expect(pings.length, 5); // Two steady 5s ticks. + + sync.stop(); + async.elapse(const Duration(seconds: 30)); + expect(pings.length, 5); + }); + }); + + test('computes the offset from a pong and translates host time', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + final pingId = pings.single; + + // 100ms RTT; host clock 5000ms ahead of ours at the midpoint. + async.elapse(const Duration(milliseconds: 100)); + final hostAtMidpoint = pingId + 50 + 5000; + sync.onPong(pingId, hostAtMidpoint); + + expect(sync.offsetMs, 5000); + expect(sync.minRttMs, 100); + expect(sync.hostNowMs(), 1000000 + 100 + 5000); + sync.stop(); + }); + }); + + test('prefers the lowest-RTT sample in the window', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + + // First exchange: jittery 100ms RTT with a wildly wrong offset. + final first = pings[0]; // Sent at t=0; ping id == send timestamp. + async.elapse(const Duration(milliseconds: 100)); + sync.onPong(first, first + 50 + 9999); + expect(sync.offsetMs, 9999); + expect(sync.minRttMs, 100); + + // Burst ping at t=500; answer it with a clean 40ms RTT. + async.elapse(const Duration(milliseconds: 400)); + final second = pings[1]; + async.elapse(const Duration(milliseconds: 40)); + sync.onPong(second, second + 20 + 5000); + + expect(sync.minRttMs, 40); + expect(sync.offsetMs, 5000); + sync.stop(); + }); + }); + + test('discards samples with RTT over a second and unknown ping ids', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + final pingId = pings.single; + + sync.onPong(123456789, 42); // Not ours. + expect(sync.offsetMs, isNull); + + async.elapse(const Duration(milliseconds: 1500)); + sync.onPong(pingId, pingId + 750); + expect(sync.offsetMs, isNull); // RTT 1500ms discarded. + + // A pong for an already-consumed/never-sent id stays ignored. + sync.onPong(pingId, pingId + 750); + expect(sync.offsetMs, isNull); + sync.stop(); + }); + }); + + test('keeps multiple pings in flight and matches each by id', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + async.elapse(const Duration(milliseconds: 1100)); + expect(pings.length, 3); + + // Answer them out of order. + final p0 = pings[0], p1 = pings[1], p2 = pings[2]; + sync.onPong(p2, p2 + 50 + 1000); // RTT = now - sentAt(t=1000ms) = 100ms + sync.onPong(p0, p0 + 550 + 2000); // RTT 1100ms → discarded + sync.onPong(p1, p1 + 300 + 3000); // RTT 600ms → accepted + + expect(sync.minRttMs, 100); + expect(sync.offsetMs, 1000); + sync.stop(); + }); + }); + + test('window evicts the oldest samples', () { + fakeAsync((async) { + final (sync, pings) = build(async); + sync.start(); + + // First sample: the all-time best RTT (10ms), but offset 7777. + final first = pings[0]; + async.elapse(const Duration(milliseconds: 10)); + sync.onPong(first, first + 5 + 7777); + expect(sync.offsetMs, 7777); + + // Push 8 more samples (the window size) with worse RTTs, offset 100. + for (var i = 0; i < 8; i++) { + async.elapse(const Duration(seconds: 5)); + final pingId = pings.last; // Sent at t == pingId (id is timestamp). + async.elapse(const Duration(milliseconds: 60)); + final now = 1000000 + async.elapsed.inMilliseconds; + final rtt = now - pingId; + sync.onPong(pingId, pingId + rtt ~/ 2 + 100); + } + + // The 10ms/7777 sample has been evicted; best of the window wins. + expect(sync.offsetMs, 100); + sync.stop(); + }); + }); +} diff --git a/test/watch_together/guest_playback_reconciler_test.dart b/test/watch_together/guest_playback_reconciler_test.dart new file mode 100644 index 00000000..8b3ebb9e --- /dev/null +++ b/test/watch_together/guest_playback_reconciler_test.dart @@ -0,0 +1,554 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/models/playback_state.dart'; +import 'package:plezy/watch_together/models/sync_message.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; +import 'package:plezy/watch_together/services/attached_player.dart'; +import 'package:plezy/watch_together/services/clock_sync.dart'; +import 'package:plezy/watch_together/services/guest_playback_reconciler.dart'; + +import '../test_helpers/watch_together_fakes.dart'; + +const _epochMs = 1000000; + +class _Harness { + _Harness(this.async, {GuestReconcilerCallbacks callbacks = const GuestReconcilerCallbacks()}) { + player = FakeSyncPlayer(position: const Duration(minutes: 2)); + clock = ClockSync(sendPing: pings.add, nowMs: nowMs); + reconciler = GuestPlaybackReconciler( + myPeerId: 'guest', + sendToHost: outgoing.add, + clockSync: clock, + callbacks: callbacks, + nowMs: nowMs, + ); + attached = AttachedPlayer(player: player, onLost: () {}, nowMs: nowMs); + } + + final FakeAsync async; + late final FakeSyncPlayer player; + late final ClockSync clock; + late final GuestPlaybackReconciler reconciler; + late final AttachedPlayer attached; + final List outgoing = []; + final List pings = []; + int _seq = 0; + + int nowMs() => _epochMs + async.elapsed.inMilliseconds; + + void attachReady() { + reconciler.attach(attached, ratingKey: 'rk1', serverId: 'srv', hasFirstFrame: true); + async.flushMicrotasks(); + } + + PlaybackState state({ + PlaybackPhase phase = PlaybackPhase.playing, + int? anchorPositionMs, + int? anchorHostTimeMs, + double rate = 1.0, + ControlMode controlMode = ControlMode.hostOnly, + List waitingOn = const [], + String ratingKey = 'rk1', + String? actorPeerId, + PlaybackActionHint? actionHint, + int? seq, + }) { + return PlaybackState( + seq: seq ?? ++_seq, + ratingKey: ratingKey, + serverId: 'srv', + phase: phase, + anchorPositionMs: anchorPositionMs ?? player.state.position.inMilliseconds, + anchorHostTimeMs: anchorHostTimeMs ?? nowMs(), + rate: rate, + controlMode: controlMode, + waitingOn: waitingOn, + actorPeerId: actorPeerId, + actionHint: actionHint, + ); + } + + /// Delivers a state and runs one extra tick so the drift median has two + /// samples (a single sample never triggers a correction). + void deliverAndSettleDrift(PlaybackState s) { + reconciler.onState(s); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 500)); + } + + Iterable get seekCommands => player.commandLog.where((c) => c.startsWith('seek:')); + Iterable get statuses => outgoing.where((m) => m.type == SyncMessageType.status).map((m) => m.status!); + Iterable get controls => + outgoing.where((m) => m.type == SyncMessageType.control).map((m) => m.control!); + + void dispose() { + reconciler.dispose(); + attached.dispose(); + } +} + +void main() { + test('stale sequence numbers are dropped', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + h.reconciler.onState(h.state(phase: PlaybackPhase.paused, seq: 10)); + async.flushMicrotasks(); + expect(h.player.state.playing, isFalse); + + // An older state saying "playing" must not apply. + h.reconciler.onState(h.state(phase: PlaybackPhase.playing, seq: 9)); + async.flushMicrotasks(); + expect(h.player.state.playing, isFalse); + expect(h.reconciler.latestState!.seq, 10); + h.dispose(); + }); + }); + + group('drift pipeline', () { + test('within the deadband nothing happens', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + // Anchor implies we should be 200ms ahead of where we are — inside + // the deadband. + final pos = h.player.state.position.inMilliseconds; + h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 200)); + + expect(h.seekCommands, isEmpty); + expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isEmpty); + h.dispose(); + }); + }); + + test('moderate drift nudges the rate and restores it on convergence', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + // We are 1s behind the room → speed up by 4%. + final pos = h.player.state.position.inMilliseconds; + final s = h.state(anchorPositionMs: pos + 1000); + h.deliverAndSettleDrift(s); + expect(h.player.state.rate, closeTo(1.04, 0.0001)); + expect(h.seekCommands, isEmpty); + + // Converged: hold the player ~50ms off target across several ticks + // (median smoothing needs the old samples to wash out) → restored. + for (var i = 0; i < 3; i++) { + h.player.setPosition(Duration(milliseconds: s.targetPositionMs(h.nowMs() + 500) + 50)); + async.elapse(const Duration(milliseconds: 500)); + } + expect(h.player.state.rate, closeTo(1.0, 0.0001)); + h.dispose(); + }); + }); + + test('audio passthrough suppresses nudging (tolerated up to the seek band)', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + h.player.audioPassthroughActive = true; + async.flushMicrotasks(); + + final pos = h.player.state.position.inMilliseconds; + h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 1000)); + + expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isEmpty); + expect(h.seekCommands, isEmpty); + h.dispose(); + }); + }); + + test('rate nudges that do not take effect disable nudging for the session', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + h.player.ignoreRateChanges = true; + async.flushMicrotasks(); + + final pos = h.player.state.position.inMilliseconds; + h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 1000)); + expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isNotEmpty); + + async.elapse(const Duration(milliseconds: 600)); // Confirm window. + final rateCommandsAfterLatch = h.player.commandLog.where((c) => c.startsWith('rate:')).length; + + // Further drift no longer attempts nudges. + h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 1500)); + async.elapse(const Duration(seconds: 2)); + expect(h.player.commandLog.where((c) => c.startsWith('rate:')).length, rateCommandsAfterLatch); + h.dispose(); + }); + }); + + test('large drift hard-seeks with lead, settle window, and cooldown', () { + fakeAsync((async) { + final correcting = []; + final h = _Harness(async, callbacks: GuestReconcilerCallbacks(onCorrectingChanged: correcting.add)); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + final pos = h.player.state.position.inMilliseconds; + final target = pos + 10000; + h.deliverAndSettleDrift(h.state(anchorPositionMs: target)); + + // Seeked to (extrapolated) target + 250ms lead. + expect(h.seekCommands, hasLength(1)); + final seekTarget = int.parse(h.seekCommands.single.substring('seek:'.length)); + expect(seekTarget, greaterThanOrEqualTo(target + 250)); + expect(seekTarget, lessThan(target + 250 + 1500)); + expect(correcting, [true]); + + // Settle: playback-restart fired on seek; +250ms ends the window. + async.elapse(const Duration(milliseconds: 300)); + expect(correcting, [true, false]); + + // Within the cooldown a fresh large drift does not seek again. + h.player.setPosition(Duration(milliseconds: seekTarget - 8000)); + async.elapse(const Duration(milliseconds: 1000)); + expect(h.seekCommands, hasLength(1)); + + // After the cooldown it does. + async.elapse(const Duration(milliseconds: 1500)); + expect(h.seekCommands.length, greaterThan(1)); + h.dispose(); + }); + }); + + test('settle falls back to the timeout when no playback-restart arrives', () { + fakeAsync((async) { + final correcting = []; + final h = _Harness(async, callbacks: GuestReconcilerCallbacks(onCorrectingChanged: correcting.add)); + h.player.emitRestartOnSeek = false; + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 10000)); + expect(correcting, [true]); + + async.elapse(const Duration(milliseconds: 1600)); + expect(correcting, [true, false]); + h.dispose(); + }); + }); + }); + + group('phases', () { + test('paused phase aligns to the anchor and pauses the player', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + h.reconciler.onState(h.state(phase: PlaybackPhase.paused, anchorPositionMs: 600000)); + async.flushMicrotasks(); + + expect(h.player.state.playing, isFalse); + expect(h.seekCommands, hasLength(1)); + expect(h.player.state.position, const Duration(minutes: 10)); + h.dispose(); + }); + }); + + test('host loading phase holds paused without chasing the meaningless anchor', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + + h.reconciler.onState(h.state(phase: PlaybackPhase.loading, anchorPositionMs: 0)); + async.elapse(const Duration(seconds: 3)); + + expect(h.player.state.playing, isFalse); + expect(h.seekCommands, isEmpty); // Never seeks to the host's stale 0. + h.dispose(); + }); + }); + + test('hostOnly: a local pause snaps back to the room state', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + + h.reconciler.onState(h.state(phase: PlaybackPhase.playing)); + async.flushMicrotasks(); + expect(h.player.state.playing, isTrue); + + h.player.emitPlaying(false); // User pause. + async.flushMicrotasks(); + expect(h.player.state.playing, isTrue); // Snapped back. + expect(h.controls, isEmpty); // No request in hostOnly. + h.dispose(); + }); + }); + + test('scheduled group start fires at the host moment, clock-adjusted', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + + // Establish a clock offset of +5000ms (host ahead) via one exchange. + h.clock.start(); + final ping = h.pings.single; + async.elapse(const Duration(milliseconds: 100)); + h.clock.onPong(ping, ping + 50 + 5000); + expect(h.clock.offsetMs, 5000); + h.clock.stop(); + + // Host schedules the start 1s into ITS future. + final startAtHost = h.clock.hostNowMs() + 1000; + final anchor = h.player.state.position.inMilliseconds; + h.reconciler.onState(h.state(anchorHostTimeMs: startAtHost, anchorPositionMs: anchor)); + async.flushMicrotasks(); + expect(h.player.state.playing, isFalse); // Holding. + + async.elapse(const Duration(milliseconds: 950)); + expect(h.player.state.playing, isFalse); + async.elapse(const Duration(milliseconds: 100)); + expect(h.player.state.playing, isTrue); // Fired on the dot. + h.dispose(); + }); + }); + + test('a newer pause cancels a pending scheduled start', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + + h.reconciler.onState(h.state(anchorHostTimeMs: h.nowMs() + 1000)); + async.flushMicrotasks(); + h.reconciler.onState(h.state(phase: PlaybackPhase.paused)); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 2)); + expect(h.player.state.playing, isFalse); // Start never fired. + h.dispose(); + }); + }); + }); + + group('media and status', () { + test('epoch mismatch hands off to the media-switch flow and stops correcting', () { + fakeAsync((async) { + final switches = <(String, String, String?)>[]; + final h = _Harness( + async, + callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add((rk, sid, title))), + ); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + final commandsBefore = h.player.commandLog.length; + + h.reconciler.onState(h.state(ratingKey: 'rk2', phase: PlaybackPhase.loading)); + async.elapse(const Duration(seconds: 2)); + + expect(switches, [('rk2', 'srv', null)]); + expect(h.player.commandLog.length, commandsBefore); // No commands for foreign media. + h.dispose(); + }); + }); + + test('attach reconciles to the latest state received while detached', () { + fakeAsync((async) { + final h = _Harness(async); + + // State arrives during an episode-switch gap (no player attached). + h.reconciler.onState(h.state(phase: PlaybackPhase.paused, anchorPositionMs: 300000)); + async.flushMicrotasks(); + + h.attachReady(); + expect(h.player.state.position, const Duration(minutes: 5)); + h.dispose(); + }); + }); + + test('readiness is announced on first frame and revoked on detach', () { + fakeAsync((async) { + final h = _Harness(async); + h.reconciler.attach(h.attached, ratingKey: 'rk1', serverId: 'srv'); + async.flushMicrotasks(); + + expect(h.statuses.last.ready, isFalse); + + h.player.emitPlaybackRestart(); + async.flushMicrotasks(); + expect(h.statuses.last.ready, isTrue); + + h.reconciler.detachPlayer(); + expect(h.statuses.last.ready, isFalse); + h.dispose(); + }); + }); + + test('self-heals when the host wrongly lists us in waitingOn', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + final readyStatuses = h.statuses.where((s) => s.ready).length; + + h.reconciler.onState(h.state(phase: PlaybackPhase.waitingForPeers, waitingOn: ['guest'])); + async.flushMicrotasks(); + + expect(h.statuses.where((s) => s.ready).length, readyStatuses + 1); + h.dispose(); + }); + }); + + test('buffering changes refresh the status while stalled', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.reconciler.onState(h.state()); + async.flushMicrotasks(); + + h.player.emitBuffering(true); + async.flushMicrotasks(); + expect(h.statuses.last.buffering, isTrue); + + async.elapse(const Duration(seconds: 6)); + expect(h.statuses.where((s) => s.buffering).length, greaterThan(1)); // 5s refresh. + + h.player.emitBuffering(false); + async.flushMicrotasks(); + expect(h.statuses.last.buffering, isFalse); + h.dispose(); + }); + }); + }); + + group('anyone-mode control', () { + test('guest seek sends a request and in-flight heartbeats do not undo it', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.reconciler.onState(h.state(controlMode: ControlMode.anyone)); + async.flushMicrotasks(); + + // User seeks locally; screen already moved the player. + h.player.setPosition(const Duration(minutes: 20)); + h.reconciler.onLocalSeekIntent(const Duration(minutes: 20)); + async.flushMicrotasks(); + expect(h.controls.single.kind, ControlRequestKind.seek); + expect(h.controls.single.positionMs, const Duration(minutes: 20).inMilliseconds); + + // A heartbeat that left the host before our request arrives with the + // old anchor — inside the optimistic window it must not yank us back. + h.reconciler.onState(h.state(controlMode: ControlMode.anyone, anchorPositionMs: 120000)); + async.elapse(const Duration(milliseconds: 600)); + expect(h.seekCommands, isEmpty); + + // The host's confirming transition (actor = us) closes the window. + h.reconciler.onState( + h.state( + controlMode: ControlMode.anyone, + anchorPositionMs: const Duration(minutes: 20).inMilliseconds, + actorPeerId: 'guest', + actionHint: PlaybackActionHint.seek, + ), + ); + async.elapse(const Duration(milliseconds: 600)); + expect(h.seekCommands, isEmpty); // Already in place — converged. + h.dispose(); + }); + }); + + test('guest play/pause intents become control requests', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.reconciler.onState(h.state(controlMode: ControlMode.anyone)); + async.flushMicrotasks(); + expect(h.player.state.playing, isTrue); + + h.player.emitPlaying(false); // User pause. + async.flushMicrotasks(); + expect(h.controls.last.kind, ControlRequestKind.pause); + // Optimistic: not snapped back immediately. + expect(h.player.state.playing, isFalse); + h.dispose(); + }); + }); + }); + + group('edge cases', () { + test('EOF clamp: both at the credits → no fighting', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + final durationMs = h.player.state.duration.inMilliseconds; + h.player.setPosition(Duration(milliseconds: durationMs)); + h.player.setCompleted(true); + + h.deliverAndSettleDrift(h.state(anchorPositionMs: durationMs - 400)); + expect(h.seekCommands, isEmpty); + h.dispose(); + }); + }); + + test('guest at EOF while the room plays on rejoins via seek + play', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + final durationMs = h.player.state.duration.inMilliseconds; + h.player.setPosition(Duration(milliseconds: durationMs)); + h.player.setCompleted(true); + + h.reconciler.onState(h.state(anchorPositionMs: durationMs - 600000)); + async.flushMicrotasks(); + expect(h.seekCommands, hasLength(1)); + h.dispose(); + }); + }); + + test('live (!seekable) limits corrections to play/pause/rate', () { + fakeAsync((async) { + final h = _Harness(async); + final livePlayer = FakeSyncPlayer(seekable: false, position: const Duration(minutes: 2)); + final attached = AttachedPlayer(player: livePlayer, onLost: () {}, nowMs: h.nowMs); + h.reconciler.attach(attached, ratingKey: 'rk1', serverId: 'srv', hasFirstFrame: true); + async.flushMicrotasks(); + + h.deliverAndSettleDrift(h.state(anchorPositionMs: livePlayer.state.position.inMilliseconds + 60000)); + expect(livePlayer.state.playing, isTrue); // Play enforced. + expect(livePlayer.commandLog.where((c) => c.startsWith('seek:')), isEmpty); // Never seeks live. + h.reconciler.dispose(); + attached.dispose(); + h.attached.dispose(); + }); + }); + + test('backgrounded guests freewheel without corrections', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachReady(); + h.player.emitPlaying(true); + async.flushMicrotasks(); + h.reconciler.setBackgrounded(true); + + h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 30000)); + expect(h.seekCommands, isEmpty); + + h.reconciler.setBackgrounded(false); + async.elapse(const Duration(milliseconds: 600)); + expect(h.seekCommands, isNotEmpty); // Catches up once foregrounded. + h.dispose(); + }); + }); + }); +} diff --git a/test/watch_together/host_playback_coordinator_test.dart b/test/watch_together/host_playback_coordinator_test.dart new file mode 100644 index 00000000..e592fe50 --- /dev/null +++ b/test/watch_together/host_playback_coordinator_test.dart @@ -0,0 +1,590 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/models/playback_state.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; +import 'package:plezy/watch_together/services/attached_player.dart'; +import 'package:plezy/watch_together/services/host_playback_coordinator.dart'; + +import '../test_helpers/watch_together_fakes.dart'; + +const _epochMs = 1000000; + +class _Harness { + _Harness( + FakeAsync async, { + ControlMode controlMode = ControlMode.hostOnly, + HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(), + }) { + int nowMs() => _epochMs + async.elapsed.inMilliseconds; + player = FakeSyncPlayer(position: const Duration(minutes: 2)); + coordinator = HostPlaybackCoordinator( + myPeerId: 'host', + controlMode: controlMode, + sendState: (state, {toPeerId}) => sent.add((state, toPeerId)), + callbacks: callbacks, + nowMs: nowMs, + ); + attached = AttachedPlayer(player: player, onLost: () {}, nowMs: nowMs); + } + + late final FakeSyncPlayer player; + late final HostPlaybackCoordinator coordinator; + late final AttachedPlayer attached; + final List<(PlaybackState, String?)> sent = []; + + /// Broadcast states only (no targeted sends). + List get broadcasts => [ + for (final (state, to) in sent) + if (to == null) state, + ]; + + PlaybackState get last => broadcasts.last; + + void attachForMedia(FakeAsync async, {bool hasFirstFrame = false}) { + coordinator.attach(attached, ratingKey: 'rk1', serverId: 'srv', mediaTitle: 'Ep 1', hasFirstFrame: hasFirstFrame); + async.flushMicrotasks(); + } + + void hostBecomesReady(FakeAsync async) { + player.emitPlaybackRestart(); + async.flushMicrotasks(); + } + + void guestReports( + FakeAsync async, { + String peerId = 'guest', + bool ready = true, + bool buffering = false, + String mediaKey = 'srv:rk1', + int? rttMs, + }) { + coordinator.onPeerStatus( + peerId, + PeerStatus(mediaKey: mediaKey, ready: ready, buffering: buffering, positionMs: 0, rttMs: rttMs), + ); + async.flushMicrotasks(); + } + + void dispose() { + coordinator.dispose(); + attached.dispose(); + } +} + +void main() { + group('initial start coordination', () { + test('guest loads first: nothing but loading-phase states until the host is ready (the loop bug)', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + + // Guest is ready long before the host. + h.guestReports(async); + async.elapse(const Duration(seconds: 5)); + + // Every state so far must be loading — never "playing at a frozen + // position", which is what caused guests to loop. + expect(h.broadcasts, isNotEmpty); + expect(h.broadcasts.every((s) => s.phase == PlaybackPhase.loading), isTrue); + expect(h.player.commandLog.where((c) => c == 'play'), isEmpty); + + // Host becomes ready: waitingForPeers resolves instantly into a + // scheduled start because the guest is already ready. + h.hostBecomesReady(async); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorHostTimeMs, greaterThan(_epochMs + async.elapsed.inMilliseconds)); + + // The host's own player starts exactly at the scheduled moment. + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + expect(delay, greaterThanOrEqualTo(HostPlaybackCoordinator.startDelayMinMs)); + expect(h.player.state.playing, isFalse); + async.elapse(Duration(milliseconds: delay)); + expect(h.player.state.playing, isTrue); + + h.dispose(); + }); + }); + + test('host loads first: waits for the guest, then schedules the start', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + + expect(h.last.phase, PlaybackPhase.waitingForPeers); + expect(h.last.waitingOn, ['guest']); + expect(h.player.state.playing, isFalse); + + async.elapse(const Duration(seconds: 3)); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + h.guestReports(async, rttMs: 200); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds), 750); + + h.dispose(); + }); + }); + + test('start delay scales with the worst peer RTT, capped', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.coordinator.onPeerJoined('guest2', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + h.guestReports(async, rttMs: 100); + h.guestReports(async, peerId: 'guest2', rttMs: 900); + + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds), 1350); + + h.dispose(); + }); + }); + + test('host alone starts immediately with no artificial delay', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async); + h.hostBecomesReady(async); + + expect(h.last.phase, PlaybackPhase.playing); + async.flushMicrotasks(); + async.elapse(Duration.zero); + expect(h.player.state.playing, isTrue); + + h.dispose(); + }); + }); + + test('attaching to an already-rendering player counts as ready', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async, hasFirstFrame: true); + expect(h.broadcasts.map((s) => s.phase), contains(PlaybackPhase.playing)); + h.dispose(); + }); + }); + + test('readiness waits for the startup hold (frame-rate gate)', () { + fakeAsync((async) { + int nowMs() => _epochMs + async.elapsed.inMilliseconds; + final sent = []; + final player = FakeSyncPlayer(); + final coordinator = HostPlaybackCoordinator( + myPeerId: 'host', + controlMode: ControlMode.hostOnly, + sendState: (state, {toPeerId}) => sent.add(state), + nowMs: nowMs, + ); + final attached = AttachedPlayer(player: player, onLost: () {}, nowMs: nowMs); + final hold = Completer(); + + coordinator.attach(attached, ratingKey: 'rk1', serverId: 'srv', startupHold: hold.future); + async.flushMicrotasks(); + player.emitPlaybackRestart(); + async.flushMicrotasks(); + + expect(sent.every((s) => s.phase == PlaybackPhase.loading), isTrue); + + hold.complete(); + async.flushMicrotasks(); + expect(sent.last.phase, isNot(PlaybackPhase.loading)); + + coordinator.dispose(); + attached.dispose(); + }); + }); + }); + + group('stalls and group wait', () { + _Harness playingRoom(FakeAsync async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.guestReports(async); + h.hostBecomesReady(async); + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + async.elapse(Duration(milliseconds: delay)); + expect(h.player.state.playing, isTrue); + return h; + } + + test('host stall: brief blips are absorbed by the grace window', () { + fakeAsync((async) { + final h = playingRoom(async); + final statesBefore = h.broadcasts.length; + + h.player.emitBuffering(true); + async.elapse(const Duration(milliseconds: 300)); + h.player.emitBuffering(false); + async.elapse(const Duration(seconds: 1)); + + expect(h.broadcasts.skip(statesBefore).where((s) => s.phase == PlaybackPhase.waitingForPeers), isEmpty); + h.dispose(); + }); + }); + + test('host stall: sustained buffering pauses the room without pausing the host player', () { + fakeAsync((async) { + final h = playingRoom(async); + h.player.setPosition(const Duration(minutes: 5)); + + h.player.emitBuffering(true); + async.elapse(const Duration(milliseconds: 600)); + + expect(h.last.phase, PlaybackPhase.waitingForPeers); + expect(h.last.waitingOn, ['host']); + expect(h.last.anchorPositionMs, const Duration(minutes: 5).inMilliseconds); + // mpv recovers paused-for-cache on its own; pausing would fight it. + expect(h.player.commandLog.where((c) => c == 'pause'), isEmpty); + + // Recovery: hysteresis then a scheduled resume from the anchor. + h.player.emitBuffering(false); + async.elapse(const Duration(milliseconds: 500)); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorHostTimeMs, greaterThan(_epochMs + async.elapsed.inMilliseconds)); + h.dispose(); + }); + }); + + test('guest stall: room pauses, safety timeout excuses them, resume fires', () { + fakeAsync((async) { + final resumedWithout = >[]; + final h = _Harness(async, callbacks: HostCoordinatorCallbacks(onResumedWithout: resumedWithout.add)); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.guestReports(async); + h.hostBecomesReady(async); + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + async.elapse(Duration(milliseconds: delay)); + + h.guestReports(async, buffering: true); + async.elapse(const Duration(milliseconds: 600)); + + expect(h.last.phase, PlaybackPhase.waitingForPeers); + expect(h.last.waitingOn, ['guest']); + expect(h.player.state.playing, isFalse); // Host pauses for a peer stall. + + // Guest never recovers — safety excuses them and the room resumes + // immediately (no other gating peers left). + async.elapse(const Duration(seconds: 15)); + expect(resumedWithout, [ + ['guest'], + ]); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.player.state.playing, isTrue); + + // A healthy report un-excuses the guest: its next stall gates again. + h.guestReports(async); + h.guestReports(async, buffering: true); + async.elapse(const Duration(milliseconds: 600)); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + h.dispose(); + }); + }); + + test('guest recovery resumes the room with a fresh scheduled start', () { + fakeAsync((async) { + final h = playingRoom(async); + + h.guestReports(async, buffering: true); + async.elapse(const Duration(milliseconds: 600)); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + final anchorDuringWait = h.last.anchorPositionMs; + + h.guestReports(async, buffering: false); + async.elapse(const Duration(milliseconds: 450)); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorPositionMs, anchorDuringWait); + h.dispose(); + }); + }); + + test('late joiner never pauses a playing room', () { + fakeAsync((async) { + final h = playingRoom(async); + final statesBefore = h.broadcasts.length; + + h.coordinator.onPeerJoined('late', compatible: true); + async.flushMicrotasks(); + // Targeted state so the joiner can catch up. + expect(h.sent.where((entry) => entry.$2 == 'late'), isNotEmpty); + + // Their loading status does not gate the room. + h.guestReports(async, peerId: 'late', ready: false); + async.elapse(const Duration(seconds: 2)); + expect(h.broadcasts.skip(statesBefore).where((s) => s.phase == PlaybackPhase.waitingForPeers), isEmpty); + h.dispose(); + }); + }); + + test('a stalled peer leaving unblocks the room', () { + fakeAsync((async) { + final h = playingRoom(async); + h.guestReports(async, buffering: true); + async.elapse(const Duration(milliseconds: 600)); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + h.coordinator.onPeerLeft('guest'); + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + }); + + group('intents and control', () { + test('play presses while waiting are held back; the room starts at all-ready', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + // User mashes play while the room waits on the guest — held back. + h.player.emitPlaying(true); + async.flushMicrotasks(); + expect(h.player.state.playing, isFalse); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + h.guestReports(async); + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + + test('a pause control request during the wait lands the room paused at all-ready', () { + fakeAsync((async) { + final h = _Harness(async, controlMode: ControlMode.anyone); + h.coordinator.onPeerJoined('guest', compatible: true); + h.coordinator.onPeerJoined('guest2', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + h.guestReports(async, peerId: 'guest2'); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + h.coordinator.onControlRequest('guest2', const ControlRequest(kind: ControlRequestKind.pause)); + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.paused); + + // The remaining guest becoming ready must NOT auto-play. + h.guestReports(async); + expect(h.last.phase, PlaybackPhase.paused); + expect(h.player.state.playing, isFalse); + h.dispose(); + }); + }); + + test('user play with everyone ready schedules a synchronized resume', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + h.guestReports(async); + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + async.elapse(Duration(milliseconds: delay)); + + h.player.emitPlaying(false); // User pauses. + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.paused); + + h.player.emitPlaying(true); // User resumes. + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.playing); + expect(h.last.anchorHostTimeMs, greaterThan(_epochMs + async.elapsed.inMilliseconds)); + // Host was paused back until the scheduled moment. + expect(h.player.state.playing, isFalse); + async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds))); + expect(h.player.state.playing, isTrue); + h.dispose(); + }); + }); + + test('control requests apply to the host player with actor attribution', () { + fakeAsync((async) { + final actions = <(String, PlaybackActionHint)>[]; + final h = _Harness( + async, + controlMode: ControlMode.anyone, + callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))), + ); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.guestReports(async); + h.hostBecomesReady(async); + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + async.elapse(Duration(milliseconds: delay)); + + h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.pause)); + async.flushMicrotasks(); + expect(h.player.state.playing, isFalse); + expect(h.last.phase, PlaybackPhase.paused); + expect(h.last.actorPeerId, 'guest'); + expect(actions, contains(('guest', PlaybackActionHint.pause))); + + h.coordinator.onControlRequest( + 'guest', + const ControlRequest(kind: ControlRequestKind.seek, positionMs: 600000), + ); + async.flushMicrotasks(); + expect(h.player.state.position, const Duration(minutes: 10)); + expect(h.last.anchorPositionMs, 600000); + expect(h.last.actionHint, PlaybackActionHint.seek); + + h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.play)); + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + + test('local seeks debounce into a single re-anchor broadcast', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async); + h.hostBecomesReady(async); + async.elapse(const Duration(milliseconds: 100)); + final statesBefore = h.broadcasts.length; + + h.coordinator.onLocalSeekIntent(const Duration(minutes: 10)); + async.elapse(const Duration(milliseconds: 100)); + h.coordinator.onLocalSeekIntent(const Duration(minutes: 11)); + async.elapse(const Duration(milliseconds: 100)); + h.coordinator.onLocalSeekIntent(const Duration(minutes: 12)); + async.elapse(const Duration(milliseconds: 250)); + + final seekStates = h.broadcasts.skip(statesBefore).where((s) => s.actionHint == PlaybackActionHint.seek); + expect(seekStates, hasLength(1)); + expect(seekStates.single.anchorPositionMs, const Duration(minutes: 12).inMilliseconds); + h.dispose(); + }); + }); + }); + + group('heartbeats and epochs', () { + test('heartbeats are 2s while playing, 5s otherwise, suppressed in background', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async); + h.hostBecomesReady(async); + async.elapse(Duration.zero); + expect(h.player.state.playing, isTrue); + + final before = h.broadcasts.length; + async.elapse(const Duration(seconds: 6)); + expect(h.broadcasts.length - before, 3); // 2s cadence. + + h.coordinator.setBackgrounded(true); + final backgrounded = h.broadcasts.length; + async.elapse(const Duration(seconds: 10)); + expect(h.broadcasts.length, backgrounded); + + h.coordinator.setBackgrounded(false); // Immediate fresh heartbeat. + expect(h.broadcasts.length, backgrounded + 1); + h.dispose(); + }); + }); + + test('heartbeat detects implicit jumps and flags them as seeks', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async); + h.hostBecomesReady(async); + async.elapse(Duration.zero); + + // Simulate playback advancing normally between heartbeats… + async.elapse(const Duration(seconds: 2)); + // …then something seeks the player behind our back. + h.player.setPosition(const Duration(minutes: 30)); + async.elapse(const Duration(seconds: 2)); + + expect(h.broadcasts.last.actionHint, PlaybackActionHint.seek); + h.dispose(); + }); + }); + + test('sequence numbers strictly increase across every send', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.hostBecomesReady(async); + h.guestReports(async); + async.elapse(const Duration(seconds: 10)); + + final seqs = [for (final (state, _) in h.sent) state.seq]; + for (var i = 1; i < seqs.length; i++) { + expect(seqs[i], greaterThan(seqs[i - 1])); + } + h.dispose(); + }); + }); + + test('epoch switch resets gating and broadcasts loading with a mediaSwitch hint', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.guestReports(async); + h.hostBecomesReady(async); + final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds); + async.elapse(Duration(milliseconds: delay)); + expect(h.last.phase, PlaybackPhase.playing); + + h.coordinator.setLocalMedia(ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); + async.flushMicrotasks(); + expect(h.last.phase, PlaybackPhase.loading); + expect(h.last.actionHint, PlaybackActionHint.mediaSwitch); + expect(h.last.ratingKey, 'rk2'); + + // Old-epoch readiness no longer counts: after the host reloads and + // becomes ready for rk2, the guest (still on rk1) gates the start. + h.coordinator.detachPlayer(); + h.coordinator.attach(h.attached, ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); + async.flushMicrotasks(); + h.hostBecomesReady(async); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + expect(h.last.waitingOn, ['guest']); + + // The guest reports ready on the new epoch — start schedules. + h.guestReports(async, mediaKey: 'srv:rk2'); + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + + test('incompatible peers never gate and get no targeted state', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('legacy', compatible: false); + h.attachForMedia(async); + h.hostBecomesReady(async); + + expect(h.last.phase, PlaybackPhase.playing); // Did not wait for them. + expect(h.sent.where((entry) => entry.$2 == 'legacy'), isEmpty); + h.dispose(); + }); + }); + + test('requestState answers during loading so joiners can start loading media', () { + fakeAsync((async) { + final h = _Harness(async); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + + h.coordinator.onStateRequested('guest'); + final targeted = h.sent.where((entry) => entry.$2 == 'guest').map((entry) => entry.$1); + expect(targeted.where((s) => s.phase == PlaybackPhase.loading && s.ratingKey == 'rk1'), isNotEmpty); + h.dispose(); + }); + }); + }); +} diff --git a/test/watch_together/playback_state_test.dart b/test/watch_together/playback_state_test.dart new file mode 100644 index 00000000..b92539a4 --- /dev/null +++ b/test/watch_together/playback_state_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/models/playback_state.dart'; +import 'package:plezy/watch_together/models/sync_message.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; + +void main() { + const fullState = PlaybackState( + seq: 42, + ratingKey: '12345', + serverId: 'srv-1', + mediaTitle: 'Some Episode', + phase: PlaybackPhase.playing, + anchorPositionMs: 90000, + anchorHostTimeMs: 1718700000000, + rate: 1.5, + controlMode: ControlMode.anyone, + waitingOn: ['peer-a', 'peer-b'], + actorPeerId: 'peer-a', + actionHint: PlaybackActionHint.seek, + ); + + group('PlaybackState', () { + test('round-trips through map with all fields', () { + expect(PlaybackState.fromMap(fullState.toMap()), fullState); + }); + + test('round-trips with optionals omitted and omits empty keys', () { + const minimal = PlaybackState( + seq: 1, + ratingKey: 'rk', + serverId: 'sid', + phase: PlaybackPhase.loading, + anchorPositionMs: 0, + anchorHostTimeMs: 1000, + rate: 1.0, + controlMode: ControlMode.hostOnly, + ); + final map = minimal.toMap(); + expect(map.containsKey('ti'), isFalse); + expect(map.containsKey('w'), isFalse); + expect(map.containsKey('ab'), isFalse); + expect(map.containsKey('ah'), isFalse); + expect(PlaybackState.fromMap(map), minimal); + }); + + test('round-trips through the SyncMessage envelope', () { + final message = SyncMessage.state(fullState, peerId: 'host-1'); + final decoded = SyncMessage.fromJson(message.toJson()); + expect(decoded.type, SyncMessageType.state); + expect(decoded.state, fullState); + expect(decoded.peerId, 'host-1'); + }); + + test('unknown enum indexes decode to safe fallbacks instead of throwing', () { + final map = fullState.toMap() + ..['ph'] = 99 + ..['ah'] = 99 + ..['cm'] = 99; + final decoded = PlaybackState.fromMap(map); + expect(decoded.phase, PlaybackPhase.paused); + expect(decoded.actionHint, isNull); + expect(decoded.controlMode, ControlMode.hostOnly); + }); + + group('targetPositionMs', () { + test('extrapolates from the anchor while playing', () { + final target = fullState.targetPositionMs(fullState.anchorHostTimeMs + 2000); + expect(target, 90000 + (2000 * 1.5).round()); + }); + + test('clamps to the anchor before a scheduled start', () { + expect(fullState.targetPositionMs(fullState.anchorHostTimeMs - 5000), 90000); + }); + + test('returns the anchor for non-playing phases', () { + final paused = fullState.copyWith(phase: PlaybackPhase.paused); + expect(paused.targetPositionMs(fullState.anchorHostTimeMs + 60000), 90000); + }); + }); + + test('mediaKey matches mediaKeyFor', () { + expect(fullState.mediaKey, PlaybackState.mediaKeyFor(ratingKey: '12345', serverId: 'srv-1')); + }); + }); + + group('PeerStatus', () { + test('round-trips through map and envelope', () { + const status = PeerStatus(mediaKey: 'srv-1:12345', ready: true, buffering: false, positionMs: 1234, rttMs: 80); + expect(PeerStatus.fromMap(status.toMap()), status); + + final decoded = SyncMessage.fromJson(SyncMessage.status(status, peerId: 'guest-1').toJson()); + expect(decoded.type, SyncMessageType.status); + expect(decoded.status, status); + }); + + test('omits rtt when unknown', () { + const status = PeerStatus(mediaKey: 'k', ready: false, buffering: true, positionMs: 0); + expect(status.toMap().containsKey('rtt'), isFalse); + expect(PeerStatus.fromMap(status.toMap()), status); + }); + }); + + group('ControlRequest', () { + test('round-trips all kinds', () { + const requests = [ + ControlRequest(kind: ControlRequestKind.play, positionMs: 5000), + ControlRequest(kind: ControlRequestKind.pause), + ControlRequest(kind: ControlRequestKind.seek, positionMs: 60000), + ControlRequest(kind: ControlRequestKind.rate, rate: 1.25), + ]; + for (final request in requests) { + expect(ControlRequest.fromMap(request.toMap()), request); + final decoded = SyncMessage.fromJson(SyncMessage.control(request, peerId: 'g').toJson()); + expect(decoded.control, request); + } + }); + }); + + group('SyncMessage v2 envelope', () { + test('join carries the protocol version', () { + final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false); + final decoded = SyncMessage.fromJson(join.toJson()); + expect(decoded.version, SyncMessage.protocolVersion); + }); + + test('requestState round-trips', () { + final decoded = SyncMessage.fromJson(SyncMessage.requestState(peerId: 'p').toJson()); + expect(decoded.type, SyncMessageType.requestState); + expect(decoded.peerId, 'p'); + }); + + test('copyWith preserves v2 payloads', () { + final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id'); + expect(relabeled.state, fullState); + expect(relabeled.peerId, 'relay-id'); + }); + }); +} diff --git a/test/watch_together/watch_together_controller_test.dart b/test/watch_together/watch_together_controller_test.dart new file mode 100644 index 00000000..25e4de10 --- /dev/null +++ b/test/watch_together/watch_together_controller_test.dart @@ -0,0 +1,276 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/watch_together/models/playback_state.dart'; +import 'package:plezy/watch_together/models/sync_message.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; +import 'package:plezy/watch_together/services/watch_together_controller.dart'; + +import '../test_helpers/watch_together_fakes.dart'; + +const _epochMs = 1000000; + +/// Two live controllers (host + guest) bridged by an in-memory relay. +class _Room { + _Room(this.async, {ControlMode controlMode = ControlMode.hostOnly}) { + hostService = hub.register('host'); + guestService = hub.register('guest'); + + host = WatchTogetherController( + peerService: hostService, + session: WatchSession( + sessionId: 'ROOM1', + role: SessionRole.host, + controlMode: controlMode, + state: SessionState.connected, + hostPeerId: 'host', + ), + nowMs: nowMs, + ); + guest = WatchTogetherController( + peerService: guestService, + session: WatchSession( + sessionId: 'ROOM1', + role: SessionRole.guest, + controlMode: controlMode, + state: SessionState.connected, + hostPeerId: 'host', + ), + nowMs: nowMs, + ); + + hostPlayer = FakeSyncPlayer(position: const Duration(minutes: 2)); + guestPlayer = FakeSyncPlayer(position: Duration.zero); + + guest.announceJoin('Guest'); + host.announceJoin('Host'); + async.flushMicrotasks(); + } + + final FakeAsync async; + final hub = FakeRelayHub(); + late final HubPeerService hostService; + late final HubPeerService guestService; + late final WatchTogetherController host; + late final WatchTogetherController guest; + late final FakeSyncPlayer hostPlayer; + late final FakeSyncPlayer guestPlayer; + + int nowMs() => _epochMs + async.elapsed.inMilliseconds; + + PlaybackState lastHostState() => hostService.outgoingLog.lastWhere((m) => m.type == SyncMessageType.state).state!; + + void hostStartsMedia({String ratingKey = 'rk1', bool hasFirstFrame = false}) { + host.attachPlayer( + hostPlayer, + ratingKey: ratingKey, + serverId: 'srv', + mediaTitle: 'Ep', + hasFirstFrame: hasFirstFrame, + ); + host.setCurrentMedia(ratingKey: ratingKey, serverId: 'srv', mediaTitle: 'Ep'); + async.flushMicrotasks(); + } + + void guestJoinsMedia({String ratingKey = 'rk1'}) { + guest.attachPlayer(guestPlayer, ratingKey: ratingKey, serverId: 'srv'); + async.flushMicrotasks(); + } + + void bothBecomeReady() { + hostPlayer.emitPlaybackRestart(); + guestPlayer.emitPlaybackRestart(); + async.flushMicrotasks(); + } + + void dispose() { + host.dispose(); + guest.dispose(); + hub.dispose(); + } +} + +void main() { + test('full flow: join, media dispatch, load, one simultaneous start — no loops', () { + fakeAsync((async) { + final mediaDispatches = []; + final room = _Room(async); + room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk); + + // Host opens media; guest hears about it from the loading state even + // though the host hasn't finished loading (joiners load in parallel). + room.hostStartsMedia(); + expect(mediaDispatches, ['rk1']); + + // Guest loads FIRST (the original bug scenario). + room.guestJoinsMedia(); + room.guestPlayer.emitPlaybackRestart(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 4)); + + // While the host loads, the guest must never have been told to play. + expect(room.guestPlayer.state.playing, isFalse); + expect(room.guestPlayer.commandLog.where((c) => c == 'play'), isEmpty); + + // Host finishes loading → scheduled start lands on both simultaneously. + room.hostPlayer.emitPlaybackRestart(); + async.flushMicrotasks(); + final state = room.lastHostState(); + expect(state.phase, PlaybackPhase.playing); + final delay = state.anchorHostTimeMs - room.nowMs(); + expect(delay, greaterThan(0)); + + async.elapse(Duration(milliseconds: delay - 50)); + expect(room.hostPlayer.state.playing, isFalse); + expect(room.guestPlayer.state.playing, isFalse); + async.elapse(const Duration(milliseconds: 100)); + expect(room.hostPlayer.state.playing, isTrue); + expect(room.guestPlayer.state.playing, isTrue); + + // And the guest was aligned to the host's anchor position. + expect((room.guestPlayer.state.position.inMilliseconds - state.anchorPositionMs).abs(), lessThanOrEqualTo(500)); + room.dispose(); + }); + }); + + test('episode switch: state arriving during the guest detach gap is not lost', () { + fakeAsync((async) { + final mediaDispatches = []; + final room = _Room(async); + room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk); + + room.hostStartsMedia(); + room.guestJoinsMedia(); + room.bothBecomeReady(); + final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); + async.elapse(Duration(milliseconds: delay + 100)); + expect(room.guestPlayer.state.playing, isTrue); + + // Guest detaches (reload gap) — and ONLY THEN the host switches media. + room.guest.detachPlayer(); + async.flushMicrotasks(); + room.host.setCurrentMedia(ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); + room.host.detachPlayer(); + room.host.attachPlayer(room.hostPlayer, ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); + async.flushMicrotasks(); + + // The guest controller was detached but session-scoped routing caught + // the new epoch. + expect(mediaDispatches, contains('rk2')); + + // Guest re-attaches for the new episode; both load; room starts again. + room.guest.attachPlayer(room.guestPlayer, ratingKey: 'rk2', serverId: 'srv'); + async.flushMicrotasks(); + room.bothBecomeReady(); + final resume = room.lastHostState(); + expect(resume.phase, PlaybackPhase.playing); + expect(resume.ratingKey, 'rk2'); + room.dispose(); + }); + }); + + test('hostOnly: forged control requests are dropped at the controller', () { + fakeAsync((async) { + final room = _Room(async); + room.hostStartsMedia(); + room.guestJoinsMedia(); + room.bothBecomeReady(); + final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); + async.elapse(Duration(milliseconds: delay + 100)); + expect(room.hostPlayer.state.playing, isTrue); + + room.guestService.sendTo( + 'host', + SyncMessage.control(const ControlRequest(kind: ControlRequestKind.pause), peerId: 'guest'), + ); + async.elapse(const Duration(seconds: 1)); + + expect(room.hostPlayer.state.playing, isTrue); // Ignored. + room.dispose(); + }); + }); + + test('anyone-mode: guest control requests round-trip through the host', () { + fakeAsync((async) { + final room = _Room(async, controlMode: ControlMode.anyone); + room.hostStartsMedia(); + room.guestJoinsMedia(); + room.bothBecomeReady(); + final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); + async.elapse(Duration(milliseconds: delay + 100)); + + // Guest presses pause → request → host applies → state pauses guest too. + room.guestPlayer.emitPlaying(false); + async.flushMicrotasks(); + expect(room.hostPlayer.state.playing, isFalse); + final paused = room.lastHostState(); + expect(paused.phase, PlaybackPhase.paused); + expect(paused.actorPeerId, 'guest'); + room.dispose(); + }); + }); + + test('clock sync runs over the relay and converges', () { + fakeAsync((async) { + final room = _Room(async); + // The guest's clock-sync burst pings the host; pongs come back with the + // shared fake clock → offset 0. + async.elapse(const Duration(seconds: 2)); + final pongs = room.guestService.outgoingLog.where((m) => m.type == SyncMessageType.ping); + expect(pongs, isNotEmpty); + room.dispose(); + }); + }); + + test('v1 peers are flagged and never gate the start', () { + fakeAsync((async) { + final needsUpdate = []; + final room = _Room(async); + room.host.onPeerNeedsUpdate = needsUpdate.add; + + // A legacy client joins on its own connection: its join message has no + // version field (the relay stamps the sender id, so it must really + // connect as itself — peerId spoofing is rewritten). + final legacyService = room.hub.register('legacy'); + legacyService.sendTo( + 'host', + SyncMessage( + type: SyncMessageType.join, + timestamp: room.nowMs(), + peerId: 'legacy', + displayName: 'Old App', + isHost: false, + ), + ); + async.flushMicrotasks(); + expect(needsUpdate, ['legacy']); + + room.hostStartsMedia(); + room.guestJoinsMedia(); + room.bothBecomeReady(); + // The legacy peer never reports status, yet the room starts. + expect(room.lastHostState().phase, PlaybackPhase.playing); + room.dispose(); + }); + }); + + test('guest reconnect re-requests state and the host answers directly', () { + fakeAsync((async) { + final room = _Room(async); + room.hostStartsMedia(); + room.guestJoinsMedia(); + room.bothBecomeReady(); + final statesBefore = room.guestService.outgoingLog.length; + + room.guest.onReconnected(); + async.flushMicrotasks(); + + // Status + requestState went out; host replied with a targeted state. + final outgoing = room.guestService.outgoingLog.skip(statesBefore); + expect(outgoing.where((m) => m.type == SyncMessageType.status), isNotEmpty); + expect(outgoing.where((m) => m.type == SyncMessageType.requestState), isNotEmpty); + final targeted = room.hostService.outgoingLog.where((m) => m.type == SyncMessageType.state); + expect(targeted, isNotEmpty); + room.dispose(); + }); + }); +} diff --git a/test/watch_together/watch_together_provider_test.dart b/test/watch_together/watch_together_provider_test.dart index ddbae5c5..01a6e989 100644 --- a/test/watch_together/watch_together_provider_test.dart +++ b/test/watch_together/watch_together_provider_test.dart @@ -23,13 +23,14 @@ void main() { expect(p.isHost, isFalse); expect(p.isConnected, isFalse); expect(p.isSyncing, isFalse); - expect(p.isDeferredPlay, isFalse); + expect(p.isWaitingForPeers, isFalse); + expect(p.waitingOnNames, isEmpty); expect(p.isWaitingForHostReconnect, isFalse); expect(p.participants, isEmpty); expect(p.participantCount, 0); // Default control mode falls back to hostOnly when there's no session. expect(p.controlMode, ControlMode.hostOnly); - expect(p.syncManager, isNull); + expect(p.hasAttachedPlayer, isFalse); p.dispose(); }); @@ -108,24 +109,24 @@ void main() { p.dispose(); }); - test('attachPlayer is a no-op without a sync manager (logs warning)', () { + test('attachPlayer is a no-op without a sync controller (logs warning)', () { final p = WatchTogetherProvider(); // The mpv Player object is platform-tied; skipping it would reach the - // null-syncManager guard first and bail. Calling with a null check via + // null-controller guard first and bail. Calling with a null check via // the same path used by the production code: just verify the early // return path on detachPlayer (which is also null-safe). expect(p.detachPlayer, returnsNormally); p.dispose(); }); - test('setBackgrounded forwards to sync manager but is null-safe', () { + test('setBackgrounded forwards to the sync controller but is null-safe', () { final p = WatchTogetherProvider(); expect(() => p.setBackgrounded(true), returnsNormally); expect(() => p.setBackgrounded(false), returnsNormally); p.dispose(); }); - test('onLocalSeek is null-safe without a sync manager', () { + test('onLocalSeek is null-safe without a sync controller', () { final p = WatchTogetherProvider(); expect(() => p.onLocalSeek(const Duration(seconds: 5)), returnsNormally); p.dispose(); diff --git a/test/watch_together/watch_together_sync_manager_test.dart b/test/watch_together/watch_together_sync_manager_test.dart deleted file mode 100644 index 324f652b..00000000 --- a/test/watch_together/watch_together_sync_manager_test.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/mpv/mpv.dart'; -import 'package:plezy/watch_together/models/sync_message.dart'; -import 'package:plezy/watch_together/models/watch_session.dart'; -import 'package:plezy/watch_together/services/watch_together_peer_service.dart'; -import 'package:plezy/watch_together/services/watch_together_sync_manager.dart'; - -void main() { - group('WatchTogetherSyncManager deferred play', () { - test('does not re-enter initial load gate after attaching an already-playing player', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'host'); - final player = _FakePlayer(playing: true, position: const Duration(minutes: 3)); - final manager = _hostManager(peerService); - final deferredStates = []; - manager.onDeferredPlayChanged = deferredStates.add; - - manager.initializeParticipants(['host', 'guest']); - manager.attachPlayer(player); - - await player.emitPlaying(false); - await player.emitPlaying(true); - - expect(deferredStates, isNot(contains(true))); - expect(player.state.playing, isTrue); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - - test('remote play completion prevents a later local resume from using the initial load gate', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'guest'); - final player = _FakePlayer(playing: false, position: const Duration(seconds: 10)); - final manager = _guestManager(peerService, controlMode: ControlMode.anyone); - final deferredStates = []; - manager.onDeferredPlayChanged = deferredStates.add; - - manager.initializeParticipants(['guest', 'host', 'other']); - manager.attachPlayer(player); - - peerService.emit(SyncMessage.playerReady(peerId: 'other', ready: false)); - await _settle(); - - peerService.emit(SyncMessage.play(peerId: 'host', position: const Duration(seconds: 20))); - await _settle(); - expect(player.state.playing, isTrue); - - await player.emitPlaying(false); - await player.emitPlaying(true); - - expect(deferredStates, isNot(contains(true))); - expect(player.state.playing, isTrue); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - - test('ready guest re-announces readiness after receiving session config', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'guest'); - final player = _FakePlayer(playing: false, position: const Duration(seconds: 10)); - final manager = _guestManager(peerService, controlMode: ControlMode.anyone); - - manager.initializeParticipants(['guest', 'host']); - manager.attachPlayer(player); - peerService.broadcasts.clear(); - - peerService.emit( - SyncMessage.sessionConfig( - controlMode: ControlMode.anyone, - currentPosition: const Duration(seconds: 20), - isPlaying: false, - playbackRate: 1.0, - peerId: 'host', - ), - ); - await _settle(); - - expect( - peerService.broadcasts.where( - (message) => - message.type == SyncMessageType.playerReady && - message.peerId == 'guest' && - message.bufferingState == true, - ), - isNotEmpty, - ); - expect(player.state.position, const Duration(seconds: 20)); - expect(player.state.playing, isFalse); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - - test('host local play is not deferred after guest readiness is restored', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'host'); - final player = _FakePlayer(playing: false, position: const Duration(minutes: 3)); - final manager = _hostManager(peerService); - final deferredStates = []; - manager.onDeferredPlayChanged = deferredStates.add; - - manager.initializeParticipants(['host', 'guest']); - manager.attachPlayer(player); - peerService.emit(SyncMessage.playerReady(peerId: 'guest', ready: true)); - await _settle(); - peerService.broadcasts.clear(); - - await player.emitPlaying(true); - - expect(deferredStates, isNot(contains(true))); - expect(player.state.playing, isTrue); - expect(peerService.broadcasts.where((m) => m.type == SyncMessageType.play), isNotEmpty); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - - test('removing a disconnected not-ready peer resumes deferred play', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'host'); - final player = _FakePlayer(playing: false, position: const Duration(minutes: 5)); - final manager = _hostManager(peerService); - final deferredStates = []; - manager.onDeferredPlayChanged = deferredStates.add; - - manager.initializeParticipants(['host', 'guest']); - manager.attachPlayer(player); - - await player.emitPlaying(true); - - expect(deferredStates, contains(true)); - expect(player.state.playing, isFalse); - - await manager.handlePeerDisconnected('guest'); - - expect(deferredStates, containsAllInOrder([true, false])); - expect(player.state.playing, isTrue); - expect(peerService.broadcasts.where((m) => m.type == SyncMessageType.play), isNotEmpty); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - - test('media-switch attachment cycle re-announces readiness and re-arms the initial-play gate', () async { - final peerService = _FakeWatchTogetherPeerService(peerId: 'host'); - final player = _FakePlayer(playing: false, position: const Duration(minutes: 3)); - final manager = _hostManager(peerService); - final deferredStates = []; - manager.onDeferredPlayChanged = deferredStates.add; - - manager.initializeParticipants(['host', 'guest']); - manager.attachPlayer(player); - peerService.emit(SyncMessage.playerReady(peerId: 'guest', ready: true)); - await _settle(); - - await player.emitPlaying(true); - expect(deferredStates, isNot(contains(true))); - await player.emitPlaying(false); - peerService.broadcasts.clear(); - - // In-place media switch: the reload cycles the attachment exactly like - // the provider does (re-initialize participants, then re-attach). - manager.detachPlayer(); - expect( - peerService.broadcasts.where( - (m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == false, - ), - isNotEmpty, - ); - manager.initializeParticipants(['host', 'guest']); - manager.attachPlayer(player); - - // The already-loaded (non-buffering) player re-announces ready for the - // new item on attach. - expect( - peerService.broadcasts.where( - (m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == true, - ), - isNotEmpty, - ); - - // First play after the switch defers again until the guest is ready. - await player.emitPlaying(true); - expect(deferredStates, contains(true)); - expect(player.state.playing, isFalse); - - manager.dispose(); - await player.dispose(); - await peerService.close(); - }); - }); -} - -WatchTogetherSyncManager _hostManager(_FakeWatchTogetherPeerService peerService) { - return WatchTogetherSyncManager( - peerService: peerService, - session: const WatchSession( - sessionId: 'ROOM1', - role: SessionRole.host, - controlMode: ControlMode.hostOnly, - state: SessionState.connected, - hostPeerId: 'host', - ), - displayName: 'Host', - ); -} - -WatchTogetherSyncManager _guestManager(_FakeWatchTogetherPeerService peerService, {required ControlMode controlMode}) { - return WatchTogetherSyncManager( - peerService: peerService, - session: WatchSession( - sessionId: 'ROOM1', - role: SessionRole.guest, - controlMode: controlMode, - state: SessionState.connected, - hostPeerId: 'host', - ), - displayName: 'Guest', - ); -} - -Future _settle() async { - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); -} - -class _FakeWatchTogetherPeerService extends WatchTogetherPeerService { - _FakeWatchTogetherPeerService({required this.peerId}) : super(customBaseUrl: 'http://localhost'); - - final String peerId; - final StreamController _messages = StreamController.broadcast(); - final List broadcasts = []; - final Map> sentMessages = {}; - - @override - String? get myPeerId => peerId; - - @override - Stream get onMessageReceived => _messages.stream; - - @override - void broadcast(SyncMessage message) { - broadcasts.add(message); - } - - @override - void sendTo(String peerId, SyncMessage message) { - sentMessages.putIfAbsent(peerId, () => []).add(message); - } - - void emit(SyncMessage message) { - _messages.add(message); - } - - Future close() => _messages.close(); -} - -class _FakePlayer implements Player { - _FakePlayer({bool playing = false, Duration position = Duration.zero}) - : _state = PlayerState(playing: playing, buffering: false, position: position); - - PlayerState _state; - bool _disposed = false; - - final StreamController _playingController = StreamController.broadcast(); - final StreamController _bufferingController = StreamController.broadcast(); - final StreamController _rateController = StreamController.broadcast(); - - @override - PlayerState get state => _state; - - @override - PlayerStreams get streams => PlayerStreams( - playing: _playingController.stream, - completed: const Stream.empty(), - buffering: _bufferingController.stream, - position: const Stream.empty(), - duration: const Stream.empty(), - seekable: const Stream.empty(), - buffer: const Stream.empty(), - volume: const Stream.empty(), - rate: _rateController.stream, - tracks: const Stream.empty(), - track: const Stream.empty(), - log: const Stream.empty(), - error: const Stream.empty(), - audioDevice: const Stream.empty(), - audioDevices: const Stream>.empty(), - bufferRanges: const Stream>.empty(), - playbackRestart: const Stream.empty(), - backendSwitched: const Stream.empty(), - ); - - Future emitPlaying(bool value) async { - _state = _state.copyWith(playing: value); - _playingController.add(value); - await _settle(); - } - - @override - Future play() async { - _state = _state.copyWith(playing: true); - } - - @override - Future pause() async { - _state = _state.copyWith(playing: false); - } - - @override - Future seek(Duration position) async { - _state = _state.copyWith(position: position); - } - - @override - Future setRate(double rate) async { - _state = _state.copyWith(rate: rate); - _rateController.add(rate); - } - - @override - bool get disposed => _disposed; - - @override - Future dispose({bool preserveDisplayMode = false}) async { - _disposed = true; - await _playingController.close(); - await _bufferingController.close(); - await _rateController.close(); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -}