Sync fixes: clean seeks, clock offset, end of file, 2.5 min read-ahead (proto 6)

- User seeks are intercepted at the player (SyncPlayer), previewed
  locally, sent once 300 ms after the drag ends; own echoes are matched
  by seekSeq and never re-applied; the landed check can't reapply a
  stale target. 1 s position poll removed.
- ping/pong clock offset (min-RTT ClockSync); positions use server time.
- Reports carry duration; buffer gate is min(4 s, remaining - 2 s);
  new finished transport instead of a HELD stall at the end.
- cache/readahead 150 s, 256 MiB demuxer cache, pins read back.
- Resumed reports put a stale ready member back in the set; hello.resume
  takes over the old member after an unclean drop (no ghost).
- Docs: roadmap rendering findings (HW path works; shaders verified).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:20:18 +03:00
1 parent 0669ec0fc2
commit d8d487c189
28 files changed
+1592 -237

No files matched your search

@@ -8,6 +8,7 @@ import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created
@@ -15,7 +16,18 @@ import 'frame_watchdog.dart';
/// [recreate] for "Reload video". Never reuse a player across sessions —
/// a GPU-side freeze sticks to the player, not to the widget.
class PlaybackController {
Player? _player;
/// Read-ahead target: about two minutes rides out short outages and
/// makes skipping an intro instant.
static const readAheadSecs = 150;
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
static const maxBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
static const maxBackBytes = 64 * 1024 * 1024;
SyncPlayer? _player;
VideoController? _video;
Timer? _watchTimer;
bool _probing = false;
@@ -37,6 +49,13 @@ class PlaybackController {
Future<int?> Function()? shownFrame;
Player get player => _player!;
/// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek;
/// Pinned mpv properties whose read-back differs from what was set
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
final List<String> pinMismatches = [];
bool get _ready => _player != null;
/// The [VideoController] of the current player. Created lazily by the
@@ -64,12 +83,13 @@ class PlaybackController {
Future<void> init() async {
if (_player != null) return;
_player = Player(
_player = SyncPlayer(
configuration: PlayerConfiguration(
bufferSize: 64 * 1024 * 1024,
bufferSize: maxBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
));
))
..onUserSeek = (d) => onUserSeek?.call(d);
await _pinBufferProps();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
@@ -94,14 +114,23 @@ class PlaybackController {
try {
final dynamic platform = player.platform;
await platform?.setProperty(property, value);
final got = '${await platform?.getProperty(property)}';
if (!pinMatches(value, got)) {
pinMismatches.add('$property: $value -> $got');
debugPrint('pin $property=$value not applied, mpv has "$got"');
}
} catch (e, st) {
pinMismatches.add('$property: $value -> error $e');
debugPrint('pin $property=$value failed: $e\n$st');
}
}
Future<void> _pinBufferProps() async {
await _pin('demuxer-readahead-secs', '10');
await _pin('cache-secs', '10');
pinMismatches.clear();
await _pin('demuxer-readahead-secs', '$readAheadSecs');
await _pin('cache-secs', '$readAheadSecs');
await _pin('demuxer-max-bytes', '$maxBytes');
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
final hwdec = _hwdecOverride;
if (hwdec != null) {
await _pin('hwdec', hwdec);
@@ -214,7 +243,8 @@ class PlaybackController {
switch (action) {
case SeekAction(:final position):
if ((positionSecs - position).abs() < 0.5) break;
await player.seek(Duration(milliseconds: (position * 1000).round()));
await _player!
.syncSeek(Duration(milliseconds: (position * 1000).round()));
case TransportAction(:final playing):
if (playing) {
await player.play();
@@ -269,3 +299,10 @@ class PlaybackController {
}
}
}
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
@@ -8,6 +8,7 @@ import '../playback/playback_controller.dart';
import '../sfx/sfx.dart';
import '../sync/state_diff.dart';
import '../sync/sync_client.dart';
import '../sync/user_seek.dart';
import 'health.dart';
import 'idle.dart';
@@ -113,7 +114,6 @@ class SessionCoordinator {
// Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false;
DateTime? _reassertAt;
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
int _epoch = 0;
Timer? _reconnectTimer;
@@ -121,10 +121,19 @@ class SessionCoordinator {
String? _openUrl;
String _wsHost = '';
int _wsPort = 0;
double _lastPos = 0;
double? _pendingSeekTarget;
// Server seek applied to the player (event id and when), checked once it
// should have landed.
int? _pendingSeekEvent;
DateTime? _pendingSeekAt;
int _seekRetries = 0;
int _lastEventId = -1;
final UserSeekGate _seekGate = UserSeekGate();
Timer? _seekTimer;
// True while a media open is in flight: the player's duration may still
// belong to the previous media then.
bool _opening = false;
// Token of the current connection, offered as `resume` on reconnect.
String _resumeToken = '';
DateTime? _lastStateAt;
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
int _toastId = 0;
@@ -140,6 +149,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init();
_playerReady = true;
} catch (e, st) {
@@ -166,12 +176,7 @@ class SessionCoordinator {
await playback.recreate();
final url = _openUrl;
_openUrl = null;
_pendingSeekTarget = null;
_pendingSeekAt = null;
_seekRetries = 0;
// New player starts at 0: without this the first tick would read
// the drop to 0 as a user seek and send everyone there.
_lastPos = 0;
_clearPendingSeek();
if (url != null) await _openMedia(url);
} catch (e, st) {
debugPrint('video reload failed: $e\n$st');
@@ -212,6 +217,10 @@ class SessionCoordinator {
_restoringReady = false;
_wasPlayAllowed = false;
_everConnected = false;
_resumeToken = '';
_seekGate.reset();
_clearPendingSeek();
_lastEventId = -1;
markInput();
_subscribe();
feedLog.value = [];
@@ -233,6 +242,7 @@ class SessionCoordinator {
if (epoch != _epoch) throw const ConnectCancelled();
reconnecting.value = false;
_everConnected = true;
_resumeToken = sync.token;
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
_lastStateAt = DateTime.now().toUtc();
final url = welcome.mediaUrl;
@@ -280,6 +290,7 @@ class SessionCoordinator {
if (url == _openUrl) return;
_openUrl = url;
diff.reset();
_opening = true;
try {
await playback.openUrl(url);
health.value =
@@ -288,6 +299,8 @@ class SessionCoordinator {
debugPrint('open media failed: $e');
health.value =
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
} finally {
_opening = false;
}
}
@@ -296,10 +309,12 @@ class SessionCoordinator {
}
Future<void> _onState(StateBroadcast r) async {
final now = DateTime.now().toUtc();
_lastStateAt = now;
_lastStateAt = DateTime.now().toUtc();
serverState.value = r;
_trackSelfReady(r, now);
_trackSelfReady(r);
_seekGate.observe(r, sync.memberId);
final newEvent = r.eventId != _lastEventId;
_lastEventId = r.eventId;
if (r.mediaId != _mediaId) {
_mediaId = r.mediaId;
var url = stateMediaUrl(r);
@@ -312,14 +327,16 @@ class SessionCoordinator {
} else {
_openUrl = null;
}
_pendingSeekTarget = null;
_seekRetries = 0;
_seekGate.reset();
_clearPendingSeek();
}
if (!_playerReady || _openUrl == null) {
_wasPlayAllowed = false;
_updateStarving(r, 0);
return;
}
// Server clock: serverTime in the state is the server's.
final now = sync.serverNow();
final allowed = localPlayAllowed(r, sync.memberId);
var actions = diff.diff(r, playback.positionSecs, now);
if (r.transport == 'playing' && !allowed) {
@@ -335,23 +352,28 @@ class SessionCoordinator {
SeekAction(edge),
const TransportAction(true),
];
_pendingSeekTarget = edge;
_pendingSeekAt = now;
}
_wasPlayAllowed = allowed;
for (final a in actions) {
if (a is SeekAction) {
_pendingSeekTarget = a.position;
_pendingSeekAt = now;
}
// The user is seeking (or our seek has not echoed yet), or this is the
// echo of our own seek arriving: the player is where the user put it.
// Later drift corrections within that event still apply.
if (_seekGate.holdsPlayer(DateTime.now().toUtc()) ||
(newEvent && _seekGate.isOwnEcho(r, sync.memberId))) {
actions = [for (final a in actions) if (a is! SeekAction) a];
}
if (actions.any((a) => a is SeekAction)) {
_pendingSeekEvent = r.eventId;
_pendingSeekAt = DateTime.now().toUtc();
_seekRetries = 0;
}
await playback.apply(actions);
_updateStarving(r, playback.bufferedSecsAhead);
}
/// Mirrors the server's ready flag for self into [_lastReady] (force
/// by others included) and re-enters the set after a stale spell.
void _trackSelfReady(StateBroadcast r, DateTime now) {
/// by others included). After a stale spell the server puts a ready
/// member back in the set by itself.
void _trackSelfReady(StateBroadcast r) {
final self = selfMember.peek();
if (self == null) return;
if (r.mediaId != _mediaId) _restoringReady = false;
@@ -360,12 +382,6 @@ class SessionCoordinator {
} else {
_lastReady = self.ready;
}
if (shouldReassertReady(self) &&
(_reassertAt == null ||
now.difference(_reassertAt!) >= const Duration(seconds: 3))) {
_reassertAt = now;
sync.sendReady(true, source: 'auto');
}
}
void _updateStarving(StateBroadcast r, double bufferedAhead) {
@@ -373,6 +389,10 @@ class SessionCoordinator {
starving.value = null;
return;
}
if (r.transport == 'finished') {
starving.value = 'finished — seek back or change media';
return;
}
for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
starving.value =
@@ -474,13 +494,17 @@ class SessionCoordinator {
toasts.value =
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
final r = serverState.peek();
if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one.
final known = !_opening && _openUrl != null && _mediaId == r.mediaId;
sync.sendReport(
position: playback.positionSecs,
buffered: playback.bufferedSecsAhead,
eventId: r.eventId,
duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0,
);
_detectUserSeek(r);
_checkSeekLanded(r);
}
if (!_manualClose &&
@@ -492,8 +516,7 @@ class SessionCoordinator {
starving.value = null;
_wasPlayAllowed = false;
_mediaId = -1;
_pendingSeekTarget = null;
_seekRetries = 0;
_clearPendingSeek();
diff.reset();
}
if (!reconnecting.peek()) {
@@ -527,46 +550,70 @@ class SessionCoordinator {
}
}
void _detectUserSeek(StateBroadcast r) {
if (_openUrl == null) {
_lastPos = playback.positionSecs;
return;
}
final now = DateTime.now().toUtc();
final pos = playback.positionSecs;
final jumped = (pos - _lastPos).abs();
_lastPos = pos;
if (jumped <= 2.0) return;
if ((pos - serverPositionAt(r, now)).abs() < 2.0) return;
final recentSync = _pendingSeekAt != null &&
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
if (recentSync) return;
markInput();
sync.sendSeek(pos);
void _clearPendingSeek() {
_pendingSeekEvent = null;
_pendingSeekAt = null;
_seekRetries = 0;
}
/// A server seek that has not landed after 1.5 s is applied once more,
/// to where the server is now (never the old target: it went stale
/// while playing). Dropped when a newer event or a user seek took over.
Future<void> _checkSeekLanded(StateBroadcast r) async {
final target = _pendingSeekTarget;
final event = _pendingSeekEvent;
final at = _pendingSeekAt;
if (target == null || at == null) return;
if (DateTime.now().toUtc().difference(at).inMilliseconds < 1500) return;
if (event == null || at == null) return;
final now = DateTime.now().toUtc();
if (event != r.eventId || _seekGate.holdsPlayer(now)) {
_clearPendingSeek();
return;
}
if (now.difference(at).inMilliseconds < 1500) return;
final target = serverPositionAt(r, sync.serverNow());
if (seekLanded(ownPosition: playback.positionSecs, target: target)) {
_pendingSeekTarget = null;
_pendingSeekAt = null;
_seekRetries = 0;
_clearPendingSeek();
return;
}
if (_seekRetries < 1) {
_seekRetries++;
_pendingSeekAt = DateTime.now().toUtc();
_pendingSeekAt = now;
await playback.apply([SeekAction(target)]);
} else {
_pendingSeekTarget = null;
_pendingSeekAt = null;
_seekRetries = 0;
_clearPendingSeek();
}
}
/// Every user seek (seek bar, arrow keys, [seekTo]) lands here: the
/// player moves at once (scrub preview), the server hears one `seek`
/// once the user stops for [UserSeekGate.debounce].
void _userSeek(double position) {
if (!position.isFinite || position < 0) return;
if (serverState.peek() == null || _openUrl == null) return;
markInput();
final now = DateTime.now().toUtc();
_seekGate.request(position, now);
_clearPendingSeek();
unawaited(playback.apply([SeekAction(position)]));
_seekTimer?.cancel();
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
}
void _flushUserSeek() {
_seekTimer = null;
final settled = _seekGate.takeSettled(DateTime.now().toUtc());
if (settled == null) return;
var position = settled.position;
// Playing on since the request: send where the player is now, so the
// server's clock and ours agree when the echo comes back.
final own = playback.positionSecs;
if (_playerReady &&
playback.player.state.playing &&
(own - position).abs() < 2.0) {
position = own;
}
sync.sendSeek(position, seq: settled.seq);
}
void toggleReady() {
final self = selfMember.peek();
// Ready but out of the set (dropped out while stale) gates like
@@ -587,19 +634,17 @@ class SessionCoordinator {
sync.sendForce('all', true);
}
void seekTo(double position) {
markInput();
sync.sendSeek(position);
}
void seekTo(double position) => _userSeek(position);
/// Arrow keys: relative to a seek still in flight, so repeated presses
/// add up.
void nudge(double deltaSecs) {
final r = serverState.peek();
if (r == null || _openUrl == null) return;
final target =
serverPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
if (target.isNaN || target < 0) return;
markInput();
sync.sendSeek(target);
final base = _seekGate.target ?? serverPositionAt(r, sync.serverNow());
final target = base + deltaSecs;
if (target.isNaN) return;
_userSeek(target < 0 ? 0 : target);
}
void sendChat(String text) {
@@ -656,11 +701,13 @@ class SessionCoordinator {
name: params.name,
avatar: params.avatar,
hostKey: params.hostKey,
resume: _resumeToken,
);
// Whoever bumped the epoch also closed (or replaced) the socket.
if (stale()) return;
reconnecting.value = false;
_everConnected = true;
_resumeToken = sync.token;
_lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
final url = welcome.mediaUrl;
@@ -739,6 +786,9 @@ class SessionCoordinator {
_reconnectTimer = null;
_ticker?.cancel();
_ticker = null;
_seekTimer?.cancel();
_seekTimer = null;
_seekGate.reset();
reconnecting.value = false;
_playerReady = false;
try {
+26 -8
View File
@@ -19,9 +19,13 @@ class RateAction extends PlayerAction {
const RateAction(this.rate);
}
double serverPositionAt(StateBroadcast state, DateTime now) {
/// Server position at [serverNow], which must be on the server's clock
/// (`SyncClient.serverNow()`, local time corrected by the clock offset):
/// `state.serverTime` is stamped by the server.
double serverPositionAt(StateBroadcast state, DateTime serverNow) {
if (state.transport != 'playing') return state.position;
return state.position + now.difference(state.serverTime).inMilliseconds / 1000.0;
return state.position +
serverNow.difference(state.serverTime).inMicroseconds / 1e6;
}
bool seekLanded({required double ownPosition, required double target}) {
@@ -39,12 +43,6 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
return false;
}
/// Self is ready but out of the set and fresh again: the server's stale
/// sweep dropped it from the set during a signal gap and nothing puts it
/// back. Re-sending ready restores it without the user pressing Space.
bool shouldReassertReady(MemberView? self) =>
self != null && self.ready && !self.inSet && !self.stale;
class StateDiff {
static const debounceWindow = Duration(seconds: 2);
@@ -57,6 +55,7 @@ class StateDiff {
bool get slowing => _slowing;
/// [now] is server time (see [serverPositionAt]).
List<PlayerAction> diff(StateBroadcast state, double ownPosition, DateTime now) {
final out = <PlayerAction>[];
final serverPos = serverPositionAt(state, now);
@@ -73,6 +72,25 @@ class StateDiff {
return out;
}
// End of media: everyone stops where they are (at or just before the
// end). No seek to the very end and no drift correction.
if (state.transport == 'finished') {
final first = _lastEventId == -1;
final changed = first ||
state.eventId != _lastEventId ||
state.transport != _lastTransport;
_lastEventId = state.eventId;
_lastTransport = state.transport;
_lastMediaId = state.mediaId;
_slowing = false;
_deviatingSince = null;
if (!changed) return out;
out.add(const RateAction(1.0));
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
out.add(const TransportAction(false));
return out;
}
if (_lastEventId == -1) {
_lastEventId = state.eventId;
_lastTransport = state.transport;
+35 -2
View File
@@ -7,6 +7,18 @@ import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, kicked, closed, error }
class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off.
final DateTime Function() _clock;
SyncClient({DateTime Function()? clock})
: _clock = clock ?? (() => DateTime.now().toUtc());
/// Offset to the server's clock, from ping/pong round trips.
final ClockSync clockSync = ClockSync();
/// Now on the server's clock: use this, not the local clock, against
/// `serverTime` in a state.
DateTime serverNow() => clockSync.serverNow(_clock());
WebSocketChannel? _channel;
StreamSubscription? _sub;
final StreamController<StateBroadcast> _states = StreamController.broadcast();
@@ -34,8 +46,10 @@ class SyncClient {
String avatar = '',
HelloMedia? media,
String hostKey = '',
String resume = '',
}) async {
await disconnect();
clockSync.reset();
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
@@ -63,6 +77,7 @@ class SyncClient {
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey,
resume: resume,
);
ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter;
@@ -81,6 +96,7 @@ class SyncClient {
} catch (_) {}
}
_status.add(SyncStatus.open);
sendPing();
return welcome;
} catch (e) {
_pendingWelcome = null;
@@ -138,6 +154,14 @@ class SyncClient {
} catch (_) {
_kicked.add('');
}
case 'pong':
try {
final pong = PongMessage.fromJson(decoded);
clockSync.add(
sentMs: pong.clientMs,
serverMs: pong.serverMs,
receivedMs: _clock().millisecondsSinceEpoch);
} catch (_) {}
case 'err':
_errors.add('${decoded['message'] ?? 'error'}');
case 'chat':
@@ -170,9 +194,14 @@ class SyncClient {
_send(ForceIntent(target: target, ready: ready).toJson());
}
void sendSeek(double position) {
void sendSeek(double position, {int seq = 0}) {
if (position.isNaN || position < 0) return;
_send(SeekIntent(position: position).toJson());
_send(SeekIntent(position: position, seq: seq).toJson());
}
/// One clock sample; the coordinator sends one per second.
void sendPing() {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
}
void sendSwap(HelloMedia media) {
@@ -183,6 +212,8 @@ class SyncClient {
required double position,
required double buffered,
required int eventId,
double duration = 0,
int mediaId = 0,
}) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send(
@@ -190,6 +221,8 @@ class SyncClient {
position: position,
buffered: safeBuffered,
eventId: eventId,
duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId,
).toJson(),
);
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
/// (and anything else in the video controls) calls `player.seek`; with
/// [onUserSeek] set those calls become explicit events for the sync layer
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
class SyncPlayer extends Player {
SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
/// Seek mpv directly (preview while scrubbing, server corrections).
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
/// arrow keys one per press) into one `seek` intent, and tells the
/// coordinator when server seeks must not touch the player:
///
/// - while the user is still seeking ([debounce] since the last request),
/// - while our sent seek has no echo yet ([echoTimeout] at most),
/// - for the echo of our own seek (`state.seekBy` is us): the player is
/// already there, re-seeking would only hitch or yank.
class UserSeekGate {
final Duration debounce;
final Duration echoTimeout;
UserSeekGate({
this.debounce = const Duration(milliseconds: RelayConstants.seekDebounceMs),
this.echoTimeout = const Duration(seconds: 3),
});
double? _target;
DateTime? _requestedAt;
int _seq = 0;
int _sentSeq = 0;
DateTime? _sentAt;
/// Latest requested position, while a request or its echo is pending.
double? get target => _target;
/// The user asked for [position] at [now].
void request(double position, DateTime now) {
_target = position;
_requestedAt = now;
}
bool scrubbing(DateTime now) =>
_requestedAt != null && now.difference(_requestedAt!) < debounce;
/// The settled request to send, once [debounce] has passed since the last
/// one; null otherwise. Returns its seq, which the server echoes.
({double position, int seq})? takeSettled(DateTime now) {
final t = _target;
if (t == null || _requestedAt == null || scrubbing(now)) return null;
_requestedAt = null;
_sentSeq = ++_seq;
_sentAt = now;
return (position: t, seq: _sentSeq);
}
bool get _awaitingEcho => _sentAt != null;
/// Server seeks are ignored while this holds.
bool holdsPlayer(DateTime now) {
if (_requestedAt != null) return true; // not sent yet
final sent = _sentAt;
if (sent == null) return false;
if (now.difference(sent) >= echoTimeout) {
_sentAt = null;
_target = null;
return false;
}
return true;
}
/// The state's current event is a seek this client sent.
bool isOwnEcho(StateBroadcast state, String memberId) {
final seq = state.seekSeq;
return memberId.isNotEmpty &&
state.seekBy == memberId &&
seq != null &&
seq > 0 &&
seq <= _seq;
}
/// Feed every state: the echo of the last sent seek ends the hold.
void observe(StateBroadcast state, String memberId) {
if (!_awaitingEcho || _requestedAt != null) return;
if (isOwnEcho(state, memberId) && state.seekSeq! >= _sentSeq) {
_sentAt = null;
_target = null;
}
}
void reset() {
_target = null;
_requestedAt = null;
_sentAt = null;
}
}