From f5488cb7ff8136f845977fe7baae942f99346610 Mon Sep 17 00:00:00 2001 From: Tolu Adegbehingbe <71156414+toluLikesToCode@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:54:17 -0600 Subject: [PATCH] fix(player): hold the Watch Together anchor while the host reloads (#1809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An in-place source switch — audio, subtitle, version or quality — detaches the host's player for the duration of the reload. `_broadcast` falls back to a position of 0 when no player is attached, so any state published in that window names 0:00 as the authoritative position and every guest hard-seeks to the start of the item. Heartbeats already suppress themselves while detached, which is why this hides: the paths that leak the zero are the ones that answer on demand. `onStateRequested`, `onPeerJoined` and `onReconnected` all broadcast regardless of whether a player is attached, so a guest entering the player, joining, or reconnecting mid-reload is the trigger. Fall back to the last broadcast anchor instead. That field is only assigned for untargeted broadcasts, so it holds the last position the room was actually told, and the reload's own re-attach path already re-anchors from it once the player comes back. --- .../services/host_playback_coordinator.dart | 6 +++- .../host_playback_coordinator_test.dart | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index a2a50a47..50d40582 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -777,7 +777,11 @@ class HostPlaybackCoordinator { anchorPositionMs = _pendingStartPositionMs ?? player?.position.inMilliseconds ?? 0; anchorHostTimeMs = _pendingStartAtMs!; } else { - anchorPositionMs = anchorPositionOverrideMs ?? player?.position.inMilliseconds ?? 0; + // An in-place reload detaches the player, and the reply-on-demand paths + // still answer while it is gone. Without the last broadcast to fall back + // on, they publish an authoritative 0 and every guest hard-seeks to 0:00. + anchorPositionMs = + anchorPositionOverrideMs ?? player?.position.inMilliseconds ?? _lastBroadcast?.anchorPositionMs ?? 0; anchorHostTimeMs = _nowMs(); } diff --git a/test/watch_together/host_playback_coordinator_test.dart b/test/watch_together/host_playback_coordinator_test.dart index 38a2a1d3..25746c20 100644 --- a/test/watch_together/host_playback_coordinator_test.dart +++ b/test/watch_together/host_playback_coordinator_test.dart @@ -716,6 +716,36 @@ void main() { h.dispose(); }); }); + + test('the reply-on-demand paths hold the anchor while the player is detached', () { + fakeAsync((async) { + final h = _Harness(async); + h.attachForMedia(async, hasFirstFrame: true); + h.hostBecomesReady(async); + final anchored = h.last.anchorPositionMs; + expect(anchored, const Duration(minutes: 2).inMilliseconds); + + // The state every in-place source reload passes through. Heartbeats + // suppress themselves here; the on-demand replies do not, so they are + // the paths that would otherwise publish a position of 0. + h.coordinator.detachPlayer(); + async.flushMicrotasks(); + + h.coordinator.onStateRequested('guest'); + h.coordinator.onPeerJoined('guest2', compatible: true); + h.coordinator.onReconnected(); + async.flushMicrotasks(); + + for (final (state, toPeerId) in h.sent.skip(h.sent.length - 3)) { + expect( + state.anchorPositionMs, + anchored, + reason: 'reply to ${toPeerId ?? 'the room'} must hold the anchor, not collapse it to 0', + ); + } + h.dispose(); + }); + }); }); group('driver distraction', () {