Files
sync_replay_project/apps/desktop/lib/src/sync/state_diff.dart
T
foxyzandClaude Opus 5.5 d8d487c189 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>
2026-10-07 01:20:18 +03:00

167 lines
4.8 KiB
Dart

import 'package:sync_relay_core/core.dart';
sealed class PlayerAction {
const PlayerAction();
}
class SeekAction extends PlayerAction {
final double position;
const SeekAction(this.position);
}
class TransportAction extends PlayerAction {
final bool playing;
const TransportAction(this.playing);
}
class RateAction extends PlayerAction {
final double rate;
const RateAction(this.rate);
}
/// 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 +
serverNow.difference(state.serverTime).inMicroseconds / 1e6;
}
bool seekLanded({required double ownPosition, required double target}) {
if ((ownPosition - target).abs() > RelayConstants.driftSeekThresholdSecs) {
return false;
}
return true;
}
bool localPlayAllowed(StateBroadcast state, String memberId) {
if (memberId.isEmpty) return false;
for (final m in state.members) {
if (m.id == memberId) return m.inSet && m.ready;
}
return false;
}
class StateDiff {
static const debounceWindow = Duration(seconds: 2);
int _lastEventId = -1;
String _lastTransport = '';
int _lastMediaId = -1;
bool _slowing = false;
DateTime? _deviatingSince;
int _deviatingEvent = -1;
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);
if (state.mediaId != _lastMediaId && _lastMediaId != -1) {
_lastEventId = state.eventId;
_lastTransport = state.transport;
_lastMediaId = state.mediaId;
_slowing = false;
_deviatingSince = null;
out.add(const SeekAction(0));
out.add(const TransportAction(false));
out.add(const RateAction(1.0));
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;
_lastMediaId = state.mediaId;
_deviatingSince = null;
out.add(SeekAction(serverPos));
out.add(TransportAction(state.transport == 'playing'));
return out;
}
if (state.eventId != _lastEventId) {
_lastEventId = state.eventId;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(SeekAction(serverPos));
if (state.transport != _lastTransport) {
_lastTransport = state.transport;
out.add(TransportAction(state.transport == 'playing'));
}
return out;
}
if (state.transport != _lastTransport) {
_lastTransport = state.transport;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(TransportAction(state.transport == 'playing'));
out.add(SeekAction(serverPos));
return out;
}
final drift = ownPosition - serverPos;
if (_slowing) {
if (drift.abs() < 0.1) {
_slowing = false;
out.add(const RateAction(1.0));
}
return out;
}
switch (correctionFor(ownPosition: ownPosition, serverPosition: serverPos)) {
case Correction.seek:
if (_deviatingEvent != state.eventId || _deviatingSince == null) {
_deviatingEvent = state.eventId;
_deviatingSince = now;
break;
}
if (now.difference(_deviatingSince!).compareTo(debounceWindow) < 0) {
break;
}
_deviatingSince = null;
out.add(SeekAction(serverPos));
case Correction.slowDown:
_deviatingSince = null;
_slowing = true;
out.add(const RateAction(RelayConstants.slowDownRate));
case Correction.none:
_deviatingSince = null;
break;
}
return out;
}
void reset() {
_lastEventId = -1;
_lastTransport = '';
_lastMediaId = -1;
_slowing = false;
_deviatingSince = null;
_deviatingEvent = -1;
}
}