fix: watch together bugs and server write serialization

This commit is contained in:
edde746
2026-04-16 14:00:24 +02:00
parent 3cf1ebc09b
commit 1d3ae3e1c5
8 changed files with 193 additions and 132 deletions
+2
View File
@@ -3597,6 +3597,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
); );
}, },
), ),
// Watch Together: session indicator and menu
WatchTogetherOverlay(onLeaveSession: _handleBackButton),
// Watch Together: participant join/leave/buffering notifications // Watch Together: participant join/leave/buffering notifications
const ParticipantNotificationOverlay(), const ParticipantNotificationOverlay(),
// Watch Together: waiting for participants to load // Watch Together: waiting for participants to load
+4 -1
View File
@@ -22,7 +22,10 @@ class PeerError {
final String message; final String message;
final dynamic originalError; final dynamic originalError;
const PeerError({required this.type, required this.message, this.originalError}); /// Server-provided error code (e.g. 'room_not_found', 'room_full').
final String? serverCode;
const PeerError({required this.type, required this.message, this.originalError, this.serverCode});
@override @override
String toString() => 'PeerError($type): $message'; String toString() => 'PeerError($type): $message';
@@ -280,6 +280,25 @@ class SyncMessage {
/// Position as Duration (convenience getter) /// Position as Duration (convenience getter)
Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null; Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null;
SyncMessage copyWith({String? peerId}) {
return SyncMessage(
type: type,
timestamp: timestamp,
positionMs: positionMs,
bufferingState: bufferingState,
rate: rate,
peerId: peerId ?? this.peerId,
displayName: displayName,
isHost: isHost,
controlMode: controlMode,
pingId: pingId,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
isPlaying: isPlaying,
);
}
/// Serialize to JSON string for sending over data channel /// Serialize to JSON string for sending over data channel
String toJson() { String toJson() {
final map = <String, dynamic>{'t': type.name, 'ts': timestamp}; final map = <String, dynamic>{'t': type.name, 'ts': timestamp};
@@ -79,9 +79,6 @@ class WatchSession {
/// Current connection state /// Current connection state
final SessionState state; final SessionState state;
/// List of participants in the session
final List<Participant> participants;
/// Error message if state is error /// Error message if state is error
final String? errorMessage; final String? errorMessage;
@@ -102,7 +99,6 @@ class WatchSession {
required this.role, required this.role,
required this.controlMode, required this.controlMode,
required this.state, required this.state,
this.participants = const [],
this.errorMessage, this.errorMessage,
this.mediaRatingKey, this.mediaRatingKey,
this.mediaServerId, this.mediaServerId,
@@ -116,15 +112,11 @@ class WatchSession {
/// Whether the session is currently connected /// Whether the session is currently connected
bool get isConnected => state == SessionState.connected; bool get isConnected => state == SessionState.connected;
/// Number of participants (including self)
int get participantCount => participants.length;
WatchSession copyWith({ WatchSession copyWith({
String? sessionId, String? sessionId,
SessionRole? role, SessionRole? role,
ControlMode? controlMode, ControlMode? controlMode,
SessionState? state, SessionState? state,
List<Participant>? participants,
String? errorMessage, String? errorMessage,
String? mediaRatingKey, String? mediaRatingKey,
String? mediaServerId, String? mediaServerId,
@@ -136,7 +128,6 @@ class WatchSession {
role: role ?? this.role, role: role ?? this.role,
controlMode: controlMode ?? this.controlMode, controlMode: controlMode ?? this.controlMode,
state: state ?? this.state, state: state ?? this.state,
participants: participants ?? this.participants,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
mediaRatingKey: mediaRatingKey ?? this.mediaRatingKey, mediaRatingKey: mediaRatingKey ?? this.mediaRatingKey,
mediaServerId: mediaServerId ?? this.mediaServerId, mediaServerId: mediaServerId ?? this.mediaServerId,
@@ -163,7 +154,6 @@ class WatchSession {
mediaRatingKey: mediaRatingKey, mediaRatingKey: mediaRatingKey,
mediaServerId: mediaServerId, mediaServerId: mediaServerId,
mediaTitle: mediaTitle, mediaTitle: mediaTitle,
participants: [],
); );
} }
@@ -174,7 +164,6 @@ class WatchSession {
role: SessionRole.guest, role: SessionRole.guest,
controlMode: ControlMode.hostOnly, // Will be updated when connected controlMode: ControlMode.hostOnly, // Will be updated when connected
state: SessionState.connecting, state: SessionState.connecting,
participants: [],
); );
} }
} }
@@ -36,20 +36,22 @@ class WatchTogetherProvider with ChangeNotifier {
// During Watch Together join, 4-5 notifications fire within milliseconds; // During Watch Together join, 4-5 notifications fire within milliseconds;
// this batches them into one rebuild to avoid overwhelming low-end devices. // this batches them into one rebuild to avoid overwhelming low-end devices.
bool _notifyScheduled = false; bool _notifyScheduled = false;
bool _disposed = false;
@override @override
void notifyListeners() { void notifyListeners() {
if (_notifyScheduled) return; if (_disposed || _notifyScheduled) return;
_notifyScheduled = true; _notifyScheduled = true;
scheduleMicrotask(() { scheduleMicrotask(() {
_notifyScheduled = false; _notifyScheduled = false;
super.notifyListeners(); if (!_disposed) super.notifyListeners();
}); });
} }
// Host reconnect grace period // Host reconnect grace period
Timer? _hostReconnectTimer; Timer? _hostReconnectTimer;
bool _isWaitingForHostReconnect = false; bool _isWaitingForHostReconnect = false;
bool _hostIntentionallyLeft = false;
// Debounce map for action events (peerId+type → last emission timestamp) // Debounce map for action events (peerId+type → last emission timestamp)
final Map<String, int> _lastActionEventMs = {}; final Map<String, int> _lastActionEventMs = {};
@@ -131,7 +133,6 @@ class WatchTogetherProvider with ChangeNotifier {
role: session.role, role: session.role,
controlMode: session.controlMode, controlMode: session.controlMode,
state: session.state, state: session.state,
participants: session.participants,
errorMessage: session.errorMessage, errorMessage: session.errorMessage,
hostPeerId: session.hostPeerId, hostPeerId: session.hostPeerId,
); );
@@ -174,6 +175,13 @@ class WatchTogetherProvider with ChangeNotifier {
} }
} }
/// Wire up reconnection handler to re-announce join after reconnect
void _wireReconnectHandler() {
_peerService!.onReconnected = () {
_syncManager?.announceJoin(_displayName);
};
}
/// Wire up sync manager's state change callback to update provider state /// Wire up sync manager's state change callback to update provider state
void _wireSyncStateChanges() { void _wireSyncStateChanges() {
_syncManager!.onSyncStateChanged = (isSyncing) { _syncManager!.onSyncStateChanged = (isSyncing) {
@@ -227,6 +235,7 @@ class WatchTogetherProvider with ChangeNotifier {
); );
_wireSyncStateChanges(); _wireSyncStateChanges();
_wireReconnectHandler();
notifyListeners(); notifyListeners();
appLogger.d('WatchTogether: Session created: $createdSessionId'); appLogger.d('WatchTogether: Session created: $createdSessionId');
@@ -276,6 +285,7 @@ class WatchTogetherProvider with ChangeNotifier {
}; };
_wireSyncStateChanges(); _wireSyncStateChanges();
_wireReconnectHandler();
// Add self to participants // Add self to participants
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false)); _participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
@@ -298,25 +308,15 @@ class WatchTogetherProvider with ChangeNotifier {
/// ///
/// Returns `true` if the user became the host. /// Returns `true` if the user became the host.
Future<bool> enterRoom(String sessionId, {ControlMode controlMode = ControlMode.anyone, String? displayName}) async { Future<bool> enterRoom(String sessionId, {ControlMode controlMode = ControlMode.anyone, String? displayName}) async {
// Probe the relay to determine if the room exists, then delegate to
// the existing createSession / joinSession which handle all setup.
final customRelayUrl = SettingsService.instanceOrNull?.getCustomRelayUrl();
final probe = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
try { try {
final becameHost = await probe.joinOrCreateSession(sessionId); await joinSession(sessionId, displayName: displayName);
final shouldBeHost = becameHost || probe.connectedPeers.isEmpty; return false; // joined as guest
await probe.disconnect(); } on PeerError catch (e) {
probe.dispose(); // Room doesn't exist — create it as host
if (e.serverCode == 'room_not_found') {
if (shouldBeHost) {
await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId); await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId);
} else { return true;
await joinSession(sessionId, displayName: displayName);
} }
return shouldBeHost;
} catch (e) {
await probe.disconnect();
probe.dispose();
rethrow; rethrow;
} }
} }
@@ -358,6 +358,7 @@ class WatchTogetherProvider with ChangeNotifier {
_isDeferredPlay = false; _isDeferredPlay = false;
_lastHandledCurrentPlaybackKey = null; _lastHandledCurrentPlaybackKey = null;
_lastActionEventMs.clear(); _lastActionEventMs.clear();
_hostIntentionallyLeft = false;
notifyListeners(); notifyListeners();
appLogger.d('WatchTogether: Session left'); appLogger.d('WatchTogether: Session left');
@@ -410,8 +411,9 @@ class WatchTogetherProvider with ChangeNotifier {
_participants.removeWhere((p) => p.peerId == peerId); _participants.removeWhere((p) => p.peerId == peerId);
// If host disconnected, start grace period for reconnection // If host disconnected unexpectedly, start grace period for reconnection.
if (!isHost && peerId == _session?.hostPeerId) { // Skip if the host already sent a deliberate leave message.
if (!isHost && peerId == _session?.hostPeerId && !_hostIntentionallyLeft) {
_startHostReconnectGracePeriod(); _startHostReconnectGracePeriod();
} else if (disconnectedName != null) { } else if (disconnectedName != null) {
_participantEventController.add( _participantEventController.add(
@@ -459,16 +461,16 @@ class WatchTogetherProvider with ChangeNotifier {
_participantEventController.add( _participantEventController.add(
ParticipantEvent(displayName: message.displayName!, type: ParticipantEventType.joined), ParticipantEvent(displayName: message.displayName!, type: ParticipantEventType.joined),
); );
}
// Send our join info back so the new peer adds us to their // Send our join info back so the new peer adds us to their
// participant list. Every peer does this (not just the host) // participant list. Only reply to NEW peers to avoid an
// so that late joiners learn about all existing participants. // infinite join ping-pong (A→join→B→join→A→...).
if (_peerService != null) { if (_peerService != null) {
_peerService!.sendTo( _peerService!.sendTo(
message.peerId!, message.peerId!,
SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: isHost), SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: isHost),
); );
}
} }
notifyListeners(); notifyListeners();
@@ -487,6 +489,14 @@ class WatchTogetherProvider with ChangeNotifier {
ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left), ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left),
); );
} }
// If the host deliberately left, end the session immediately
// instead of waiting for the 15s reconnect grace period.
if (!isHost && message.peerId == _session?.hostPeerId) {
_hostIntentionallyLeft = true;
_handleHostExitedPlayer(message);
}
notifyListeners(); notifyListeners();
} }
break; break;
@@ -739,6 +749,7 @@ class WatchTogetherProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true;
_cancelHostReconnectGracePeriod(); _cancelHostReconnectGracePeriod();
_participantEventController.close(); _participantEventController.close();
leaveSession(); leaveSession();
@@ -59,6 +59,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
static const int _maxReconnectAttempts = 3; static const int _maxReconnectAttempts = 3;
Timer? _reconnectTimer; Timer? _reconnectTimer;
/// Called after a successful reconnection so the provider can re-announce join.
void Function()? onReconnected;
// Keepalive (via KeepaliveMixin) // Keepalive (via KeepaliveMixin)
@override @override
Duration get pingInterval => const Duration(seconds: 15); Duration get pingInterval => const Duration(seconds: 15);
@@ -192,10 +195,16 @@ class WatchTogetherPeerService with KeepaliveMixin {
case 'message': case 'message':
final payload = msg['payload']; final payload = msg['payload'];
final serverFrom = msg['from'] as String?;
if (payload != null) { if (payload != null) {
try { try {
final payloadStr = payload is String ? payload : jsonEncode(payload); final payloadStr = payload is String ? payload : jsonEncode(payload);
final syncMsg = SyncMessage.fromJson(payloadStr); var syncMsg = SyncMessage.fromJson(payloadStr);
// Use the server-authenticated sender ID instead of the
// self-reported peerId in the payload to prevent spoofing.
if (serverFrom != null && syncMsg.peerId != serverFrom) {
syncMsg = syncMsg.copyWith(peerId: serverFrom);
}
_safeAdd(_messageReceivedController, syncMsg); _safeAdd(_messageReceivedController, syncMsg);
} catch (e) { } catch (e) {
appLogger.e('WatchTogether: Failed to parse sync message payload', error: e); appLogger.e('WatchTogether: Failed to parse sync message payload', error: e);
@@ -206,7 +215,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
final code = msg['code'] as String? ?? 'unknown'; final code = msg['code'] as String? ?? 'unknown';
final message = msg['message'] as String? ?? 'Unknown error'; final message = msg['message'] as String? ?? 'Unknown error';
appLogger.e('WatchTogether: Server error: $code - $message'); appLogger.e('WatchTogether: Server error: $code - $message');
final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message'); final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code);
_safeAdd(_errorController, error); _safeAdd(_errorController, error);
if (setupCompleter != null && !setupCompleter.isCompleted) { if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.completeError(error); setupCompleter.completeError(error);
@@ -292,15 +301,27 @@ class WatchTogetherPeerService with KeepaliveMixin {
_listenToChannel(channel, setupCompleter: completer); _listenToChannel(channel, setupCompleter: completer);
startKeepalive(); startKeepalive();
// Re-send create or join // Always try join first — the room may still have peers (e.g. host
if (_isHost) { // reconnecting while guests remain). Fall back to create only if
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId}); // the room no longer exists and we were the host.
} else { _sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId});
_sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId});
try {
await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect');
} on PeerError catch (e) {
if (_isHost && e.serverCode == 'room_not_found') {
appLogger.d('WatchTogether: Room gone, re-creating as host');
final createCompleter = Completer<void>();
_listenToChannel(channel, setupCompleter: createCompleter);
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId});
await createCompleter.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect create');
} else {
rethrow;
}
} }
await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect');
appLogger.d('WatchTogether: Reconnected successfully'); appLogger.d('WatchTogether: Reconnected successfully');
onReconnected?.call();
} catch (e) { } catch (e) {
appLogger.e('WatchTogether: Reconnect failed', error: e); appLogger.e('WatchTogether: Reconnect failed', error: e);
_handleWebSocketClosed(); _handleWebSocketClosed();
@@ -384,20 +405,6 @@ class WatchTogetherPeerService with KeepaliveMixin {
} }
} }
/// Try to join a session; if it doesn't exist, create it as host.
///
/// Returns `true` if the user became the host (room was empty).
Future<bool> joinOrCreateSession(String sessionId) async {
try {
await joinSession(sessionId);
return false; // joined as guest
} on PeerError catch (e) {
if (e.type != PeerErrorType.serverError) rethrow;
await createSession(sessionId: sessionId);
return true; // created as host
}
}
/// Broadcast a message to all connected peers /// Broadcast a message to all connected peers
void broadcast(SyncMessage message) { void broadcast(SyncMessage message) {
final payload = message.toJson(); final payload = message.toJson();
@@ -228,7 +228,7 @@ class WatchTogetherSyncManager {
} finally { } finally {
_isRemoteAction = false; _isRemoteAction = false;
} }
_broadcastPlayPause(true); // Don't broadcast play — deferred play will broadcast when all peers are ready.
return; return;
} }
@@ -539,20 +539,6 @@ class WatchTogetherSyncManager {
return; return;
} }
// HOST RELAY: In "anyone" mode, host rebroadcasts control commands from guests
// This is needed because guests only connect to host (star topology), not to each other
if (_session.isHost && _session.controlMode == ControlMode.anyone) {
final isControlMessage =
message.type == SyncMessageType.play ||
message.type == SyncMessageType.pause ||
message.type == SyncMessageType.seek ||
message.type == SyncMessageType.rate;
if (isControlMessage) {
_peerService.broadcast(message);
}
}
// In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig) // In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig)
if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) { if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) {
final isHostMessage = message.peerId == _session.hostPeerId; final isHostMessage = message.peerId == _session.hostPeerId;
@@ -669,11 +655,14 @@ class WatchTogetherSyncManager {
if (_deferredPlay && isAllReady) { if (_deferredPlay && isAllReady) {
_setDeferredPlay(false); _setDeferredPlay(false);
_firstPlayCompleted = true; _firstPlayCompleted = true;
final pos = _deferredPlayPosition;
_deferredPlayPosition = null;
await _applyRemotePlay( await _applyRemotePlay(
position: _deferredPlayPosition, position: pos,
expectedAttachmentGeneration: queuedAttachmentGeneration, expectedAttachmentGeneration: queuedAttachmentGeneration,
); );
_deferredPlayPosition = null; // Broadcast play to all peers now that everyone is ready
_broadcastPlayPause(true);
} }
} }
break; break;
+92 -51
View File
@@ -197,12 +197,62 @@ type serverMsg struct {
Payload json.RawMessage `json:"payload,omitempty"` Payload json.RawMessage `json:"payload,omitempty"`
} }
// --- Client (serializes writes to a single goroutine) ---
type Client struct {
conn *websocket.Conn
send chan []byte
}
func newClient(conn *websocket.Conn) *Client {
c := &Client{conn: conn, send: make(chan []byte, 64)}
go c.writePump()
return c
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case data, ok := <-c.send:
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, nil)
return
}
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
c.conn.WriteMessage(websocket.TextMessage, data)
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func (c *Client) sendJSON(msg serverMsg) {
data, err := json.Marshal(msg)
if err != nil {
return
}
select {
case c.send <- data:
default:
// Drop if buffer full (slow client)
}
}
func (c *Client) close() {
close(c.send)
}
// --- Room --- // --- Room ---
type Room struct { type Room struct {
SessionID string SessionID string
HostPeerID string HostPeerID string
Peers map[string]*websocket.Conn Peers map[string]*Client
mu sync.RWMutex mu sync.RWMutex
CreatedAt time.Time CreatedAt time.Time
} }
@@ -220,12 +270,20 @@ func (r *Room) broadcastExcept(senderID string, msg serverMsg) {
if err != nil { if err != nil {
return return
} }
// Copy peers under lock, then send without holding it
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() targets := make([]*Client, 0, len(r.Peers))
for id, conn := range r.Peers { for id, client := range r.Peers {
if id != senderID { if id != senderID {
conn.SetWriteDeadline(time.Now().Add(writeWait)) targets = append(targets, client)
conn.WriteMessage(websocket.TextMessage, data) }
}
r.mu.RUnlock()
for _, client := range targets {
select {
case client.send <- data:
default:
} }
} }
} }
@@ -236,14 +294,17 @@ func (r *Room) sendTo(targetID string, msg serverMsg) bool {
return false return false
} }
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() client, ok := r.Peers[targetID]
conn, ok := r.Peers[targetID] r.mu.RUnlock()
if !ok { if !ok {
return false return false
} }
conn.SetWriteDeadline(time.Now().Add(writeWait)) select {
conn.WriteMessage(websocket.TextMessage, data) case client.send <- data:
return true return true
default:
return false
}
} }
// --- Log store --- // --- Log store ---
@@ -463,18 +524,6 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) {
w.Write(data) w.Write(data)
} }
func (s *Server) sendError(conn *websocket.Conn, code, message string) {
data, _ := json.Marshal(serverMsg{Type: "error", Code: code, Message: message})
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
}
func (s *Server) sendJSON(conn *websocket.Conn, msg serverMsg) {
data, _ := json.Marshal(msg)
conn.SetWriteDeadline(time.Now().Add(writeWait))
conn.WriteMessage(websocket.TextMessage, data)
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r) ip := clientIP(r)
@@ -498,17 +547,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
return nil return nil
}) })
// Ping ticker // Client wraps the conn with a serialized write channel + ping ticker
ticker := time.NewTicker(pingInterval) client := newClient(conn)
defer ticker.Stop() defer client.close()
go func() {
for range ticker.C {
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}()
rl := newRateLimiter(rateBurst, rateSustained) rl := newRateLimiter(rateBurst, rateSustained)
var currentRoom *Room var currentRoom *Room
@@ -542,24 +583,24 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
} }
if !rl.allow() { if !rl.allow() {
s.sendError(conn, "rate_limited", "Too many messages") client.sendJSON(serverMsg{Type: "error", Code: "rate_limited", Message: "Too many messages"})
continue continue
} }
var msg clientMsg var msg clientMsg
if err := json.Unmarshal(raw, &msg); err != nil { if err := json.Unmarshal(raw, &msg); err != nil {
s.sendError(conn, "invalid_message", "Invalid JSON") client.sendJSON(serverMsg{Type: "error", Code: "invalid_message", Message: "Invalid JSON"})
continue continue
} }
switch msg.Type { switch msg.Type {
case "create": case "create":
if msg.SessionID == "" || msg.PeerID == "" { if msg.SessionID == "" || msg.PeerID == "" {
s.sendError(conn, "invalid_message", "sessionId and peerId required") client.sendJSON(serverMsg{Type: "error", Code: "invalid_message", Message: "sessionId and peerId required"})
continue continue
} }
if !s.conns.tryCreateRoom(ip) { if !s.conns.tryCreateRoom(ip) {
s.sendError(conn, "rate_limited", "Too many rooms created") client.sendJSON(serverMsg{Type: "error", Code: "rate_limited", Message: "Too many rooms created"})
continue continue
} }
s.mu.Lock() s.mu.Lock()
@@ -570,7 +611,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if !empty { if !empty {
s.mu.Unlock() s.mu.Unlock()
s.conns.releaseRoom(ip) s.conns.releaseRoom(ip)
s.sendError(conn, "room_exists", "Room already exists") client.sendJSON(serverMsg{Type: "error", Code: "room_exists", Message: "Room already exists"})
continue continue
} }
// Empty stale room — reclaim the ID // Empty stale room — reclaim the ID
@@ -579,7 +620,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
room := &Room{ room := &Room{
SessionID: msg.SessionID, SessionID: msg.SessionID,
HostPeerID: msg.PeerID, HostPeerID: msg.PeerID,
Peers: map[string]*websocket.Conn{msg.PeerID: conn}, Peers: map[string]*Client{msg.PeerID: client},
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
s.rooms[msg.SessionID] = room s.rooms[msg.SessionID] = room
@@ -588,27 +629,27 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
currentPeerID = msg.PeerID currentPeerID = msg.PeerID
isHost = true isHost = true
log.Printf("room %s created by %s", msg.SessionID, msg.PeerID) log.Printf("room %s created by %s", msg.SessionID, msg.PeerID)
s.sendJSON(conn, serverMsg{Type: "created", SessionID: msg.SessionID}) client.sendJSON(serverMsg{Type: "created", SessionID: msg.SessionID})
case "join": case "join":
if msg.SessionID == "" || msg.PeerID == "" { if msg.SessionID == "" || msg.PeerID == "" {
s.sendError(conn, "invalid_message", "sessionId and peerId required") client.sendJSON(serverMsg{Type: "error", Code: "invalid_message", Message: "sessionId and peerId required"})
continue continue
} }
s.mu.RLock() s.mu.RLock()
room, exists := s.rooms[msg.SessionID] room, exists := s.rooms[msg.SessionID]
s.mu.RUnlock() s.mu.RUnlock()
if !exists { if !exists {
s.sendError(conn, "room_not_found", "Room does not exist") client.sendJSON(serverMsg{Type: "error", Code: "room_not_found", Message: "Room does not exist"})
continue continue
} }
room.mu.Lock() room.mu.Lock()
if len(room.Peers) >= maxRoomSize { if len(room.Peers) >= maxRoomSize {
room.mu.Unlock() room.mu.Unlock()
s.sendError(conn, "room_full", "Room is full") client.sendJSON(serverMsg{Type: "error", Code: "room_full", Message: "Room is full"})
continue continue
} }
room.Peers[msg.PeerID] = conn room.Peers[msg.PeerID] = client
peers := room.peerIDs() peers := room.peerIDs()
room.mu.Unlock() room.mu.Unlock()
currentRoom = room currentRoom = room
@@ -622,12 +663,12 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
existingPeers = append(existingPeers, p) existingPeers = append(existingPeers, p)
} }
} }
s.sendJSON(conn, serverMsg{Type: "joined", SessionID: msg.SessionID, Peers: existingPeers}) client.sendJSON(serverMsg{Type: "joined", SessionID: msg.SessionID, Peers: existingPeers})
room.broadcastExcept(msg.PeerID, serverMsg{Type: "peerJoined", PeerID: msg.PeerID}) room.broadcastExcept(msg.PeerID, serverMsg{Type: "peerJoined", PeerID: msg.PeerID})
case "broadcast": case "broadcast":
if currentRoom == nil { if currentRoom == nil {
s.sendError(conn, "not_in_room", "Not in a room") client.sendJSON(serverMsg{Type: "error", Code: "not_in_room", Message: "Not in a room"})
continue continue
} }
currentRoom.broadcastExcept(currentPeerID, serverMsg{ currentRoom.broadcastExcept(currentPeerID, serverMsg{
@@ -638,11 +679,11 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
case "sendTo": case "sendTo":
if currentRoom == nil { if currentRoom == nil {
s.sendError(conn, "not_in_room", "Not in a room") client.sendJSON(serverMsg{Type: "error", Code: "not_in_room", Message: "Not in a room"})
continue continue
} }
if msg.To == "" { if msg.To == "" {
s.sendError(conn, "invalid_message", "to field required") client.sendJSON(serverMsg{Type: "error", Code: "invalid_message", Message: "to field required"})
continue continue
} }
if !currentRoom.sendTo(msg.To, serverMsg{ if !currentRoom.sendTo(msg.To, serverMsg{
@@ -650,14 +691,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
From: currentPeerID, From: currentPeerID,
Payload: msg.Payload, Payload: msg.Payload,
}) { }) {
s.sendError(conn, "not_in_room", "Target peer not found") client.sendJSON(serverMsg{Type: "error", Code: "not_in_room", Message: "Target peer not found"})
} }
case "ping": case "ping":
s.sendJSON(conn, serverMsg{Type: "pong"}) client.sendJSON(serverMsg{Type: "pong"})
default: default:
s.sendError(conn, "invalid_message", "Unknown message type") client.sendJSON(serverMsg{Type: "error", Code: "invalid_message", Message: "Unknown message type"})
} }
} }
} }