Files
plezy/lib/watch_together/services/clock_sync.dart
T
edde746 0c809491c7 fix(watch-together): declare rate changes to the room and stop the host clock during its own stalls
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.
2026-09-02 12:31:27 +02:00

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);
}
}
}