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

+63
View File
@@ -130,4 +130,67 @@ void main() {
expect(RangeRequest.parse('bytes=5000-6000', 1000), isNull);
});
});
group('ClockSync', () {
test('uses the min-RTT sample, not the average', () {
final c = ClockSync();
expect(c.offsetMs, 0);
// Server is 5000 ms ahead. Symmetric 10 ms trip: exact.
c.add(sentMs: 1000, serverMs: 6010, receivedMs: 1020);
// Asymmetric slow trips (300 ms out, 10 back) would skew an average.
c.add(sentMs: 2000, serverMs: 7300, receivedMs: 2310);
c.add(sentMs: 3000, serverMs: 8400, receivedMs: 3410);
expect(c.rttMs, 20);
expect(c.offsetMs, 5000);
final local = DateTime.utc(2026, 1, 1);
expect(c.serverNow(local), local.add(const Duration(seconds: 5)));
});
test('old samples age out of the window', () {
final c = ClockSync(window: 2);
c.add(sentMs: 0, serverMs: 105, receivedMs: 10); // offset 100
c.add(sentMs: 0, serverMs: 250, receivedMs: 100);
c.add(sentMs: 0, serverMs: 250, receivedMs: 100);
expect(c.offsetMs, 200);
});
test('negative round trip ignored', () {
final c = ClockSync()..add(sentMs: 10, serverMs: 0, receivedMs: 5);
expect(c.hasSample, isFalse);
});
});
group('proto 6 fields', () {
Map<String, Object?> rt(Map<String, Object?> j) =>
Map<String, Object?>.from(jsonDecode(jsonEncode(j)) as Map);
test('round-trip', () {
final hello = HelloMessage.fromJson(rt(const HelloMessage(
proto: 6,
secret: '',
user: HelloUser(name: 'a'),
media: HelloMedia(name: '', size: 0, duration: 0),
resume: 'tok')
.toJson()));
expect(hello.resume, 'tok');
final seek = SeekIntent.fromJson(rt(const SeekIntent(position: 3, seq: 9).toJson()));
expect(seek.seq, 9);
final report = ReportMessage.fromJson(rt(const ReportMessage(
position: 1, buffered: 2, eventId: 3, duration: 1440.5, mediaId: 4)
.toJson()));
expect(report.duration, 1440.5);
expect(report.mediaId, 4);
final pong = PongMessage.fromJson(
rt(const PongMessage(clientMs: 5, serverMs: 7).toJson()));
expect((pong.clientMs, pong.serverMs), (5, 7));
expect(PingMessage.fromJson(rt(const PingMessage(clientMs: 5).toJson())).clientMs, 5);
});
test('older frames without the new fields still parse', () {
expect(SeekIntent.fromJson({'t': 'seek', 'position': 1}).seq, 0);
final r = ReportMessage.fromJson(
{'t': 'report', 'position': 1, 'buffered': 2, 'eventId': 3});
expect((r.duration, r.mediaId), (0.0, 0));
});
});
}
+135
View File
@@ -402,4 +402,139 @@ void main() {
expect(rpBack.eventId, 3);
});
});
group('stale ready rejoins', () {
test('ready member swept while stale is back in the set when reports resume', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 6.0);
buffer('b', 6.0);
expect(engine.transport, Transport.playing);
// bob goes silent past staleAfter; alice keeps reporting.
for (var i = 0; i < 16; i++) {
t = t.add(const Duration(seconds: 1));
buffer('a', 6.0);
engine.sweepStale();
}
expect(engine.members['b']!.inSet, isFalse);
expect(engine.members['b']!.ready, isTrue);
// bob's reports resume: he must be gated again without pressing Space.
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isTrue);
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').inSet,
isTrue);
});
test('unready member does not rejoin on reports', () {
join('a', 'alice');
buffer('a', 6.0);
expect(engine.members['a']!.inSet, isFalse);
});
});
group('end of file', () {
setUp(() {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 20);
});
void playTo(double secs, {required double Function(double pos) buffered}) {
while (engine.positionAt() < secs) {
t = t.add(const Duration(milliseconds: 500));
final pos = engine.positionAt();
engine.applyReport('a', pos, buffered(pos), engine.eventId);
engine.tick();
}
}
test('last seconds play out with less than 4 s left, then finished', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
// mpv's read-ahead cannot exceed what is left of the file.
double left(double pos) => (20 - pos).clamp(0, 6).toDouble();
playTo(19.5, buffered: left);
expect(engine.transport, Transport.playing);
t = t.add(const Duration(seconds: 1));
engine.tick();
expect(engine.transport, Transport.finished);
expect(engine.positionAt(), 20);
expect(engine.broadcast().transport, 'finished');
// Finished never auto-resumes, however ready everyone is.
t = t.add(const Duration(seconds: 5));
buffer('a', 0);
engine.tick();
expect(engine.transport, Transport.finished);
});
test('seek back from finished resumes for ready members', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
t = t.add(const Duration(seconds: 21));
engine.tick();
expect(engine.transport, Transport.finished);
engine.applySeek('a', 5);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
expect(engine.positionAt(), 5);
});
test('swap after finished resets to held', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
t = t.add(const Duration(seconds: 21));
engine.tick();
expect(engine.transport, Transport.finished);
engine.applySwap(
'a', const HelloMedia(name: 'g', size: 1, duration: 0, url: 'http://x/g'));
expect(engine.transport, Transport.held);
expect(engine.media!.duration, 0);
});
test('duration learned from reports for the current media only', () {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
join('a', 'alice');
engine.applyReport('a', 0, 1, engine.eventId,
duration: 99, mediaId: engine.mediaId - 1);
expect(engine.media!.duration, 0);
engine.applyReport('a', 0, 1, engine.eventId,
duration: 42.5, mediaId: engine.mediaId);
expect(engine.media!.duration, 42.5);
expect(engine.broadcast().media!['duration'], 42.5);
});
test('requirement is min(4 s, remaining) with slack', () {
join('a', 'alice');
engine.applySeek('a', 18);
expect(engine.requiredBufferSecs(), lessThan(RelayConstants.minBufferSecs));
engine.applySeek('a', 5);
expect(engine.requiredBufferSecs(), RelayConstants.minBufferSecs);
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
expect(engine.requiredBufferSecs(), RelayConstants.minBufferSecs);
});
});
group('seek attribution', () {
test('state carries who sent the seek and its seq, only for that event', () {
join('a', 'alice');
engine.applySeek('a', 10, seq: 7);
var s = engine.broadcast();
expect(s.seekBy, 'm1');
expect(s.seekSeq, 7);
final back = StateBroadcast.fromJson(Map<String, Object?>.from(
jsonDecode(jsonEncode(s.toJson())) as Map));
expect(back.seekBy, 'm1');
expect(back.seekSeq, 7);
// A later event (gate) is not attributed to the seek.
engine.applyReady('a', true);
buffer('a', 6.0);
s = engine.broadcast();
expect(s.transport, 'playing');
expect(s.seekBy, isNull);
});
});
}