Rooms drifted in speed and kept pausing on poor links. A 2x long-press or a speed pick on any peer propagated with no attribution, so it read as the app changing speed on its own; a host whose cache starved let mpv resume its player on its own timetable while the room waited, so every recovery re-anchored ahead of the guests and they hard-seeked in a loop; and a wall-clock step on a guest made every host anchor read stale and seeked the file by the size of the step. Rate intent is now declared by the screen (speed sheet, keyboard, long-press, media controls) through WatchTogetherProvider.onLocalRate instead of being inferred from the player's rate stream, matching how seeks already work. The expectation ledger no longer tracks rate at all, so a nudge, a default-speed apply or a late ack can never become a room rate change, and a promoted host adopts the room rate rather than its player's momentary one. Remote rate changes surface as "<name> set the speed to <rate>". The host pauses its own player when it stalls, anchors the room there, raises mpv's cache-pause-wait to 4s, and holds the resume until it has buffered three times the stall it just had. Heartbeats during a sub-grace blip keep extrapolating the previous anchor instead of re-anchoring on a frozen position. The sync layer's clock is monotonic. ClockSync discards its window when a sample's offset moves further than the round trips involved can explain, and a guest that receives an anchor reading implausibly old holds corrections and re-converges instead of seeking.
157 lines
5.0 KiB
Dart
157 lines
5.0 KiB
Dart
import 'dart:async';
|
|
|
|
import '../../utils/app_logger.dart';
|
|
import '../primitives.dart';
|
|
|
|
/// NTP-style clock-offset estimation against the session host (guest side).
|
|
///
|
|
/// Sends pings through [sendPing] (the controller wraps them into sync
|
|
/// messages addressed to the host) and consumes pongs via [onPong]. Keeps a
|
|
/// rolling window of samples and reports the offset of the lowest-RTT sample
|
|
/// — a single clean exchange beats an average polluted by jittery ones.
|
|
///
|
|
/// A sample whose offset disagrees with the current estimate by more than
|
|
/// the round trips involved could explain is a clock discontinuity (either
|
|
/// peer's clock stepped, a device woke from sleep) rather than jitter: the
|
|
/// window is discarded and re-converged, so the estimate steps once instead
|
|
/// of dragging stale samples along for the length of the window.
|
|
///
|
|
/// All time reads go through the injected [nowMs] so tests can virtualize
|
|
/// time alongside `fakeAsync`.
|
|
class ClockSync {
|
|
ClockSync({required this._sendPing, int Function()? nowMs}) : _nowMs = nowMs ?? watchTogetherSystemNowMs;
|
|
|
|
static const int _windowSize = 8;
|
|
static const int _maxAcceptedRttMs = 1000;
|
|
static const Duration _interval = Duration(seconds: 5);
|
|
static const Duration _burstSpacing = Duration(milliseconds: 500);
|
|
static const int _burstCount = 3;
|
|
static const int _pendingExpiryMs = 10000;
|
|
|
|
/// Extra allowance on top of the round-trip bound before a sample counts
|
|
/// as a clock step: covers pong-processing delay on a busy host.
|
|
static const int _jumpSlackMs = 100;
|
|
|
|
final void Function(int pingId) _sendPing;
|
|
final int Function() _nowMs;
|
|
|
|
/// In-flight pings: pingId -> local send time. Multiple may be pending.
|
|
final Map<int, int> _pending = {};
|
|
|
|
/// Accepted samples, oldest first.
|
|
final List<({int offsetMs, int rttMs})> _samples = [];
|
|
|
|
Timer? _timer;
|
|
Timer? _burstTimer;
|
|
bool _started = false;
|
|
|
|
/// How far ahead the host's clock is vs ours, or null before any sample.
|
|
int? get offsetMs => _best?.offsetMs;
|
|
|
|
/// Lowest RTT to the host in the sample window, or null before any sample.
|
|
int? get minRttMs => _best?.rttMs;
|
|
|
|
({int offsetMs, int rttMs})? get _best {
|
|
if (_samples.isEmpty) return null;
|
|
var best = _samples.first;
|
|
for (final sample in _samples.skip(1)) {
|
|
if (sample.rttMs < best.rttMs) best = sample;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/// Local time translated into the host's clock (identity until a sample
|
|
/// arrives — callers needing a guarantee should check [offsetMs]).
|
|
int hostNowMs() => _nowMs() + (offsetMs ?? 0);
|
|
|
|
/// Begin measuring: a short convergence burst, then a steady interval.
|
|
void start() {
|
|
if (_started) return;
|
|
_started = true;
|
|
|
|
var sent = 0;
|
|
_ping();
|
|
sent++;
|
|
_burstTimer = Timer.periodic(_burstSpacing, (timer) {
|
|
if (sent >= _burstCount) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
_ping();
|
|
sent++;
|
|
});
|
|
|
|
_timer = Timer.periodic(_interval, (_) => _ping());
|
|
}
|
|
|
|
void stop() {
|
|
_started = false;
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_burstTimer?.cancel();
|
|
_burstTimer = null;
|
|
_pending.clear();
|
|
}
|
|
|
|
/// Discard every sample and in-flight ping (the peer the offset was
|
|
/// measured against changed — e.g. a host transfer) and re-converge
|
|
/// against the current host with a fresh burst.
|
|
void reset() {
|
|
_samples.clear();
|
|
if (_started) {
|
|
_started = false;
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_burstTimer?.cancel();
|
|
_burstTimer = null;
|
|
_pending.clear();
|
|
start();
|
|
} else {
|
|
_pending.clear();
|
|
}
|
|
}
|
|
|
|
void _ping() {
|
|
final now = _nowMs();
|
|
_pending.removeWhere((_, sentAt) => now - sentAt > _pendingExpiryMs);
|
|
// The ping id doubles as the send timestamp; nudge to keep ids unique
|
|
// when two pings land on the same millisecond.
|
|
var pingId = now;
|
|
while (_pending.containsKey(pingId)) {
|
|
pingId++;
|
|
}
|
|
_pending[pingId] = now;
|
|
_sendPing(pingId);
|
|
}
|
|
|
|
/// Feed a pong from the host. [remoteTimestampMs] is the host's clock when
|
|
/// it created the pong.
|
|
void onPong(int pingId, int remoteTimestampMs) {
|
|
final sentAt = _pending.remove(pingId);
|
|
if (sentAt == null) return; // Not ours or already expired.
|
|
|
|
final now = _nowMs();
|
|
final rtt = now - sentAt;
|
|
if (rtt < 0 || rtt > _maxAcceptedRttMs) {
|
|
appLogger.d('ClockSync: discarding sample with RTT=${rtt}ms');
|
|
return;
|
|
}
|
|
|
|
final offset = remoteTimestampMs - sentAt - (rtt ~/ 2);
|
|
final best = _best;
|
|
if (best != null) {
|
|
// Asymmetric delay can shift an offset by at most half of each round
|
|
// trip; anything beyond both is a clock step, not jitter.
|
|
final tolerance = (rtt + best.rttMs) ~/ 2 + _jumpSlackMs;
|
|
if ((offset - best.offsetMs).abs() > tolerance) {
|
|
appLogger.d('ClockSync: offset jumped ${offset - best.offsetMs}ms (tolerance ${tolerance}ms), re-converging');
|
|
_samples.clear();
|
|
}
|
|
}
|
|
_samples.add((offsetMs: offset, rttMs: rtt));
|
|
if (_samples.length > _windowSize) {
|
|
_samples.removeAt(0);
|
|
}
|
|
}
|
|
}
|