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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user