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:
1 parent
0669ec0fc2
commit
d8d487c189
28 files changed
+1592
-237
No files matched your search
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user