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

+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;
}
}