Server on an isolate, dedicated server binary, room -> server (proto 5)

- serve(Map config, events) is the one server entrypoint: the app
  hosts it via Isolate.spawn (crash surfaced on the Host card, logs
  forwarded as [server]); bin/server.dart is the dedicated server,
  compiled into the bundle as watch-together-server. --headless gone.
- Host identity via a per-hosting hostKey in hello, replacing the
  loopback heuristic.
- Rename room -> server across code, files and wire (t:"state",
  /health name); protocol bumped to 5.
- CI compiles the server binary; isolate smoke tests in server and
  desktop suites. Docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:55:14 +03:00
1 parent d8a0729029
commit 81cac1214d
48 files changed
+1073 -423

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
# apps/desktop
The Flutter desktop app (the only binary users run). Layout and rules:
The Flutter desktop app (what users run; hosting runs the server from
`packages/server` on an isolate). Layout and rules:
[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md). Build and test:
[../../docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
-11
View File
@@ -19,10 +19,6 @@ Future<void> main(List<String> args) async {
exit(exitCode);
}
initLogging();
if (args.contains('--headless')) {
await runHeadless(args);
return;
}
FlutterError.onError = (details) {
debugPrint('FLUTTER ERROR: ${details.exceptionAsString()}\n${details.stack}');
FlutterError.presentError(details);
@@ -67,13 +63,6 @@ Future<void> main(List<String> args) async {
runApp(const WatchTogetherApp());
}
Future<void> runHeadless(List<String> args) async {
final session = SessionState.instance;
await session.startHeadless(args);
await ProcessSignal.sigint.watch().first;
await session.stopServer();
}
/// Close button / Alt+F4: leave gracefully first (others see "left", a
/// hosted server shuts down), bounded so a hung socket can't keep the
/// window open.
@@ -7,7 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import '../sync/room_diff.dart';
import '../sync/state_diff.dart';
import 'frame_watchdog.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created
@@ -39,6 +39,12 @@ class ConnectFlow extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
void showError(ConnectMode mode, CardError error) {
errors[mode] = error;
notices[mode] = null;
_notify();
}
void clearError(ConnectMode mode) {
if (errors[mode] == null && notices[mode] == null) return;
errors[mode] = null;
@@ -33,6 +33,7 @@ class _HomePageState extends State<HomePage> {
late final _media =
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@@ -49,11 +50,21 @@ class _HomePageState extends State<HomePage> {
}
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
});
// Back from a session whose hosted server crashed: say so on the Host card.
_stopCrashWatch = effect(() {
final crash = SessionState.instance.serverCrash.value;
if (crash == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
SessionState.instance.serverCrash.value = null;
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
});
});
}
@override
void dispose() {
_stopKickWatch();
_stopCrashWatch();
_flow.dispose();
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
c.dispose();
@@ -354,7 +354,7 @@ class _SessionScreenState extends State<SessionScreen> {
final coord = SessionState.instance.coordinator;
return SignalBuilder(
builder: (context) {
final r = coord.room.value;
final r = coord.serverState.value;
final waiting = [
for (final m in r?.members ?? [])
if (!m.ready) m,
@@ -648,7 +648,7 @@ class _SheetPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: SignalBuilder(
builder: (context) {
final r = coord.room.value;
final r = coord.serverState.value;
final log = coord.feedLog.value;
final theme = Theme.of(context);
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
@@ -669,7 +669,7 @@ class _SheetPanel extends StatelessWidget {
children: [
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
_StatusChip(playing: playing, hasRoom: r != null),
_StatusChip(playing: playing, hasState: r != null),
],
),
const SizedBox(height: 2),
@@ -780,14 +780,14 @@ class _SheetPanel extends StatelessWidget {
class _StatusChip extends StatelessWidget {
final bool playing;
final bool hasRoom;
const _StatusChip({required this.playing, required this.hasRoom});
final bool hasState;
const _StatusChip({required this.playing, required this.hasState});
@override
Widget build(BuildContext context) {
final Color dot;
final String label;
if (!hasRoom) {
if (!hasState) {
dot = Colors.grey;
label = '…';
} else if (playing) {
+1 -1
View File
@@ -19,7 +19,7 @@ class IdleTracker {
}
/// Called once per tick. Warn and unready only matter while self is
/// ready; while the room plays the ready flag gates nothing.
/// ready; while playing the ready flag gates nothing.
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
if (playing) {
activity(now);
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../playback/playback_controller.dart';
import '../sfx/sfx.dart';
import '../sync/room_diff.dart';
import '../sync/state_diff.dart';
import '../sync/sync_client.dart';
import 'health.dart';
import 'idle.dart';
@@ -42,6 +42,8 @@ class ConnectParams {
final String avatar;
final String mediaPath;
final String linkUrl;
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
final String hostKey;
const ConnectParams({
required this.host,
required this.port,
@@ -50,6 +52,7 @@ class ConnectParams {
this.avatar = '',
this.mediaPath = '',
this.linkUrl = '',
this.hostKey = '',
});
}
@@ -62,8 +65,8 @@ class ToastItem {
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
String? roomMediaUrl(RoomBroadcast room) {
final media = room.media;
String? stateMediaUrl(StateBroadcast state) {
final media = state.media;
if (media == null) return null;
final url = media['url'];
if (url is String && url.isNotEmpty) return url;
@@ -74,11 +77,11 @@ class SessionCoordinator {
final SfxService sfx;
final SyncClient sync = SyncClient();
final PlaybackController playback = PlaybackController();
final RoomDiff diff = RoomDiff();
final StateDiff diff = StateDiff();
final health =
signal(const HealthStatus(HealthSeverity.info, 'idle'));
final room = signal<RoomBroadcast?>(null);
final serverState = signal<StateBroadcast?>(null);
final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false);
@@ -86,7 +89,7 @@ class SessionCoordinator {
final starving = signal<String?>(null);
late final Computed<MemberView?> selfMember = computed(() {
final r = room.value;
final r = serverState.value;
if (r == null || sync.memberId.isEmpty) return null;
for (final m in r.members) {
if (m.id == sync.memberId) return m;
@@ -95,7 +98,7 @@ class SessionCoordinator {
});
Timer? _ticker;
StreamSubscription? _roomSub;
StreamSubscription? _stateSub;
StreamSubscription? _feedSub;
StreamSubscription? _kickSub;
StreamSubscription? _errSub;
@@ -122,7 +125,7 @@ class SessionCoordinator {
double? _pendingSeekTarget;
DateTime? _pendingSeekAt;
int _seekRetries = 0;
DateTime? _lastRoomAt;
DateTime? _lastStateAt;
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
int _toastId = 0;
ConnectParams? _params;
@@ -224,13 +227,14 @@ class SessionCoordinator {
secret: params.secret,
name: params.name,
avatar: params.avatar,
hostKey: params.hostKey,
);
// Whoever bumped the epoch (cancel/leave) also closed the socket.
if (epoch != _epoch) throw const ConnectCancelled();
reconnecting.value = false;
_everConnected = true;
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
_lastRoomAt = DateTime.now().toUtc();
_lastStateAt = DateTime.now().toUtc();
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) {
await _openMedia(url);
@@ -257,7 +261,7 @@ class SessionCoordinator {
}
void _subscribe() {
_roomSub ??= sync.rooms.listen(_onRoom);
_stateSub ??= sync.states.listen(_onState);
_feedSub ??= sync.feeds.listen(_onFeed);
_kickSub ??= sync.kicked.listen(_onKicked);
_errSub ??= sync.errors.listen((msg) {
@@ -291,14 +295,14 @@ class SessionCoordinator {
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
}
Future<void> _onRoom(RoomBroadcast r) async {
Future<void> _onState(StateBroadcast r) async {
final now = DateTime.now().toUtc();
_lastRoomAt = now;
room.value = r;
_lastStateAt = now;
serverState.value = r;
_trackSelfReady(r, now);
if (r.mediaId != _mediaId) {
_mediaId = r.mediaId;
var url = roomMediaUrl(r);
var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
final name = (r.media?['name'] as String?) ?? '';
if (name.isNotEmpty) url = _fileUrlFor(name);
@@ -325,7 +329,7 @@ class SessionCoordinator {
];
}
if (allowed && !_wasPlayAllowed && r.transport == 'playing') {
final edge = roomPositionAt(r, now);
final edge = serverPositionAt(r, now);
actions = [
for (final a in actions) if (a is! TransportAction) a,
SeekAction(edge),
@@ -347,7 +351,7 @@ class SessionCoordinator {
/// 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(RoomBroadcast r, DateTime now) {
void _trackSelfReady(StateBroadcast r, DateTime now) {
final self = selfMember.peek();
if (self == null) return;
if (r.mediaId != _mediaId) _restoringReady = false;
@@ -364,7 +368,7 @@ class SessionCoordinator {
}
}
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
void _updateStarving(StateBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') {
starving.value = null;
return;
@@ -418,7 +422,7 @@ class SessionCoordinator {
if (kind != null) {
var readyIndex = 0;
if (f.action == 'ready') {
readyIndex = room.peek()?.members.where((m) => m.ready).length ?? 0;
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
}
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
}
@@ -469,7 +473,7 @@ class SessionCoordinator {
final now = DateTime.now().toUtc();
toasts.value =
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
final r = room.peek();
final r = serverState.peek();
if (sync.token.isNotEmpty && r != null && _playerReady) {
sync.sendReport(
position: playback.positionSecs,
@@ -481,10 +485,10 @@ class SessionCoordinator {
}
if (!_manualClose &&
kickReason.peek() == null &&
_lastRoomAt != null &&
now.difference(_lastRoomAt!).inSeconds >= 5) {
if (room.peek() != null) {
room.value = null;
_lastStateAt != null &&
now.difference(_lastStateAt!).inSeconds >= 5) {
if (serverState.peek() != null) {
serverState.value = null;
starving.value = null;
_wasPlayAllowed = false;
_mediaId = -1;
@@ -502,7 +506,7 @@ class SessionCoordinator {
// PLAYING: the ready flag gates nothing then, and watching counts as
// activity so a pause after a long watch starts a fresh idle clock.
final event = _idle.tick(now,
playing: room.peek()?.transport == 'playing',
playing: serverState.peek()?.transport == 'playing',
selfReady: selfMember.peek()?.ready ?? false);
switch (event) {
case IdleEvent.warn:
@@ -523,7 +527,7 @@ class SessionCoordinator {
}
}
void _detectUserSeek(RoomBroadcast r) {
void _detectUserSeek(StateBroadcast r) {
if (_openUrl == null) {
_lastPos = playback.positionSecs;
return;
@@ -533,7 +537,7 @@ class SessionCoordinator {
final jumped = (pos - _lastPos).abs();
_lastPos = pos;
if (jumped <= 2.0) return;
if ((pos - roomPositionAt(r, now)).abs() < 2.0) return;
if ((pos - serverPositionAt(r, now)).abs() < 2.0) return;
final recentSync = _pendingSeekAt != null &&
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
if (recentSync) return;
@@ -541,7 +545,7 @@ class SessionCoordinator {
sync.sendSeek(pos);
}
Future<void> _checkSeekLanded(RoomBroadcast r) async {
Future<void> _checkSeekLanded(StateBroadcast r) async {
final target = _pendingSeekTarget;
final at = _pendingSeekAt;
if (target == null || at == null) return;
@@ -589,10 +593,10 @@ class SessionCoordinator {
}
void nudge(double deltaSecs) {
final r = room.peek();
final r = serverState.peek();
if (r == null || _openUrl == null) return;
final target =
roomPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
serverPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
if (target.isNaN || target < 0) return;
markInput();
sync.sendSeek(target);
@@ -651,12 +655,13 @@ class SessionCoordinator {
secret: params.secret,
name: params.name,
avatar: params.avatar,
hostKey: params.hostKey,
);
// Whoever bumped the epoch also closed (or replaced) the socket.
if (stale()) return;
reconnecting.value = false;
_everConnected = true;
_lastRoomAt = DateTime.now().toUtc();
_lastStateAt = DateTime.now().toUtc();
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty && url != _openUrl) {
@@ -743,7 +748,7 @@ class SessionCoordinator {
await sync.disconnect();
} catch (_) {}
if (!silent) {
room.value = null;
serverState.value = null;
starving.value = null;
health.value = const HealthStatus(HealthSeverity.info, 'idle');
}
@@ -751,7 +756,7 @@ class SessionCoordinator {
Future<void> dispose() async {
await leave(silent: true);
await _roomSub?.cancel();
await _stateSub?.cancel();
await _feedSub?.cancel();
await _kickSub?.cancel();
await _errSub?.cancel();
@@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
serve(args.$1, events: args.$2);
/// The hosted server, running [serve] on its own isolate. Its log lines
/// go to [onLog]; if the isolate dies without being asked to stop,
/// [onCrash] gets the cause once.
class ServerIsolate {
final Isolate _isolate;
final SendPort _stop;
final int port;
final ReceivePort _events;
final ReceivePort _errors;
final ReceivePort _exit;
final Completer<void> _exited = Completer<void>();
bool _stopping = false;
String? _lastError;
void Function(String error)? _onCrash;
ServerIsolate._(this._isolate, this._stop, this.port, this._events,
this._errors, this._exit);
@visibleForTesting
Isolate get isolate => _isolate;
bool get isRunning => !_exited.isCompleted;
/// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start.
static Future<ServerIsolate> start(
AppConfig config, {
required void Function(String line) onLog,
required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15),
}) async {
final events = ReceivePort('server events');
final errors = ReceivePort('server errors');
final exit = ReceivePort('server exit');
final startup = Completer<(int, SendPort)>();
String? lastError;
var exited = false;
ServerIsolate? self;
void fail(String message) {
if (!startup.isCompleted) startup.completeError(StateError(message));
}
events.listen((msg) {
if (msg is! Map) return;
switch (msg['t']) {
case 'log':
final err = msg['error'];
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
'${err == null ? '' : ' ($err)'}');
case 'ready':
if (!startup.isCompleted) {
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
}
case 'error':
fail('${msg['message']}');
}
});
// errorsAreFatal is off: a stray async error is logged and the server
// keeps going, as it did in-process. Only a dead isolate is a crash.
errors.listen((msg) {
final text = msg is List && msg.isNotEmpty ? '${msg.first}' : '$msg';
lastError = text;
self?._lastError = text;
onLog('SEVERE uncaught: $text');
});
exit.listen((_) {
exited = true;
fail(lastError ?? 'server exited before it was ready');
self?._onExit();
});
final Isolate isolate;
try {
isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort),
errorsAreFatal: false,
onError: errors.sendPort,
onExit: exit.sendPort,
debugName: 'server');
} catch (_) {
events.close();
errors.close();
exit.close();
rethrow;
}
final (int, SendPort) ready;
try {
ready = await startup.future.timeout(timeout);
} catch (e) {
if (!exited) isolate.kill(priority: Isolate.immediate);
events.close();
errors.close();
exit.close();
if (e is TimeoutException) {
throw StateError('server did not start within ${timeout.inSeconds} s');
}
rethrow;
}
final s = ServerIsolate._(isolate, ready.$2, ready.$1, events, errors, exit)
.._onCrash = onCrash
.._lastError = lastError;
self = s;
if (exited) {
// Died between ready and here: a failed start, not a crash.
s._stopping = true;
s._onExit();
throw StateError(lastError ?? 'server exited right after starting');
}
return s;
}
void _onExit() {
if (_exited.isCompleted) return;
_exited.complete();
_events.close();
_errors.close();
_exit.close();
final crash = _onCrash;
_onCrash = null;
if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly');
}
/// Asks the server to close its sockets and exit; kills the isolate if it
/// has not exited within [timeout].
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
if (_exited.isCompleted) return;
_stopping = true;
_stop.send({'t': 'stop'});
try {
await _exited.future.timeout(timeout);
} on TimeoutException {
_isolate.kill(priority: Isolate.immediate);
await _exited.future.timeout(const Duration(seconds: 2),
onTimeout: _onExit);
}
}
}
+61 -23
View File
@@ -1,16 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import '../sfx/sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
import 'server_isolate.dart';
enum AppRoute {
home,
@@ -128,6 +129,21 @@ List<RecentServer> migrateRecents(
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
}
/// The hosted server died during setup; shown on the Host card.
class ServerCrashed implements Exception {
final String cause;
const ServerCrashed(this.cause);
@override
String toString() => 'server crashed: $cause';
}
/// A fresh per-hosting key: the app's own client sends it in hello to be
/// recognised as the (unkickable) host.
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
}
class SessionState {
static final SessionState instance = SessionState._();
SessionState._();
@@ -135,11 +151,17 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final room = signal<RoomBroadcast?>(null);
final SfxService sfx = const NoopSfxService();
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
RoomServer? _server;
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
ServerIsolate? _server;
String? _setupCrash;
@visibleForTesting
ServerIsolate? get hostedServer => _server;
void Function()? _stopKickWatch;
// Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting.
@@ -208,18 +230,25 @@ class SessionState {
const HealthStatus(HealthSeverity.info, 'starting server…');
await stopServer();
if (op != _op) throw const ConnectCancelled();
final server = RoomServer(AppConfig(
port: params.port,
secret: params.secret,
name: 'room',
mediaPath: params.mediaPath,
));
final hostKey = newHostKey();
_setupCrash = null;
late final ServerIsolate server;
try {
await server.start();
server = await ServerIsolate.start(
AppConfig(
port: params.port,
secret: params.secret,
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
),
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
);
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
status.value = HealthStatus(
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
rethrow;
}
if (op != _op) {
@@ -228,20 +257,23 @@ class SessionState {
}
_server = server;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
port: server.port,
secret: params.secret,
name: params.name,
avatar: params.avatar,
linkUrl: params.linkUrl,
hostKey: hostKey,
));
if (op != _op) throw const ConnectCancelled();
} catch (_) {
} catch (e) {
// Own join failed or was cancelled: don't leave the port open.
if (identical(_server, server)) await stopServer();
final crash = _setupCrash;
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow;
}
_watchKick();
@@ -251,6 +283,21 @@ class SessionState {
}
}
/// The server isolate died without being stopped. In a session: show it
/// and leave. During setup the own join fails and [host] reports it.
void _onServerCrash(ServerIsolate server, String cause) {
if (!identical(_server, server)) return;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
if (route.peek() != AppRoute.session) {
_setupCrash = cause;
return;
}
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
serverCrash.value = message;
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
}
Future<void> join(ConnectParams params, ConnectForm form) async {
final op = ++_op;
_rememberForm(form);
@@ -305,15 +352,6 @@ class SessionState {
..linkUrl = form.linkUrl;
}
Future<void> startHeadless(List<String> args) async {
int port = 8080;
for (var i = 0; i < args.length - 1; i++) {
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
}
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
await _server!.start();
}
Future<void> stopServer() async {
_stopKickWatch?.call();
_stopKickWatch = null;
@@ -19,9 +19,9 @@ class RateAction extends PlayerAction {
const RateAction(this.rate);
}
double roomPositionAt(RoomBroadcast room, DateTime now) {
if (room.transport != 'playing') return room.position;
return room.position + now.difference(room.serverTime).inMilliseconds / 1000.0;
double serverPositionAt(StateBroadcast state, DateTime now) {
if (state.transport != 'playing') return state.position;
return state.position + now.difference(state.serverTime).inMilliseconds / 1000.0;
}
bool seekLanded({required double ownPosition, required double target}) {
@@ -31,9 +31,9 @@ bool seekLanded({required double ownPosition, required double target}) {
return true;
}
bool localPlayAllowed(RoomBroadcast room, String memberId) {
bool localPlayAllowed(StateBroadcast state, String memberId) {
if (memberId.isEmpty) return false;
for (final m in room.members) {
for (final m in state.members) {
if (m.id == memberId) return m.inSet && m.ready;
}
return false;
@@ -45,7 +45,7 @@ bool localPlayAllowed(RoomBroadcast room, String memberId) {
bool shouldReassertReady(MemberView? self) =>
self != null && self.ready && !self.inSet && !self.stale;
class RoomDiff {
class StateDiff {
static const debounceWindow = Duration(seconds: 2);
int _lastEventId = -1;
@@ -57,14 +57,14 @@ class RoomDiff {
bool get slowing => _slowing;
List<PlayerAction> diff(RoomBroadcast room, double ownPosition, DateTime now) {
List<PlayerAction> diff(StateBroadcast state, double ownPosition, DateTime now) {
final out = <PlayerAction>[];
final roomPos = roomPositionAt(room, now);
final serverPos = serverPositionAt(state, now);
if (room.mediaId != _lastMediaId && _lastMediaId != -1) {
_lastEventId = room.eventId;
_lastTransport = room.transport;
_lastMediaId = room.mediaId;
if (state.mediaId != _lastMediaId && _lastMediaId != -1) {
_lastEventId = state.eventId;
_lastTransport = state.transport;
_lastMediaId = state.mediaId;
_slowing = false;
_deviatingSince = null;
out.add(const SeekAction(0));
@@ -74,39 +74,39 @@ class RoomDiff {
}
if (_lastEventId == -1) {
_lastEventId = room.eventId;
_lastTransport = room.transport;
_lastMediaId = room.mediaId;
_lastEventId = state.eventId;
_lastTransport = state.transport;
_lastMediaId = state.mediaId;
_deviatingSince = null;
out.add(SeekAction(roomPos));
out.add(TransportAction(room.transport == 'playing'));
out.add(SeekAction(serverPos));
out.add(TransportAction(state.transport == 'playing'));
return out;
}
if (room.eventId != _lastEventId) {
_lastEventId = room.eventId;
if (state.eventId != _lastEventId) {
_lastEventId = state.eventId;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(SeekAction(roomPos));
if (room.transport != _lastTransport) {
_lastTransport = room.transport;
out.add(TransportAction(room.transport == 'playing'));
out.add(SeekAction(serverPos));
if (state.transport != _lastTransport) {
_lastTransport = state.transport;
out.add(TransportAction(state.transport == 'playing'));
}
return out;
}
if (room.transport != _lastTransport) {
_lastTransport = room.transport;
if (state.transport != _lastTransport) {
_lastTransport = state.transport;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(TransportAction(room.transport == 'playing'));
out.add(SeekAction(roomPos));
out.add(TransportAction(state.transport == 'playing'));
out.add(SeekAction(serverPos));
return out;
}
final drift = ownPosition - roomPos;
final drift = ownPosition - serverPos;
if (_slowing) {
if (drift.abs() < 0.1) {
_slowing = false;
@@ -114,10 +114,10 @@ class RoomDiff {
}
return out;
}
switch (correctionFor(ownPosition: ownPosition, roomPosition: roomPos)) {
switch (correctionFor(ownPosition: ownPosition, serverPosition: serverPos)) {
case Correction.seek:
if (_deviatingEvent != room.eventId || _deviatingSince == null) {
_deviatingEvent = room.eventId;
if (_deviatingEvent != state.eventId || _deviatingSince == null) {
_deviatingEvent = state.eventId;
_deviatingSince = now;
break;
}
@@ -125,7 +125,7 @@ class RoomDiff {
break;
}
_deviatingSince = null;
out.add(SeekAction(roomPos));
out.add(SeekAction(serverPos));
case Correction.slowDown:
_deviatingSince = null;
_slowing = true;
+13 -11
View File
@@ -9,13 +9,13 @@ enum SyncStatus { idle, connecting, open, kicked, closed, error }
class SyncClient {
WebSocketChannel? _channel;
StreamSubscription? _sub;
final StreamController<RoomBroadcast> _rooms = StreamController.broadcast();
final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<String> _kicked = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<RoomBroadcast> get rooms => _rooms.stream;
Stream<StateBroadcast> get states => _states.stream;
Stream<FeedMessage> get feeds => _feeds.stream;
Stream<String> get kicked => _kicked.stream;
Stream<String> get errors => _errors.stream;
@@ -24,7 +24,7 @@ class SyncClient {
String token = '';
String memberId = '';
String? mediaUrl;
RoomBroadcast? lastRoom;
StateBroadcast? lastState;
Completer<WelcomeMessage>? _pendingWelcome;
Future<WelcomeMessage> connect({
@@ -33,6 +33,7 @@ class SyncClient {
required String name,
String avatar = '',
HelloMedia? media,
String hostKey = '',
}) async {
await disconnect();
_status.add(SyncStatus.connecting);
@@ -61,6 +62,7 @@ class SyncClient {
secret: secret,
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey,
);
ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter;
@@ -74,8 +76,8 @@ class SyncClient {
mediaUrl = welcome.mediaUrl;
if (welcome.state != null) {
try {
lastRoom = RoomBroadcast.fromJson(welcome.state!);
_rooms.add(lastRoom!);
lastState = StateBroadcast.fromJson(welcome.state!);
_states.add(lastState!);
} catch (_) {}
}
_status.add(SyncStatus.open);
@@ -113,11 +115,11 @@ class SyncClient {
return;
}
switch (t) {
case 'room':
case 'state':
try {
final room = RoomBroadcast.fromJson(decoded);
lastRoom = room;
_rooms.add(room);
final state = StateBroadcast.fromJson(decoded);
lastState = state;
_states.add(state);
} catch (_) {}
case 'feed':
try {
@@ -219,12 +221,12 @@ class SyncClient {
token = '';
memberId = '';
mediaUrl = null;
lastRoom = null;
lastState = null;
}
Future<void> dispose() async {
await disconnect();
await _rooms.close();
await _states.close();
await _feeds.close();
await _kicked.close();
await _errors.close();
+15 -14
View File
@@ -25,11 +25,11 @@ Future<void> _poll(
void main() {
test(
'dead server clears room state and reconnects instead of freezing',
'dead server clears server state and reconnects instead of freezing',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(
final server = SyncServer(
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
await server.start();
final coord = SessionCoordinator();
@@ -41,7 +41,7 @@ void main() {
name: 'watcher',
));
await coord.playback.player.setVolume(0);
await _poll(() => coord.room.peek() != null, 'room state to arrive');
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
expect(coord.reconnecting.peek(), isFalse);
await server.stop();
@@ -50,8 +50,8 @@ void main() {
'reconnect loop to engage',
);
await _poll(
() => coord.room.peek() == null,
'stale room (ghost members) to clear',
() => coord.serverState.peek() == null,
'stale state (ghost members) to clear',
);
coord.cancelReconnect();
expect(coord.reconnecting.peek(), isFalse);
@@ -66,12 +66,12 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = RoomServer(
final first = SyncServer(
const AppConfig(port: 0, secret: 'race', name: 'race'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
RoomServer? second;
SyncServer? second;
final observer = SyncClient();
try {
await coord.connect(ConnectParams(
@@ -79,7 +79,7 @@ void main() {
await coord.playback.player.setVolume(0);
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = RoomServer(
second = SyncServer(
AppConfig(port: port, secret: 'race', name: 'race'));
await second.start();
var joins = 0;
@@ -93,7 +93,7 @@ void main() {
// Within the 1.5 s delay: the first attempt must die with Cancel.
coord.cancelReconnect();
coord.retry();
await _poll(() => !coord.reconnecting.peek() && coord.room.peek() != null,
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
'reconnect to land');
await Future.delayed(const Duration(seconds: 4));
await sub.cancel();
@@ -116,13 +116,13 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = RoomServer(
final first = SyncServer(
const AppConfig(port: 0, secret: 'force', name: 'force'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
final friend = SyncClient();
RoomServer? second;
SyncServer? second;
try {
await coord.connect(ConnectParams(
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
@@ -138,7 +138,7 @@ void main() {
await friend.disconnect();
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = RoomServer(
second = SyncServer(
AppConfig(port: port, secret: 'force', name: 'force'));
await second.start();
await _poll(
@@ -158,7 +158,7 @@ void main() {
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(
final server = SyncServer(
const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
final coord = SessionCoordinator();
@@ -185,7 +185,8 @@ void main() {
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
Hive.init('/home/foxyz/.claude/jobs/4898998e/tmp/hive_coord_test');
Hive.init(
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_coord_test');
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = probe.port;
await probe.close();
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart';
Future<void> _poll(
FutureOr<bool> Function() cond,
String what, {
Duration timeout = const Duration(seconds: 15),
}) 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');
}
/// A bare WebSocket viewer: no player, just the wire.
class _Raw {
final WebSocket ws;
final msgs = <Map<String, Object?>>[];
_Raw(this.ws) {
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
}
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
r.ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
await r.next('welcome');
return r;
}
Future<Map<String, Object?>> next(String type, {bool Function(Map<String, Object?>)? where}) async {
late Map<String, Object?> found;
await _poll(() {
for (final m in msgs) {
if (m['t'] == type && (where == null || where(m))) {
found = m;
msgs.remove(m);
return true;
}
}
return false;
}, '$type from server');
return found;
}
}
Future<int> _freePort() async {
final s = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = s.port;
await s.close();
return port;
}
void main() {
test('server isolate: logs, raw join, crash reported, stop frees port',
timeout: const Timeout(Duration(minutes: 1)), () async {
final logs = <String>[];
final crashes = <String>[];
final server = await ServerIsolate.start(
const AppConfig(port: 0, secret: '', name: 'iso'),
onLog: logs.add,
onCrash: crashes.add);
final viewer = await _Raw.join(server.port, 'viewer');
await viewer.next('state');
await _poll(() => logs.any((l) => l.contains('join viewer')), 'join log');
await viewer.ws.close();
server.isolate.kill(priority: Isolate.immediate);
await _poll(() => crashes.isNotEmpty, 'crash callback');
expect(server.isRunning, isFalse);
await server.stop(); // no-op on a dead isolate
final clean = await ServerIsolate.start(
const AppConfig(port: 0, secret: '', name: 'iso'),
onLog: (_) {},
onCrash: crashes.add);
final port = clean.port;
await clean.stop();
expect(crashes, hasLength(1), reason: 'a requested stop is not a crash');
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close();
});
test('bind failure surfaces as a start error',
timeout: const Timeout(Duration(seconds: 30)), () async {
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
try {
await expectLater(
ServerIsolate.start(AppConfig(port: blocker.port, secret: '', name: 'x'),
onLog: (_) {}, onCrash: (_) {}),
throwsA(isA<StateError>()));
} finally {
await blocker.close();
}
});
test('host path: isolate server, host unkickable, crash leaves the session',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
Hive.init(
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_isolate_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
try {
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
await session.coordinator.playback.player.setVolume(0);
friend = await _Raw.join(port, 'friend', secret: 'pw');
final state = await friend.next('state',
where: (m) => (m['members'] as List).any((x) => (x as Map)['name'] == 'host'));
final hostId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id'];
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
final err = await friend.next('err');
expect(err['message'], "can't kick the host");
// The host can kick the friend (plain loopback peers are not hosts).
final friendId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.kick('$friendId');
await friend.next('kicked');
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
expect(session.serverCrash.peek(), startsWith('server crashed'));
expect(session.hostedServer, isNull);
// The killed isolate's socket stays bound in-process; hosting again on
// the same port must still work.
session.serverCrash.value = null;
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
final again = await _Raw.join(port, 'again', secret: 'pw');
await again.ws.close();
await session.leave();
expect(session.hostedServer, isNull);
} finally {
await friend?.ws.close();
await session.coordinator.leave(silent: true);
await session.stopServer();
}
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
MemberView(
@@ -1,15 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/sync/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
RoomBroadcast _room({
StateBroadcast _state({
int eventId = 0,
String transport = 'held',
double position = 10.0,
int mediaId = 1,
}) {
final now = DateTime.now().toUtc();
return RoomBroadcast(
return StateBroadcast(
eventId: eventId,
transport: transport,
position: position,
@@ -19,10 +19,10 @@ RoomBroadcast _room({
);
}
RoomBroadcast _roomWithMembers(List<MemberView> members,
StateBroadcast _stateWithMembers(List<MemberView> members,
{String transport = 'playing'}) {
final now = DateTime.now().toUtc();
return RoomBroadcast(
return StateBroadcast(
eventId: 3,
transport: transport,
position: 10.0,
@@ -33,50 +33,50 @@ RoomBroadcast _roomWithMembers(List<MemberView> members,
}
void main() {
test('initial diff seeks to room edge and sets transport', () {
final diff = RoomDiff();
final actions = diff.diff(_room(), 0, DateTime.now().toUtc());
test('initial diff seeks to server edge and sets transport', () {
final diff = StateDiff();
final actions = diff.diff(_state(), 0, DateTime.now().toUtc());
expect(actions.whereType<SeekAction>().single.position, closeTo(10.0, 1.0));
expect(actions.whereType<TransportAction>().single.playing, isFalse);
});
test('same event small drift does nothing', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
final actions = diff.diff(_room(), 10.5, now);
diff.diff(_state(), 0, now);
final actions = diff.diff(_state(), 10.5, now);
expect(actions, isEmpty);
});
test('ahead drift latches slowdown, resets inside 0.1', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
var actions = diff.diff(_room(), 13.0, now);
diff.diff(_state(), 0, now);
var actions = diff.diff(_state(), 13.0, now);
expect(actions.whereType<RateAction>().single.rate, 0.95);
actions = diff.diff(_room(), 12.0, now);
actions = diff.diff(_state(), 12.0, now);
expect(actions, isEmpty);
actions = diff.diff(_room(), 10.05, now);
actions = diff.diff(_state(), 10.05, now);
expect(actions.whereType<RateAction>().single.rate, 1.0);
});
test('eventId jump seeks and resets rate', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
diff.diff(_room(), 13.0, now);
diff.diff(_state(), 0, now);
diff.diff(_state(), 13.0, now);
final actions =
diff.diff(_room(eventId: 1, position: 90.0), 13.0, now);
diff.diff(_state(eventId: 1, position: 90.0), 13.0, now);
expect(actions.whereType<SeekAction>().single.position, closeTo(90.0, 1.0));
expect(actions.whereType<RateAction>().single.rate, 1.0);
});
test('media swap seeks zero and pauses', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(transport: 'playing'), 10.0, now);
diff.diff(_state(transport: 'playing'), 10.0, now);
final actions = diff.diff(
_room(eventId: 2, transport: 'held', position: 0, mediaId: 2),
_state(eventId: 2, transport: 'held', position: 0, mediaId: 2),
50.0,
now);
expect(actions.whereType<SeekAction>().single.position, 0);
@@ -84,25 +84,25 @@ void main() {
});
group('seek debounce while playing', () {
RoomBroadcast playing({int eventId = 5, double position = 100.0}) => _room(
StateBroadcast playing({int eventId = 5, double position = 100.0}) => _state(
eventId: eventId, transport: 'playing', position: position);
test('fresh big drift is swallowed first, seeks after window', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(playing(), 100.0, t0);
var actions = diff.diff(playing(), 120.0, t0);
expect(actions.whereType<SeekAction>(), isEmpty,
reason: 'user scrub must not be yanked back before detector runs');
actions = diff.diff(
playing(), 120.0, t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
playing(), 120.0, t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
expect(actions.whereType<SeekAction>().single.position,
closeTo(102.0, 3.0),
reason: 'genuine drift still converges after the window');
});
test('new eventId seeks immediately (echo of promoted seek)', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(playing(), 100.0, t0);
diff.diff(playing(), 120.0, t0);
@@ -112,13 +112,13 @@ void main() {
});
test('held transport debounces the same way (detector promotes)', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(_room(), 10.0, t0);
var actions = diff.diff(_room(), 60.0, t0);
diff.diff(_state(), 10.0, t0);
var actions = diff.diff(_state(), 60.0, t0);
expect(actions.whereType<SeekAction>(), isEmpty);
actions = diff.diff(_room(), 60.0,
t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
actions = diff.diff(_state(), 60.0,
t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
expect(actions.whereType<SeekAction>().single.position,
closeTo(10.0, 3.0));
});
@@ -148,12 +148,12 @@ void main() {
stale: false);
test('only in-set ready members may follow transport to playing', () {
final room = _roomWithMembers([ready, catchingUp, unreadyInSet]);
expect(localPlayAllowed(room, 'm1'), isTrue);
expect(localPlayAllowed(room, 'm2'), isFalse);
expect(localPlayAllowed(room, 'm3'), isFalse);
expect(localPlayAllowed(room, 'nope'), isFalse);
expect(localPlayAllowed(room, ''), isFalse);
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);
expect(localPlayAllowed(state, 'm1'), isTrue);
expect(localPlayAllowed(state, 'm2'), isFalse);
expect(localPlayAllowed(state, 'm3'), isFalse);
expect(localPlayAllowed(state, 'nope'), isFalse);
expect(localPlayAllowed(state, ''), isFalse);
});
});
}
@@ -37,10 +37,10 @@ void main() {
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(const AppConfig(
final server = SyncServer(const AppConfig(
port: _port,
secret: _secret,
name: 'room',
name: 'server',
mediaPath: _mediaPath,
));
await server.start();
@@ -67,10 +67,10 @@ void main() {
reason: 'media must open paused, not autoplay');
expect(viewer.playback.player.state.playing, isFalse);
await _poll(() => host.room.peek() != null, 'host room state');
await _poll(() => host.serverState.peek() != null, 'host server state');
host.toggleReady();
await Future.delayed(const Duration(seconds: 5));
expect(host.room.peek()?.transport, 'held',
expect(host.serverState.peek()?.transport, 'held',
reason: 'one Space with company present must not solo-play');
expect(host.playback.player.state.playing, isFalse);
expect(viewer.playback.player.state.playing, isFalse);
@@ -78,7 +78,7 @@ void main() {
viewer.toggleReady();
await _poll(
() =>
host.room.peek()?.transport == 'playing' &&
host.serverState.peek()?.transport == 'playing' &&
viewer.playback.player.state.playing &&
host.playback.player.state.playing,
'both players playing once quorum readies',
+6 -6
View File
@@ -5,7 +5,7 @@ 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/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
@@ -29,7 +29,7 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(const AppConfig(
final server = SyncServer(const AppConfig(
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
await server.start();
final c = SessionCoordinator();
@@ -40,7 +40,7 @@ void main() {
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await _poll(() => c.room.peek() != null, 'room state');
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
@@ -54,14 +54,14 @@ void main() {
expect(c.selfMember.peek()?.ready, isTrue);
await _poll(() {
final r = c.room.peek();
final r = c.serverState.peek();
if (r == null || !c.playback.player.state.playing) return false;
final want = roomPositionAt(r, DateTime.now().toUtc());
final want = serverPositionAt(r, DateTime.now().toUtc());
return (c.playback.positionSecs - want).abs() < 1.5 &&
c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue);
expect(c.room.peek()?.transport, 'playing');
expect(c.serverState.peek()?.transport, 'playing');
await c.leave();
expect(c.playback.alive.peek(), isFalse);