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
@@ -0,0 +1,45 @@
|
||||
/// Estimates how far the server's clock is from ours (NTP style).
|
||||
///
|
||||
/// Each ping/pong round trip gives one sample: the client sent at `t0`, the
|
||||
/// server stamped `s`, the reply arrived at `t3` (client clock). Assuming the
|
||||
/// two legs take equally long, `offset = s - (t0 + t3) / 2` with an error of
|
||||
/// at most `rtt / 2`. The sample with the smallest round trip among the last
|
||||
/// [window] is the most trustworthy, so that one is used.
|
||||
class ClockSync {
|
||||
final int window;
|
||||
final List<({int rttMs, double offsetMs})> _samples = [];
|
||||
|
||||
ClockSync({this.window = 16});
|
||||
|
||||
/// Server clock minus local clock, in ms; 0 until the first sample.
|
||||
double get offsetMs {
|
||||
if (_samples.isEmpty) return 0;
|
||||
var best = _samples.first;
|
||||
for (final s in _samples) {
|
||||
if (s.rttMs < best.rttMs) best = s;
|
||||
}
|
||||
return best.offsetMs;
|
||||
}
|
||||
|
||||
/// Round trip of the sample in use, null without samples.
|
||||
int? get rttMs {
|
||||
if (_samples.isEmpty) return null;
|
||||
return _samples.map((s) => s.rttMs).reduce((a, b) => a < b ? a : b);
|
||||
}
|
||||
|
||||
bool get hasSample => _samples.isNotEmpty;
|
||||
|
||||
/// [sentMs] and [receivedMs] are local epoch ms, [serverMs] the server's.
|
||||
void add({required int sentMs, required int serverMs, required int receivedMs}) {
|
||||
final rtt = receivedMs - sentMs;
|
||||
if (rtt < 0) return;
|
||||
_samples.add((rttMs: rtt, offsetMs: serverMs - (sentMs + receivedMs) / 2));
|
||||
if (_samples.length > window) _samples.removeAt(0);
|
||||
}
|
||||
|
||||
void reset() => _samples.clear();
|
||||
|
||||
/// The server's clock now, given the local clock [local].
|
||||
DateTime serverNow(DateTime local) =>
|
||||
local.add(Duration(microseconds: (offsetMs * 1000).round()));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 5;
|
||||
static const int protoVersion = 6;
|
||||
|
||||
static const int chunkBytes = 256 * 1024;
|
||||
static const int creditWindowChunks = 8;
|
||||
@@ -10,6 +10,10 @@ abstract class RelayConstants {
|
||||
static const double slowDownThresholdSecs = 1.5;
|
||||
static const double slowDownRate = 0.95;
|
||||
static const double minBufferSecs = 4.0;
|
||||
/// Near the end the buffer requirement is what is left minus this, so a
|
||||
/// client slightly ahead of the clock (or whose last packet ends a bit
|
||||
/// early) still passes.
|
||||
static const double endSlackSecs = 2.0;
|
||||
static const double resumeHysteresisSecs = 4.0;
|
||||
|
||||
static const int stateHeartbeatSecs = 1;
|
||||
|
||||
@@ -70,12 +70,17 @@ class HelloMessage {
|
||||
/// The hosting app's own client proves it is the host with this; empty
|
||||
/// for everyone else.
|
||||
final String hostKey;
|
||||
/// Token from this client's previous `welcome`, sent on reconnect: if the
|
||||
/// server still has that member (an unclean drop it has not noticed), the
|
||||
/// connection takes it over instead of leaving a ghost.
|
||||
final String resume;
|
||||
const HelloMessage(
|
||||
{required this.proto,
|
||||
required this.secret,
|
||||
required this.user,
|
||||
required this.media,
|
||||
this.hostKey = ''});
|
||||
this.hostKey = '',
|
||||
this.resume = ''});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'hello',
|
||||
@@ -84,6 +89,7 @@ class HelloMessage {
|
||||
'user': user.toJson(),
|
||||
'media': media.toJson(),
|
||||
if (hostKey.isNotEmpty) 'hostKey': hostKey,
|
||||
if (resume.isNotEmpty) 'resume': resume,
|
||||
};
|
||||
|
||||
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
||||
@@ -97,6 +103,7 @@ class HelloMessage {
|
||||
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
hostKey: (json['hostKey'] as String?) ?? '',
|
||||
resume: (json['resume'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,13 +219,19 @@ class ForceIntent {
|
||||
|
||||
class SeekIntent {
|
||||
final double position;
|
||||
const SeekIntent({required this.position});
|
||||
/// Client-chosen, increasing per connection; echoed in `state.seekSeq` so
|
||||
/// the sender recognises its own seek.
|
||||
final int seq;
|
||||
const SeekIntent({required this.position, this.seq = 0});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'seek', 'position': position};
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'seek', 'position': position, if (seq != 0) 'seq': seq};
|
||||
|
||||
factory SeekIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'seek') throw const ProtocolException('not a seek intent');
|
||||
return SeekIntent(position: finiteDouble(json['position'], 'position'));
|
||||
return SeekIntent(
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
seq: finiteInt(json['seq'], 'seq'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +253,25 @@ class ReportMessage {
|
||||
final double position;
|
||||
final double buffered;
|
||||
final int eventId;
|
||||
const ReportMessage({required this.position, required this.buffered, required this.eventId});
|
||||
/// Length of the open media in seconds, 0 while unknown.
|
||||
final double duration;
|
||||
/// The `mediaId` the client has open; [duration] belongs to it.
|
||||
final int mediaId;
|
||||
const ReportMessage(
|
||||
{required this.position,
|
||||
required this.buffered,
|
||||
required this.eventId,
|
||||
this.duration = 0,
|
||||
this.mediaId = 0});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'report', 'position': position, 'buffered': buffered, 'eventId': eventId};
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'report',
|
||||
'position': position,
|
||||
'buffered': buffered,
|
||||
'eventId': eventId,
|
||||
if (duration > 0) 'duration': duration,
|
||||
if (mediaId != 0) 'mediaId': mediaId,
|
||||
};
|
||||
|
||||
factory ReportMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'report') throw const ProtocolException('not a report');
|
||||
@@ -251,10 +279,40 @@ class ReportMessage {
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||
duration: finiteDouble(json['duration'], 'duration'),
|
||||
mediaId: finiteInt(json['mediaId'], 'mediaId'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clock probe: the client sends its own clock in [clientMs]; the server
|
||||
/// answers with a [PongMessage] carrying it back plus its clock.
|
||||
class PingMessage {
|
||||
final int clientMs;
|
||||
const PingMessage({required this.clientMs});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'ping', 'c': clientMs};
|
||||
|
||||
factory PingMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'ping') throw const ProtocolException('not a ping');
|
||||
return PingMessage(clientMs: finiteInt(json['c'], 'c'));
|
||||
}
|
||||
}
|
||||
|
||||
class PongMessage {
|
||||
final int clientMs;
|
||||
final int serverMs;
|
||||
const PongMessage({required this.clientMs, required this.serverMs});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'pong', 'c': clientMs, 's': serverMs};
|
||||
|
||||
factory PongMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'pong') throw const ProtocolException('not a pong');
|
||||
return PongMessage(
|
||||
clientMs: finiteInt(json['c'], 'c'), serverMs: finiteInt(json['s'], 's'));
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMessage {
|
||||
final String text;
|
||||
const ChatMessage({required this.text});
|
||||
@@ -314,6 +372,10 @@ class StateBroadcast {
|
||||
final int mediaId;
|
||||
final Map<String, Object?>? media;
|
||||
final List<MemberView> members;
|
||||
/// Member id and seq of the seek that made [eventId]; null when the
|
||||
/// current event is not a seek.
|
||||
final String? seekBy;
|
||||
final int? seekSeq;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
required this.transport,
|
||||
@@ -322,6 +384,8 @@ class StateBroadcast {
|
||||
required this.mediaId,
|
||||
this.media,
|
||||
required this.members,
|
||||
this.seekBy,
|
||||
this.seekSeq,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -333,6 +397,8 @@ class StateBroadcast {
|
||||
'mediaId': mediaId,
|
||||
if (media != null) 'media': media,
|
||||
'members': members.map((m) => m.toJson()).toList(),
|
||||
if (seekBy != null) 'seekBy': seekBy,
|
||||
if (seekSeq != null) 'seekSeq': seekSeq,
|
||||
};
|
||||
|
||||
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||
@@ -352,6 +418,8 @@ class StateBroadcast {
|
||||
.map((m) => MemberView.fromJson(Map<String, Object?>.from(m)))
|
||||
.toList()
|
||||
: const [],
|
||||
seekBy: json['seekBy'] as String?,
|
||||
seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'protocol.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
enum Transport { playing, held }
|
||||
enum Transport { playing, held, finished }
|
||||
|
||||
enum Correction { none, slowDown, seek }
|
||||
|
||||
@@ -66,6 +66,10 @@ class SyncEngine {
|
||||
int mediaId = 1;
|
||||
HelloMedia? media;
|
||||
int _nextId = 1;
|
||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||
String? _seekBy;
|
||||
int _seekSeq = 0;
|
||||
int _seekEventId = -1;
|
||||
|
||||
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
|
||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||
@@ -84,6 +88,36 @@ class SyncEngine {
|
||||
bool isStale(EngineMember member, [DateTime? at]) =>
|
||||
(at ?? now).difference(member.lastSeen) > staleAfter;
|
||||
|
||||
/// Media length in seconds, null while nobody has reported it.
|
||||
double? get duration {
|
||||
final d = media?.duration ?? 0;
|
||||
return d > 0 ? d : null;
|
||||
}
|
||||
|
||||
/// Buffer every in-set member needs: [RelayConstants.minBufferSecs], or
|
||||
/// less near the end where that much is not left to buffer.
|
||||
double requiredBufferSecs([DateTime? at]) {
|
||||
final d = duration;
|
||||
const full = RelayConstants.minBufferSecs;
|
||||
if (d == null) return full;
|
||||
final left = d - positionAt(at) - RelayConstants.endSlackSecs;
|
||||
return left.clamp(0.0, full).toDouble();
|
||||
}
|
||||
|
||||
/// Any frame from a member: fresh again, and a ready member the stale
|
||||
/// sweep took out of the set goes back in (ready && !inSet only arises
|
||||
/// from that sweep).
|
||||
void _seen(EngineMember member) {
|
||||
member.lastSeen = now;
|
||||
if (member.ready && !member.inSet) member.inSet = true;
|
||||
}
|
||||
|
||||
/// Heartbeat: stale sweep, end of media.
|
||||
void tick() {
|
||||
sweepStale();
|
||||
evaluate();
|
||||
}
|
||||
|
||||
String applyJoin(String token, String name, String avatar) {
|
||||
final id = 'm${_nextId++}';
|
||||
members[token] =
|
||||
@@ -92,6 +126,14 @@ class SyncEngine {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// A reconnect took over [token]'s member (see `hello.resume`).
|
||||
void applyResume(String token) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
evaluate();
|
||||
}
|
||||
|
||||
String? applyLeave(String token) {
|
||||
final removed = members.remove(token);
|
||||
if (removed == null) return null;
|
||||
@@ -110,7 +152,7 @@ class SyncEngine {
|
||||
void applyReady(String token, bool ready, {String source = 'manual'}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
if (member.ready == ready && member.inSet) {
|
||||
evaluate();
|
||||
return;
|
||||
@@ -153,15 +195,23 @@ class SyncEngine {
|
||||
evaluate();
|
||||
}
|
||||
|
||||
void applySeek(String token, double seekPosition) {
|
||||
void applySeek(String token, double seekPosition, {int seq = 0}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
if (!seekPosition.isFinite || seekPosition < 0) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
position = seekPosition;
|
||||
serverTime = now;
|
||||
eventId++;
|
||||
_seekBy = member.id;
|
||||
_seekSeq = seq;
|
||||
_seekEventId = eventId;
|
||||
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
|
||||
// Seeking back from the end: held, and resumes once the gate passes.
|
||||
if (transport == Transport.finished) {
|
||||
transport = Transport.held;
|
||||
evaluate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
@@ -183,12 +233,19 @@ class SyncEngine {
|
||||
return true;
|
||||
}
|
||||
|
||||
void applyReport(String token, double reportPosition, double buffered, int reportEventId) {
|
||||
void applyReport(String token, double reportPosition, double buffered, int reportEventId,
|
||||
{double duration = 0, int mediaId = 0}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
if (!reportPosition.isFinite) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||
final m = media;
|
||||
// First report of the length wins; only for the media the client has open.
|
||||
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
||||
duration.isFinite && duration > 0) {
|
||||
media = HelloMedia(name: m.name, size: m.size, duration: duration, url: m.url);
|
||||
}
|
||||
if (reportEventId < eventId) return;
|
||||
evaluate();
|
||||
}
|
||||
@@ -221,10 +278,21 @@ class SyncEngine {
|
||||
|
||||
void evaluate() {
|
||||
final t = now;
|
||||
if (transport == Transport.finished) return;
|
||||
final need = requiredBufferSecs(t);
|
||||
if (transport == Transport.playing) {
|
||||
final d = duration;
|
||||
if (d != null && positionAt(t) >= d) {
|
||||
position = d;
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
transport = Transport.finished;
|
||||
feed.add(FeedMessage(actor: 'server', action: 'finished', at: t));
|
||||
return;
|
||||
}
|
||||
final blocked = members.values
|
||||
.where((m) => m.inSet)
|
||||
.any((m) => !m.ready || m.buffered < RelayConstants.minBufferSecs || isStale(m, t));
|
||||
.any((m) => !m.ready || m.buffered < need || isStale(m, t));
|
||||
if (blocked) {
|
||||
position = positionAt(t);
|
||||
serverTime = t;
|
||||
@@ -240,7 +308,7 @@ class SyncEngine {
|
||||
members.values.where((m) => !isStale(m, t)).length;
|
||||
final quorum = inSet.length >= 2 || freshCount <= 1;
|
||||
final allGood = inSet.every(
|
||||
(m) => m.ready && m.buffered >= RelayConstants.minBufferSecs && !isStale(m, t));
|
||||
(m) => m.ready && m.buffered >= need && !isStale(m, t));
|
||||
if (allGood && quorum) {
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
@@ -249,13 +317,18 @@ class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
StateBroadcast broadcast() => StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport == Transport.playing ? 'playing' : 'held',
|
||||
position: position,
|
||||
serverTime: serverTime,
|
||||
mediaId: mediaId,
|
||||
media: media?.toJson(),
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
);
|
||||
StateBroadcast broadcast() {
|
||||
final bySeek = _seekEventId == eventId;
|
||||
return StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport.name,
|
||||
position: position,
|
||||
serverTime: serverTime,
|
||||
mediaId: mediaId,
|
||||
media: media?.toJson(),
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
seekBy: bySeek ? _seekBy : null,
|
||||
seekSeq: bySeek ? _seekSeq : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user