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
@@ -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<String>) build(
FakeAsync async, {
bool playing = false,
Future<void> Function(Duration)? remoteSeek,
}) {
final player = FakeSyncPlayer(playing: playing);
final lostEvents = <String>[];
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 = <bool>[];
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 = <bool>[];
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 = <bool>[];
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 = <bool>[];
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 = <double>[];
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<PlatformException>());
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 = <Duration>[];
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 = <bool>[];
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();
});
});
});
}
+138
View File
@@ -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<int>) build(FakeAsync async, {int epochMs = 1000000}) {
final pings = <int>[];
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();
});
});
}
@@ -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<SyncMessage> outgoing = [];
final List<int> 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<String> 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<String> get seekCommands => player.commandLog.where((c) => c.startsWith('seek:'));
Iterable<PeerStatus> get statuses => outgoing.where((m) => m.type == SyncMessageType.status).map((m) => m.status!);
Iterable<ControlRequest> 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 = <bool>[];
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 = <bool>[];
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();
});
});
});
}
@@ -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<PlaybackState> 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 = <PlaybackState>[];
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<void>();
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 = <List<String>>[];
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();
});
});
});
}
@@ -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');
});
});
}
@@ -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 = <String>[];
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 = <String>[];
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 = <String>[];
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();
});
});
}
@@ -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();
@@ -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 = <bool>[];
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 = <bool>[];
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 = <bool>[];
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 = <bool>[];
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 = <bool>[];
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<void> _settle() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
_FakeWatchTogetherPeerService({required this.peerId}) : super(customBaseUrl: 'http://localhost');
final String peerId;
final StreamController<SyncMessage> _messages = StreamController<SyncMessage>.broadcast();
final List<SyncMessage> broadcasts = [];
final Map<String, List<SyncMessage>> sentMessages = {};
@override
String? get myPeerId => peerId;
@override
Stream<SyncMessage> 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<void> 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<bool> _playingController = StreamController<bool>.broadcast();
final StreamController<bool> _bufferingController = StreamController<bool>.broadcast();
final StreamController<double> _rateController = StreamController<double>.broadcast();
@override
PlayerState get state => _state;
@override
PlayerStreams get streams => PlayerStreams(
playing: _playingController.stream,
completed: const Stream<bool>.empty(),
buffering: _bufferingController.stream,
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: _rateController.stream,
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
Future<void> emitPlaying(bool value) async {
_state = _state.copyWith(playing: value);
_playingController.add(value);
await _settle();
}
@override
Future<void> play() async {
_state = _state.copyWith(playing: true);
}
@override
Future<void> pause() async {
_state = _state.copyWith(playing: false);
}
@override
Future<void> seek(Duration position) async {
_state = _state.copyWith(position: position);
}
@override
Future<void> setRate(double rate) async {
_state = _state.copyWith(rate: rate);
_rateController.add(rate);
}
@override
bool get disposed => _disposed;
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
_disposed = true;
await _playingController.close();
await _bufferingController.close();
await _rateController.close();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}