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
@@ -8,6 +8,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
@@ -15,7 +16,18 @@ import 'frame_watchdog.dart';
|
||||
/// [recreate] for "Reload video". Never reuse a player across sessions —
|
||||
/// a GPU-side freeze sticks to the player, not to the widget.
|
||||
class PlaybackController {
|
||||
Player? _player;
|
||||
/// Read-ahead target: about two minutes rides out short outages and
|
||||
/// makes skipping an intro instant.
|
||||
static const readAheadSecs = 150;
|
||||
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
||||
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
||||
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
|
||||
static const maxBytes = 256 * 1024 * 1024;
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
static const maxBackBytes = 64 * 1024 * 1024;
|
||||
|
||||
SyncPlayer? _player;
|
||||
VideoController? _video;
|
||||
Timer? _watchTimer;
|
||||
bool _probing = false;
|
||||
@@ -37,6 +49,13 @@ class PlaybackController {
|
||||
Future<int?> Function()? shownFrame;
|
||||
|
||||
Player get player => _player!;
|
||||
|
||||
/// Seeks from the video controls (seek bar) go here instead of to mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Pinned mpv properties whose read-back differs from what was set
|
||||
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
|
||||
final List<String> pinMismatches = [];
|
||||
bool get _ready => _player != null;
|
||||
|
||||
/// The [VideoController] of the current player. Created lazily by the
|
||||
@@ -64,12 +83,13 @@ class PlaybackController {
|
||||
|
||||
Future<void> init() async {
|
||||
if (_player != null) return;
|
||||
_player = Player(
|
||||
_player = SyncPlayer(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: 64 * 1024 * 1024,
|
||||
bufferSize: maxBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
));
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
await _pinBufferProps();
|
||||
_watchdog.reset();
|
||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||
@@ -94,14 +114,23 @@ class PlaybackController {
|
||||
try {
|
||||
final dynamic platform = player.platform;
|
||||
await platform?.setProperty(property, value);
|
||||
final got = '${await platform?.getProperty(property)}';
|
||||
if (!pinMatches(value, got)) {
|
||||
pinMismatches.add('$property: $value -> $got');
|
||||
debugPrint('pin $property=$value not applied, mpv has "$got"');
|
||||
}
|
||||
} catch (e, st) {
|
||||
pinMismatches.add('$property: $value -> error $e');
|
||||
debugPrint('pin $property=$value failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pinBufferProps() async {
|
||||
await _pin('demuxer-readahead-secs', '10');
|
||||
await _pin('cache-secs', '10');
|
||||
pinMismatches.clear();
|
||||
await _pin('demuxer-readahead-secs', '$readAheadSecs');
|
||||
await _pin('cache-secs', '$readAheadSecs');
|
||||
await _pin('demuxer-max-bytes', '$maxBytes');
|
||||
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
|
||||
final hwdec = _hwdecOverride;
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec);
|
||||
@@ -214,7 +243,8 @@ class PlaybackController {
|
||||
switch (action) {
|
||||
case SeekAction(:final position):
|
||||
if ((positionSecs - position).abs() < 0.5) break;
|
||||
await player.seek(Duration(milliseconds: (position * 1000).round()));
|
||||
await _player!
|
||||
.syncSeek(Duration(milliseconds: (position * 1000).round()));
|
||||
case TransportAction(:final playing):
|
||||
if (playing) {
|
||||
await player.play();
|
||||
@@ -269,3 +299,10 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||
bool pinMatches(String wanted, String got) {
|
||||
if (wanted == got) return true;
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
return a != null && b != null && a == b;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../playback/playback_controller.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
|
||||
@@ -113,7 +114,6 @@ class SessionCoordinator {
|
||||
// Set after a reconnect until the server echoes the restored ready, so
|
||||
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||
bool _restoringReady = false;
|
||||
DateTime? _reassertAt;
|
||||
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||
int _epoch = 0;
|
||||
Timer? _reconnectTimer;
|
||||
@@ -121,10 +121,19 @@ class SessionCoordinator {
|
||||
String? _openUrl;
|
||||
String _wsHost = '';
|
||||
int _wsPort = 0;
|
||||
double _lastPos = 0;
|
||||
double? _pendingSeekTarget;
|
||||
// Server seek applied to the player (event id and when), checked once it
|
||||
// should have landed.
|
||||
int? _pendingSeekEvent;
|
||||
DateTime? _pendingSeekAt;
|
||||
int _seekRetries = 0;
|
||||
int _lastEventId = -1;
|
||||
final UserSeekGate _seekGate = UserSeekGate();
|
||||
Timer? _seekTimer;
|
||||
// True while a media open is in flight: the player's duration may still
|
||||
// belong to the previous media then.
|
||||
bool _opening = false;
|
||||
// Token of the current connection, offered as `resume` on reconnect.
|
||||
String _resumeToken = '';
|
||||
DateTime? _lastStateAt;
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
int _toastId = 0;
|
||||
@@ -140,6 +149,7 @@ class SessionCoordinator {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
} catch (e, st) {
|
||||
@@ -166,12 +176,7 @@ class SessionCoordinator {
|
||||
await playback.recreate();
|
||||
final url = _openUrl;
|
||||
_openUrl = null;
|
||||
_pendingSeekTarget = null;
|
||||
_pendingSeekAt = null;
|
||||
_seekRetries = 0;
|
||||
// New player starts at 0: without this the first tick would read
|
||||
// the drop to 0 as a user seek and send everyone there.
|
||||
_lastPos = 0;
|
||||
_clearPendingSeek();
|
||||
if (url != null) await _openMedia(url);
|
||||
} catch (e, st) {
|
||||
debugPrint('video reload failed: $e\n$st');
|
||||
@@ -212,6 +217,10 @@ class SessionCoordinator {
|
||||
_restoringReady = false;
|
||||
_wasPlayAllowed = false;
|
||||
_everConnected = false;
|
||||
_resumeToken = '';
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_lastEventId = -1;
|
||||
markInput();
|
||||
_subscribe();
|
||||
feedLog.value = [];
|
||||
@@ -233,6 +242,7 @@ class SessionCoordinator {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
_resumeToken = sync.token;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
final url = welcome.mediaUrl;
|
||||
@@ -280,6 +290,7 @@ class SessionCoordinator {
|
||||
if (url == _openUrl) return;
|
||||
_openUrl = url;
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
try {
|
||||
await playback.openUrl(url);
|
||||
health.value =
|
||||
@@ -288,6 +299,8 @@ class SessionCoordinator {
|
||||
debugPrint('open media failed: $e');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +309,12 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
Future<void> _onState(StateBroadcast r) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
_lastStateAt = now;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
_trackSelfReady(r, now);
|
||||
_trackSelfReady(r);
|
||||
_seekGate.observe(r, sync.memberId);
|
||||
final newEvent = r.eventId != _lastEventId;
|
||||
_lastEventId = r.eventId;
|
||||
if (r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
var url = stateMediaUrl(r);
|
||||
@@ -312,14 +327,16 @@ class SessionCoordinator {
|
||||
} else {
|
||||
_openUrl = null;
|
||||
}
|
||||
_pendingSeekTarget = null;
|
||||
_seekRetries = 0;
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
}
|
||||
if (!_playerReady || _openUrl == null) {
|
||||
_wasPlayAllowed = false;
|
||||
_updateStarving(r, 0);
|
||||
return;
|
||||
}
|
||||
// Server clock: serverTime in the state is the server's.
|
||||
final now = sync.serverNow();
|
||||
final allowed = localPlayAllowed(r, sync.memberId);
|
||||
var actions = diff.diff(r, playback.positionSecs, now);
|
||||
if (r.transport == 'playing' && !allowed) {
|
||||
@@ -335,23 +352,28 @@ class SessionCoordinator {
|
||||
SeekAction(edge),
|
||||
const TransportAction(true),
|
||||
];
|
||||
_pendingSeekTarget = edge;
|
||||
_pendingSeekAt = now;
|
||||
}
|
||||
_wasPlayAllowed = allowed;
|
||||
for (final a in actions) {
|
||||
if (a is SeekAction) {
|
||||
_pendingSeekTarget = a.position;
|
||||
_pendingSeekAt = now;
|
||||
}
|
||||
// The user is seeking (or our seek has not echoed yet), or this is the
|
||||
// echo of our own seek arriving: the player is where the user put it.
|
||||
// Later drift corrections within that event still apply.
|
||||
if (_seekGate.holdsPlayer(DateTime.now().toUtc()) ||
|
||||
(newEvent && _seekGate.isOwnEcho(r, sync.memberId))) {
|
||||
actions = [for (final a in actions) if (a is! SeekAction) a];
|
||||
}
|
||||
if (actions.any((a) => a is SeekAction)) {
|
||||
_pendingSeekEvent = r.eventId;
|
||||
_pendingSeekAt = DateTime.now().toUtc();
|
||||
_seekRetries = 0;
|
||||
}
|
||||
await playback.apply(actions);
|
||||
_updateStarving(r, playback.bufferedSecsAhead);
|
||||
}
|
||||
|
||||
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||
/// by others included) and re-enters the set after a stale spell.
|
||||
void _trackSelfReady(StateBroadcast r, DateTime now) {
|
||||
/// by others included). After a stale spell the server puts a ready
|
||||
/// member back in the set by itself.
|
||||
void _trackSelfReady(StateBroadcast r) {
|
||||
final self = selfMember.peek();
|
||||
if (self == null) return;
|
||||
if (r.mediaId != _mediaId) _restoringReady = false;
|
||||
@@ -360,12 +382,6 @@ class SessionCoordinator {
|
||||
} else {
|
||||
_lastReady = self.ready;
|
||||
}
|
||||
if (shouldReassertReady(self) &&
|
||||
(_reassertAt == null ||
|
||||
now.difference(_reassertAt!) >= const Duration(seconds: 3))) {
|
||||
_reassertAt = now;
|
||||
sync.sendReady(true, source: 'auto');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||
@@ -373,6 +389,10 @@ class SessionCoordinator {
|
||||
starving.value = null;
|
||||
return;
|
||||
}
|
||||
if (r.transport == 'finished') {
|
||||
starving.value = 'finished — seek back or change media';
|
||||
return;
|
||||
}
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
||||
starving.value =
|
||||
@@ -474,13 +494,17 @@ class SessionCoordinator {
|
||||
toasts.value =
|
||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
final r = serverState.peek();
|
||||
if (sync.token.isNotEmpty) sync.sendPing();
|
||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||
// Duration only once the open media is the server's current one.
|
||||
final known = !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
sync.sendReport(
|
||||
position: playback.positionSecs,
|
||||
buffered: playback.bufferedSecsAhead,
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
);
|
||||
_detectUserSeek(r);
|
||||
_checkSeekLanded(r);
|
||||
}
|
||||
if (!_manualClose &&
|
||||
@@ -492,8 +516,7 @@ class SessionCoordinator {
|
||||
starving.value = null;
|
||||
_wasPlayAllowed = false;
|
||||
_mediaId = -1;
|
||||
_pendingSeekTarget = null;
|
||||
_seekRetries = 0;
|
||||
_clearPendingSeek();
|
||||
diff.reset();
|
||||
}
|
||||
if (!reconnecting.peek()) {
|
||||
@@ -527,46 +550,70 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _detectUserSeek(StateBroadcast r) {
|
||||
if (_openUrl == null) {
|
||||
_lastPos = playback.positionSecs;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now().toUtc();
|
||||
final pos = playback.positionSecs;
|
||||
final jumped = (pos - _lastPos).abs();
|
||||
_lastPos = pos;
|
||||
if (jumped <= 2.0) return;
|
||||
if ((pos - serverPositionAt(r, now)).abs() < 2.0) return;
|
||||
final recentSync = _pendingSeekAt != null &&
|
||||
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
|
||||
if (recentSync) return;
|
||||
markInput();
|
||||
sync.sendSeek(pos);
|
||||
void _clearPendingSeek() {
|
||||
_pendingSeekEvent = null;
|
||||
_pendingSeekAt = null;
|
||||
_seekRetries = 0;
|
||||
}
|
||||
|
||||
/// A server seek that has not landed after 1.5 s is applied once more,
|
||||
/// to where the server is now (never the old target: it went stale
|
||||
/// while playing). Dropped when a newer event or a user seek took over.
|
||||
Future<void> _checkSeekLanded(StateBroadcast r) async {
|
||||
final target = _pendingSeekTarget;
|
||||
final event = _pendingSeekEvent;
|
||||
final at = _pendingSeekAt;
|
||||
if (target == null || at == null) return;
|
||||
if (DateTime.now().toUtc().difference(at).inMilliseconds < 1500) return;
|
||||
if (event == null || at == null) return;
|
||||
final now = DateTime.now().toUtc();
|
||||
if (event != r.eventId || _seekGate.holdsPlayer(now)) {
|
||||
_clearPendingSeek();
|
||||
return;
|
||||
}
|
||||
if (now.difference(at).inMilliseconds < 1500) return;
|
||||
final target = serverPositionAt(r, sync.serverNow());
|
||||
if (seekLanded(ownPosition: playback.positionSecs, target: target)) {
|
||||
_pendingSeekTarget = null;
|
||||
_pendingSeekAt = null;
|
||||
_seekRetries = 0;
|
||||
_clearPendingSeek();
|
||||
return;
|
||||
}
|
||||
if (_seekRetries < 1) {
|
||||
_seekRetries++;
|
||||
_pendingSeekAt = DateTime.now().toUtc();
|
||||
_pendingSeekAt = now;
|
||||
await playback.apply([SeekAction(target)]);
|
||||
} else {
|
||||
_pendingSeekTarget = null;
|
||||
_pendingSeekAt = null;
|
||||
_seekRetries = 0;
|
||||
_clearPendingSeek();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user seek (seek bar, arrow keys, [seekTo]) lands here: the
|
||||
/// player moves at once (scrub preview), the server hears one `seek`
|
||||
/// once the user stops for [UserSeekGate.debounce].
|
||||
void _userSeek(double position) {
|
||||
if (!position.isFinite || position < 0) return;
|
||||
if (serverState.peek() == null || _openUrl == null) return;
|
||||
markInput();
|
||||
final now = DateTime.now().toUtc();
|
||||
_seekGate.request(position, now);
|
||||
_clearPendingSeek();
|
||||
unawaited(playback.apply([SeekAction(position)]));
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
|
||||
}
|
||||
|
||||
void _flushUserSeek() {
|
||||
_seekTimer = null;
|
||||
final settled = _seekGate.takeSettled(DateTime.now().toUtc());
|
||||
if (settled == null) return;
|
||||
var position = settled.position;
|
||||
// Playing on since the request: send where the player is now, so the
|
||||
// server's clock and ours agree when the echo comes back.
|
||||
final own = playback.positionSecs;
|
||||
if (_playerReady &&
|
||||
playback.player.state.playing &&
|
||||
(own - position).abs() < 2.0) {
|
||||
position = own;
|
||||
}
|
||||
sync.sendSeek(position, seq: settled.seq);
|
||||
}
|
||||
|
||||
void toggleReady() {
|
||||
final self = selfMember.peek();
|
||||
// Ready but out of the set (dropped out while stale) gates like
|
||||
@@ -587,19 +634,17 @@ class SessionCoordinator {
|
||||
sync.sendForce('all', true);
|
||||
}
|
||||
|
||||
void seekTo(double position) {
|
||||
markInput();
|
||||
sync.sendSeek(position);
|
||||
}
|
||||
void seekTo(double position) => _userSeek(position);
|
||||
|
||||
/// Arrow keys: relative to a seek still in flight, so repeated presses
|
||||
/// add up.
|
||||
void nudge(double deltaSecs) {
|
||||
final r = serverState.peek();
|
||||
if (r == null || _openUrl == null) return;
|
||||
final target =
|
||||
serverPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
|
||||
if (target.isNaN || target < 0) return;
|
||||
markInput();
|
||||
sync.sendSeek(target);
|
||||
final base = _seekGate.target ?? serverPositionAt(r, sync.serverNow());
|
||||
final target = base + deltaSecs;
|
||||
if (target.isNaN) return;
|
||||
_userSeek(target < 0 ? 0 : target);
|
||||
}
|
||||
|
||||
void sendChat(String text) {
|
||||
@@ -656,11 +701,13 @@ class SessionCoordinator {
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
hostKey: params.hostKey,
|
||||
resume: _resumeToken,
|
||||
);
|
||||
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||
if (stale()) return;
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
_resumeToken = sync.token;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
final url = welcome.mediaUrl;
|
||||
@@ -739,6 +786,9 @@ class SessionCoordinator {
|
||||
_reconnectTimer = null;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = null;
|
||||
_seekGate.reset();
|
||||
reconnecting.value = false;
|
||||
_playerReady = false;
|
||||
try {
|
||||
|
||||
@@ -19,9 +19,13 @@ class RateAction extends PlayerAction {
|
||||
const RateAction(this.rate);
|
||||
}
|
||||
|
||||
double serverPositionAt(StateBroadcast state, DateTime now) {
|
||||
/// Server position at [serverNow], which must be on the server's clock
|
||||
/// (`SyncClient.serverNow()`, local time corrected by the clock offset):
|
||||
/// `state.serverTime` is stamped by the server.
|
||||
double serverPositionAt(StateBroadcast state, DateTime serverNow) {
|
||||
if (state.transport != 'playing') return state.position;
|
||||
return state.position + now.difference(state.serverTime).inMilliseconds / 1000.0;
|
||||
return state.position +
|
||||
serverNow.difference(state.serverTime).inMicroseconds / 1e6;
|
||||
}
|
||||
|
||||
bool seekLanded({required double ownPosition, required double target}) {
|
||||
@@ -39,12 +43,6 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Self is ready but out of the set and fresh again: the server's stale
|
||||
/// sweep dropped it from the set during a signal gap and nothing puts it
|
||||
/// back. Re-sending ready restores it without the user pressing Space.
|
||||
bool shouldReassertReady(MemberView? self) =>
|
||||
self != null && self.ready && !self.inSet && !self.stale;
|
||||
|
||||
class StateDiff {
|
||||
static const debounceWindow = Duration(seconds: 2);
|
||||
|
||||
@@ -57,6 +55,7 @@ class StateDiff {
|
||||
|
||||
bool get slowing => _slowing;
|
||||
|
||||
/// [now] is server time (see [serverPositionAt]).
|
||||
List<PlayerAction> diff(StateBroadcast state, double ownPosition, DateTime now) {
|
||||
final out = <PlayerAction>[];
|
||||
final serverPos = serverPositionAt(state, now);
|
||||
@@ -73,6 +72,25 @@ class StateDiff {
|
||||
return out;
|
||||
}
|
||||
|
||||
// End of media: everyone stops where they are (at or just before the
|
||||
// end). No seek to the very end and no drift correction.
|
||||
if (state.transport == 'finished') {
|
||||
final first = _lastEventId == -1;
|
||||
final changed = first ||
|
||||
state.eventId != _lastEventId ||
|
||||
state.transport != _lastTransport;
|
||||
_lastEventId = state.eventId;
|
||||
_lastTransport = state.transport;
|
||||
_lastMediaId = state.mediaId;
|
||||
_slowing = false;
|
||||
_deviatingSince = null;
|
||||
if (!changed) return out;
|
||||
out.add(const RateAction(1.0));
|
||||
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
||||
out.add(const TransportAction(false));
|
||||
return out;
|
||||
}
|
||||
|
||||
if (_lastEventId == -1) {
|
||||
_lastEventId = state.eventId;
|
||||
_lastTransport = state.transport;
|
||||
|
||||
@@ -7,6 +7,18 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
||||
|
||||
class SyncClient {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
final DateTime Function() _clock;
|
||||
SyncClient({DateTime Function()? clock})
|
||||
: _clock = clock ?? (() => DateTime.now().toUtc());
|
||||
|
||||
/// Offset to the server's clock, from ping/pong round trips.
|
||||
final ClockSync clockSync = ClockSync();
|
||||
|
||||
/// Now on the server's clock: use this, not the local clock, against
|
||||
/// `serverTime` in a state.
|
||||
DateTime serverNow() => clockSync.serverNow(_clock());
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
@@ -34,8 +46,10 @@ class SyncClient {
|
||||
String avatar = '',
|
||||
HelloMedia? media,
|
||||
String hostKey = '',
|
||||
String resume = '',
|
||||
}) async {
|
||||
await disconnect();
|
||||
clockSync.reset();
|
||||
_status.add(SyncStatus.connecting);
|
||||
final ch = WebSocketChannel.connect(wsUri);
|
||||
unawaited(ch.ready.then((_) {}, onError: (_) {}));
|
||||
@@ -63,6 +77,7 @@ class SyncClient {
|
||||
user: HelloUser(name: name, avatar: avatar),
|
||||
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey,
|
||||
resume: resume,
|
||||
);
|
||||
ch.sink.add(jsonEncode(hello.toJson()));
|
||||
_pendingWelcome = welcomeCompleter;
|
||||
@@ -81,6 +96,7 @@ class SyncClient {
|
||||
} catch (_) {}
|
||||
}
|
||||
_status.add(SyncStatus.open);
|
||||
sendPing();
|
||||
return welcome;
|
||||
} catch (e) {
|
||||
_pendingWelcome = null;
|
||||
@@ -138,6 +154,14 @@ class SyncClient {
|
||||
} catch (_) {
|
||||
_kicked.add('');
|
||||
}
|
||||
case 'pong':
|
||||
try {
|
||||
final pong = PongMessage.fromJson(decoded);
|
||||
clockSync.add(
|
||||
sentMs: pong.clientMs,
|
||||
serverMs: pong.serverMs,
|
||||
receivedMs: _clock().millisecondsSinceEpoch);
|
||||
} catch (_) {}
|
||||
case 'err':
|
||||
_errors.add('${decoded['message'] ?? 'error'}');
|
||||
case 'chat':
|
||||
@@ -170,9 +194,14 @@ class SyncClient {
|
||||
_send(ForceIntent(target: target, ready: ready).toJson());
|
||||
}
|
||||
|
||||
void sendSeek(double position) {
|
||||
void sendSeek(double position, {int seq = 0}) {
|
||||
if (position.isNaN || position < 0) return;
|
||||
_send(SeekIntent(position: position).toJson());
|
||||
_send(SeekIntent(position: position, seq: seq).toJson());
|
||||
}
|
||||
|
||||
/// One clock sample; the coordinator sends one per second.
|
||||
void sendPing() {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
|
||||
void sendSwap(HelloMedia media) {
|
||||
@@ -183,6 +212,8 @@ class SyncClient {
|
||||
required double position,
|
||||
required double buffered,
|
||||
required int eventId,
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
}) {
|
||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||
_send(
|
||||
@@ -190,6 +221,8 @@ class SyncClient {
|
||||
position: position,
|
||||
buffered: safeBuffered,
|
||||
eventId: eventId,
|
||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||
mediaId: mediaId,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
|
||||
/// (and anything else in the video controls) calls `player.seek`; with
|
||||
/// [onUserSeek] set those calls become explicit events for the sync layer
|
||||
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
|
||||
class SyncPlayer extends Player {
|
||||
SyncPlayer({super.configuration});
|
||||
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration duration) async {
|
||||
final hook = onUserSeek;
|
||||
if (hook == null) return super.seek(duration);
|
||||
hook(duration);
|
||||
}
|
||||
|
||||
/// Seek mpv directly (preview while scrubbing, server corrections).
|
||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||
}
|
||||
|
||||
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
||||
/// arrow keys one per press) into one `seek` intent, and tells the
|
||||
/// coordinator when server seeks must not touch the player:
|
||||
///
|
||||
/// - while the user is still seeking ([debounce] since the last request),
|
||||
/// - while our sent seek has no echo yet ([echoTimeout] at most),
|
||||
/// - for the echo of our own seek (`state.seekBy` is us): the player is
|
||||
/// already there, re-seeking would only hitch or yank.
|
||||
class UserSeekGate {
|
||||
final Duration debounce;
|
||||
final Duration echoTimeout;
|
||||
|
||||
UserSeekGate({
|
||||
this.debounce = const Duration(milliseconds: RelayConstants.seekDebounceMs),
|
||||
this.echoTimeout = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
double? _target;
|
||||
DateTime? _requestedAt;
|
||||
int _seq = 0;
|
||||
int _sentSeq = 0;
|
||||
DateTime? _sentAt;
|
||||
|
||||
/// Latest requested position, while a request or its echo is pending.
|
||||
double? get target => _target;
|
||||
|
||||
/// The user asked for [position] at [now].
|
||||
void request(double position, DateTime now) {
|
||||
_target = position;
|
||||
_requestedAt = now;
|
||||
}
|
||||
|
||||
bool scrubbing(DateTime now) =>
|
||||
_requestedAt != null && now.difference(_requestedAt!) < debounce;
|
||||
|
||||
/// The settled request to send, once [debounce] has passed since the last
|
||||
/// one; null otherwise. Returns its seq, which the server echoes.
|
||||
({double position, int seq})? takeSettled(DateTime now) {
|
||||
final t = _target;
|
||||
if (t == null || _requestedAt == null || scrubbing(now)) return null;
|
||||
_requestedAt = null;
|
||||
_sentSeq = ++_seq;
|
||||
_sentAt = now;
|
||||
return (position: t, seq: _sentSeq);
|
||||
}
|
||||
|
||||
bool get _awaitingEcho => _sentAt != null;
|
||||
|
||||
/// Server seeks are ignored while this holds.
|
||||
bool holdsPlayer(DateTime now) {
|
||||
if (_requestedAt != null) return true; // not sent yet
|
||||
final sent = _sentAt;
|
||||
if (sent == null) return false;
|
||||
if (now.difference(sent) >= echoTimeout) {
|
||||
_sentAt = null;
|
||||
_target = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The state's current event is a seek this client sent.
|
||||
bool isOwnEcho(StateBroadcast state, String memberId) {
|
||||
final seq = state.seekSeq;
|
||||
return memberId.isNotEmpty &&
|
||||
state.seekBy == memberId &&
|
||||
seq != null &&
|
||||
seq > 0 &&
|
||||
seq <= _seq;
|
||||
}
|
||||
|
||||
/// Feed every state: the echo of the last sent seek ends the hold.
|
||||
void observe(StateBroadcast state, String memberId) {
|
||||
if (!_awaitingEcho || _requestedAt != null) return;
|
||||
if (isOwnEcho(state, memberId) && state.seekSeq! >= _sentSeq) {
|
||||
_sentAt = null;
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_target = null;
|
||||
_requestedAt = null;
|
||||
_sentAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/sync/state_diff.dart';
|
||||
import 'package:watch_together/src/sync/sync_client.dart';
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('a client whose clock is 3 s off still extrapolates the server position',
|
||||
() async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'clk'));
|
||||
await server.start();
|
||||
const skew = Duration(seconds: 3);
|
||||
final client = SyncClient(clock: () => DateTime.now().toUtc().add(skew));
|
||||
try {
|
||||
await client.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'),
|
||||
secret: '',
|
||||
name: 'skewed');
|
||||
client.sendReady(true);
|
||||
client.sendReport(position: 0, buffered: 10, eventId: 0);
|
||||
await _poll(() => client.lastState?.transport == 'playing', 'playing');
|
||||
for (var i = 0; i < 5; i++) {
|
||||
client.sendPing();
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
await _poll(() => client.clockSync.hasSample, 'a pong');
|
||||
expect(client.clockSync.offsetMs, closeTo(-skew.inMilliseconds, 50));
|
||||
final st = client.lastState!;
|
||||
final truth = server.hub.engine.positionAt();
|
||||
// The bug: extrapolating with the local clock is off by the skew.
|
||||
final naive = serverPositionAt(st, DateTime.now().toUtc().add(skew));
|
||||
expect(naive - truth, closeTo(3, 0.1));
|
||||
// The fix: extrapolate on the server's clock.
|
||||
final corrected = serverPositionAt(st, client.serverNow());
|
||||
expect(corrected, closeTo(truth, 0.1));
|
||||
} finally {
|
||||
await client.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4'; // 13.8 s
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
String what, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'plays through the last 4 s to finished; seek back plays again',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(
|
||||
port: 0, secret: 'eof', name: 'eof', mediaPath: _mediaPath));
|
||||
await server.start();
|
||||
final c = SessionCoordinator();
|
||||
try {
|
||||
await c.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'eof',
|
||||
name: 'solo'));
|
||||
await c.playback.player.setVolume(0);
|
||||
await _poll(() => c.serverState.peek() != null, 'server state');
|
||||
await _poll(
|
||||
() => (c.serverState.peek()?.media?['duration'] as num? ?? 0) > 13,
|
||||
'server learns the duration from reports');
|
||||
c.toggleReady();
|
||||
await _poll(() => c.playback.player.state.playing, 'playing');
|
||||
c.seekTo(7);
|
||||
final transports = <String>[];
|
||||
await _poll(() {
|
||||
transports.add(c.serverState.peek()?.transport ?? '');
|
||||
return c.serverState.peek()?.transport == 'finished';
|
||||
}, 'finished at the end');
|
||||
// ignore: avoid_print
|
||||
print('transports: ${transports.toSet()} '
|
||||
'end at ${c.playback.positionSecs.toStringAsFixed(2)}');
|
||||
expect(transports, isNot(contains('held')),
|
||||
reason: 'never held in the last seconds');
|
||||
expect(c.serverState.peek()!.position, closeTo(13.77, 0.1));
|
||||
await _poll(() => !c.playback.player.state.playing, 'player stopped');
|
||||
expect(c.playback.positionSecs, greaterThan(12));
|
||||
expect(c.starving.peek(), contains('finished'));
|
||||
|
||||
c.seekTo(2);
|
||||
await _poll(
|
||||
() =>
|
||||
c.serverState.peek()?.transport == 'playing' &&
|
||||
c.playback.player.state.playing &&
|
||||
c.playback.positionSecs < 6,
|
||||
'seek back from finished plays again');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/playback/playback_controller.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
/// 240 s at a constant 8 Mbit/s: the bitrate of a 1.4 GB / 24 min episode.
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/readahead_240s.mp4';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-t', '240', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '48',
|
||||
'-b:v', '8M', '-minrate', '8M', '-maxrate', '8M', '-bufsize', '4M',
|
||||
'-x264-params', 'nal-hrd=cbr', '-pix_fmt', 'yuv420p', '-c:a', 'aac',
|
||||
'-movflags', '+faststart', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('pins read back as set', () {
|
||||
expect(pinMatches('150', '150.000000'), isTrue);
|
||||
expect(pinMatches('268435456', '268435456'), isTrue);
|
||||
expect(pinMatches('150', '10.000000'), isFalse);
|
||||
expect(pinMatches('a=1,b=2', 'a=1,b=2'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'read-ahead grows far past 10 s from the server media endpoint',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final media = await _media();
|
||||
final server = SyncServer(AppConfig(
|
||||
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
||||
await server.start();
|
||||
final p = PlaybackController();
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty,
|
||||
reason: 'every pinned mpv property took effect');
|
||||
final token = server.session.issueToken('probe', '');
|
||||
await p.openUrl(
|
||||
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
||||
var best = 0.0;
|
||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (DateTime.now().isBefore(end) && best < 120) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
best = p.bufferedSecsAhead;
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('buffered ahead: ${best.toStringAsFixed(1)} s, '
|
||||
'served ${server.session.totalBytesServed >> 20} MiB');
|
||||
expect(best, greaterThan(120),
|
||||
reason: 'paused player reads ahead about two minutes');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/sync/state_diff.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
/// 120 s, keyframe every second (seeks land fast). Needs an audio track:
|
||||
/// without a video output attached (headless) mpv refuses a video-only file.
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/seek_drag_120s.mp4';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=320x240:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-t', '120', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24',
|
||||
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
String what, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'seek bar drag: one seek to the release point, no yank back',
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final media = await _media();
|
||||
final server = SyncServer(AppConfig(
|
||||
port: 0, secret: 'drag', name: 'drag', mediaPath: media));
|
||||
await server.start();
|
||||
final c = SessionCoordinator();
|
||||
try {
|
||||
await c.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'drag',
|
||||
name: 'solo'));
|
||||
await c.playback.player.setVolume(0);
|
||||
await _poll(() => c.serverState.peek() != null, 'server state');
|
||||
c.toggleReady();
|
||||
await _poll(
|
||||
() => c.playback.player.state.playing && c.playback.positionSecs > 1,
|
||||
'solo playback');
|
||||
|
||||
// What media_kit's MaterialDesktopSeekBar does: player.seek on every
|
||||
// pointer move (here 2.5 s of dragging from 20 s to 80 s), then once
|
||||
// more on release.
|
||||
final positions = <double>[];
|
||||
final sampler = Timer.periodic(const Duration(milliseconds: 100),
|
||||
(_) => positions.add(c.playback.positionSecs));
|
||||
const moves = 130; // ~4 s of dragging
|
||||
for (var i = 0; i <= moves; i++) {
|
||||
final at = 20 + 60 * i / moves;
|
||||
unawaited(c.playback.player.seek(Duration(milliseconds: (at * 1000).round())));
|
||||
await Future.delayed(const Duration(milliseconds: 30));
|
||||
}
|
||||
await c.playback.player.seek(const Duration(seconds: 80));
|
||||
final releasedAt = positions.length;
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
sampler.cancel();
|
||||
|
||||
final seeks = c.feedLog.peek().where((f) => f.action == 'seek').length;
|
||||
final r = c.serverState.peek()!;
|
||||
final serverPos = serverPositionAt(r, DateTime.now().toUtc());
|
||||
final during = positions.sublist(0, releasedAt);
|
||||
final after = positions.sublist(releasedAt);
|
||||
// ignore: avoid_print
|
||||
print('seek feeds=$seeks server=${serverPos.toStringAsFixed(2)} '
|
||||
'local=${c.playback.positionSecs.toStringAsFixed(2)} '
|
||||
'during=${during.map((p) => p.toStringAsFixed(1)).join(' ')} '
|
||||
'after release=${after.map((p) => p.toStringAsFixed(1)).join(' ')}');
|
||||
for (var i = 1; i < during.length; i++) {
|
||||
expect(during[i], greaterThan(during[i - 1] - 2),
|
||||
reason: 'drag never yanked backwards (sample $i)');
|
||||
}
|
||||
expect(seeks, 1, reason: 'one seek per drag, sent on release');
|
||||
expect(serverPos, inInclusiveRange(80, 87),
|
||||
reason: 'server follows the release point');
|
||||
expect((c.playback.positionSecs - serverPos).abs(), lessThan(1.0));
|
||||
for (final p in after) {
|
||||
expect(p, greaterThanOrEqualTo(79), reason: 'no yank back after release');
|
||||
}
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'drag on one client: the other follows with exactly one seek',
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final media = await _media();
|
||||
final server = SyncServer(AppConfig(
|
||||
port: 0, secret: 'drag2', name: 'drag2', mediaPath: media));
|
||||
await server.start();
|
||||
final a = SessionCoordinator();
|
||||
final b = SessionCoordinator();
|
||||
try {
|
||||
for (final (c, name) in [(a, 'a'), (b, 'b')]) {
|
||||
await c.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'drag2',
|
||||
name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
}
|
||||
await _poll(() => a.serverState.peek()?.members.length == 2, 'both in');
|
||||
a.toggleReady();
|
||||
b.toggleReady();
|
||||
await _poll(
|
||||
() => a.playback.player.state.playing && b.playback.player.state.playing,
|
||||
'both playing');
|
||||
final bSamples = <double>[];
|
||||
final sampler = Timer.periodic(const Duration(milliseconds: 100),
|
||||
(_) => bSamples.add(b.playback.positionSecs));
|
||||
for (var i = 0; i <= 100; i++) {
|
||||
unawaited(a.playback.player
|
||||
.seek(Duration(milliseconds: (30000 + 400 * i))));
|
||||
await Future.delayed(const Duration(milliseconds: 30));
|
||||
}
|
||||
await a.playback.player.seek(const Duration(seconds: 70));
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
sampler.cancel();
|
||||
final seeks = b.feedLog.peek().where((f) => f.action == 'seek').length;
|
||||
// ignore: avoid_print
|
||||
print('b saw $seeks seek(s); a=${a.playback.positionSecs.toStringAsFixed(2)} '
|
||||
'b=${b.playback.positionSecs.toStringAsFixed(2)}');
|
||||
expect(seeks, 1);
|
||||
expect(b.playback.positionSecs, greaterThan(70));
|
||||
expect((a.playback.positionSecs - b.playback.positionSecs).abs(),
|
||||
lessThan(1.0));
|
||||
expect(bSamples.where((p) => p > 31 && p < 69), isEmpty,
|
||||
reason: 'b never jumps to mid-drag positions');
|
||||
} finally {
|
||||
await a.dispose();
|
||||
await b.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/session/idle.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/sync/state_diff.dart';
|
||||
|
||||
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
|
||||
MemberView(
|
||||
id: 'm1',
|
||||
name: 'me',
|
||||
avatar: '',
|
||||
ready: ready,
|
||||
inSet: inSet,
|
||||
buffered: 5,
|
||||
stale: stale,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('IdleTracker', () {
|
||||
@@ -104,17 +91,4 @@ void main() {
|
||||
expect(n.contains('SECRET'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldReassertReady', () {
|
||||
test('ready, fresh, out of set (after stale sweep) -> reassert', () {
|
||||
expect(shouldReassertReady(_self(inSet: false)), isTrue);
|
||||
});
|
||||
test('normal states -> no', () {
|
||||
expect(shouldReassertReady(null), isFalse);
|
||||
expect(shouldReassertReady(_self()), isFalse);
|
||||
expect(shouldReassertReady(_self(ready: false, inSet: false)), isFalse);
|
||||
expect(shouldReassertReady(_self(ready: false)), isFalse);
|
||||
expect(shouldReassertReady(_self(inSet: false, stale: true)), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -156,4 +156,39 @@ void main() {
|
||||
expect(localPlayAllowed(state, ''), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('finished', () {
|
||||
test('stops in place: pause, no seek to the end, no drift seeks', () {
|
||||
final diff = StateDiff();
|
||||
final t = DateTime.now().toUtc();
|
||||
diff.diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||
final end = _state(eventId: 2, transport: 'finished', position: 120);
|
||||
final actions = diff.diff(end, 119.4, t);
|
||||
expect(actions.whereType<SeekAction>(), isEmpty);
|
||||
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
||||
// Far off and later: still nothing.
|
||||
expect(diff.diff(end, 50, t.add(const Duration(seconds: 10))), isEmpty);
|
||||
});
|
||||
|
||||
test('late joiner lands just before the end, paused', () {
|
||||
final diff = StateDiff();
|
||||
final actions = diff.diff(
|
||||
_state(eventId: 5, transport: 'finished', position: 120), 0,
|
||||
DateTime.now().toUtc());
|
||||
expect(actions.whereType<SeekAction>().single.position, 119);
|
||||
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('serverPositionAt extrapolates on the server clock', () {
|
||||
final st = StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: 'playing',
|
||||
position: 10,
|
||||
serverTime: DateTime.utc(2026, 1, 1, 12),
|
||||
mediaId: 1,
|
||||
members: const [],
|
||||
);
|
||||
expect(serverPositionAt(st, DateTime.utc(2026, 1, 1, 12, 0, 2, 500)), 12.5);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/sync/user_seek.dart';
|
||||
|
||||
StateBroadcast _state({int eventId = 1, String? seekBy, int? seekSeq}) =>
|
||||
StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: 'playing',
|
||||
position: 0,
|
||||
serverTime: DateTime.utc(2026),
|
||||
mediaId: 1,
|
||||
members: const [],
|
||||
seekBy: seekBy,
|
||||
seekSeq: seekSeq,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final t0 = DateTime.utc(2026, 1, 1);
|
||||
DateTime at(int ms) => t0.add(Duration(milliseconds: ms));
|
||||
|
||||
test('a drag is one seek, sent after the user stops', () {
|
||||
final g = UserSeekGate(debounce: const Duration(milliseconds: 300));
|
||||
for (var i = 0; i < 100; i++) {
|
||||
g.request(20.0 + i, at(i * 30));
|
||||
expect(g.takeSettled(at(i * 30 + 10)), isNull, reason: 'still dragging');
|
||||
expect(g.holdsPlayer(at(i * 30 + 10)), isTrue);
|
||||
}
|
||||
final sent = g.takeSettled(at(99 * 30 + 300));
|
||||
expect(sent!.position, 119.0);
|
||||
expect(sent.seq, 1);
|
||||
expect(g.takeSettled(at(99 * 30 + 600)), isNull, reason: 'sent once');
|
||||
});
|
||||
|
||||
test('holds until the echo of the last seek, then releases', () {
|
||||
final g = UserSeekGate(debounce: const Duration(milliseconds: 300));
|
||||
g.request(50, at(0));
|
||||
final first = g.takeSettled(at(300))!;
|
||||
g.request(60, at(400));
|
||||
final second = g.takeSettled(at(700))!;
|
||||
expect(second.seq, first.seq + 1);
|
||||
// Echo of the older seek: ours, but the hold stays.
|
||||
final old = _state(eventId: 2, seekBy: 'm1', seekSeq: first.seq);
|
||||
expect(g.isOwnEcho(old, 'm1'), isTrue);
|
||||
g.observe(old, 'm1');
|
||||
expect(g.holdsPlayer(at(800)), isTrue);
|
||||
g.observe(_state(eventId: 3, seekBy: 'm1', seekSeq: second.seq), 'm1');
|
||||
expect(g.holdsPlayer(at(900)), isFalse);
|
||||
expect(g.target, isNull);
|
||||
});
|
||||
|
||||
test('someone else\'s seek is not ours', () {
|
||||
final g = UserSeekGate();
|
||||
g.request(5, at(0));
|
||||
g.takeSettled(at(1000));
|
||||
expect(g.isOwnEcho(_state(seekBy: 'm2', seekSeq: 1), 'm1'), isFalse);
|
||||
expect(g.isOwnEcho(_state(), 'm1'), isFalse);
|
||||
});
|
||||
|
||||
test('a lost echo stops holding the player after the timeout', () {
|
||||
final g = UserSeekGate(
|
||||
debounce: const Duration(milliseconds: 300),
|
||||
echoTimeout: const Duration(seconds: 3));
|
||||
g.request(5, at(0));
|
||||
g.takeSettled(at(300));
|
||||
expect(g.holdsPlayer(at(3000)), isTrue);
|
||||
expect(g.holdsPlayer(at(3400)), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user