Guest switch dispatch pre-marked its dedup key and fired-and-forgot, so any failure (fetch error, reload busy with an auto-advance, navigation race with the host exiting) silently stranded the guest on the old media. A CurrentPlaybackDispatcher now marks a key handled only after the sink reports success against the committed identity, with a serialized in-flight slot, timeout, and generation reset; the reconciler re-offers unattached media on every host heartbeat, making the heartbeat the retry channel. Fetches that outlive their dispatch are re-validated against the current snapshot so a stale switch can't override the live one. hostExitedPlayer now rides the controller's ordered message queue with host authentication instead of racing state handling in the provider.
593 lines
21 KiB
Dart
593 lines
21 KiB
Dart
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('detached guest is re-notified on every state (heartbeat retry channel)', () {
|
|
fakeAsync((async) {
|
|
final switches = <String>[];
|
|
final h = _Harness(
|
|
async,
|
|
callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add(rk)),
|
|
);
|
|
|
|
// Never attached: every heartbeat re-offers the switch so a failed
|
|
// navigation can retry (the provider's dispatcher dedups).
|
|
h.reconciler.onState(h.state());
|
|
h.reconciler.onState(h.state());
|
|
h.reconciler.onState(h.state());
|
|
async.flushMicrotasks();
|
|
|
|
expect(switches, ['rk1', 'rk1', 'rk1']);
|
|
expect(h.player.commandLog, isEmpty);
|
|
h.dispose();
|
|
});
|
|
});
|
|
|
|
test('attached to matching media never fires the switch callback', () {
|
|
fakeAsync((async) {
|
|
final switches = <String>[];
|
|
final h = _Harness(
|
|
async,
|
|
callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add(rk)),
|
|
);
|
|
h.attachReady();
|
|
|
|
h.reconciler.onState(h.state());
|
|
async.elapse(const Duration(seconds: 2));
|
|
|
|
expect(switches, isEmpty);
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
}
|