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

@@ -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();