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.
This commit is contained in:
edde746
2026-06-11 09:42:38 +02:00
parent e54bbd11f1
commit a5c7d5b52a
27 changed files with 5099 additions and 1816 deletions
@@ -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();