fix: watch together server races, reconnect readiness, session end

This commit is contained in:
edde746
2026-04-16 15:24:44 +02:00
parent 1d3ae3e1c5
commit 34815f22ee
6 changed files with 117 additions and 67 deletions
+1 -2
View File
@@ -3557,6 +3557,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Watch Together overlays (isolated from video surface repaints) // Watch Together overlays (isolated from video surface repaints)
RepaintBoundary( RepaintBoundary(
child: Stack( child: Stack(
fit: StackFit.expand,
children: [ children: [
// Watch Together: reconnecting to host overlay // Watch Together: reconnecting to host overlay
Selector<WatchTogetherProvider, bool>( Selector<WatchTogetherProvider, bool>(
@@ -3597,8 +3598,6 @@ 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
@@ -175,10 +175,11 @@ class WatchTogetherProvider with ChangeNotifier {
} }
} }
/// Wire up reconnection handler to re-announce join after reconnect /// Wire up reconnection handler to re-announce join and readiness after reconnect
void _wireReconnectHandler() { void _wireReconnectHandler() {
_peerService!.onReconnected = () { _peerService!.onReconnected = () {
_syncManager?.announceJoin(_displayName); _syncManager?.announceJoin(_displayName);
_syncManager?.reannounceReadyIfNeeded();
}; };
} }
@@ -308,18 +309,34 @@ 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 with a lightweight peer service to check room occupancy,
// then do a single createSession or joinSession. This avoids the crash-prone
// join→teardown→create cycle on the provider.
final customRelayUrl = SettingsService.instanceOrNull?.getCustomRelayUrl();
final probe = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
bool shouldBeHost;
try { try {
await joinSession(sessionId, displayName: displayName); await probe.joinSession(sessionId);
return false; // joined as guest shouldBeHost = probe.connectedPeers.isEmpty;
} on PeerError catch (e) { } on PeerError catch (e) {
// Room doesn't exist — create it as host
if (e.serverCode == 'room_not_found') { if (e.serverCode == 'room_not_found') {
await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId); shouldBeHost = true;
return true; } else {
} await probe.disconnect();
probe.dispose();
rethrow; rethrow;
} }
} }
await probe.disconnect();
probe.dispose();
if (shouldBeHost) {
await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId);
} else {
await joinSession(sessionId, displayName: displayName);
}
return shouldBeHost;
}
/// Leave the current session /// Leave the current session
Future<void> leaveSession() async { Future<void> leaveSession() async {
@@ -490,11 +507,11 @@ class WatchTogetherProvider with ChangeNotifier {
); );
} }
// If the host deliberately left, end the session immediately // If the host deliberately left, end the session for everyone.
// instead of waiting for the 15s reconnect grace period.
if (!isHost && message.peerId == _session?.hostPeerId) { if (!isHost && message.peerId == _session?.hostPeerId) {
_hostIntentionallyLeft = true; _hostIntentionallyLeft = true;
_handleHostExitedPlayer(message); _handleHostExitedPlayer(message);
leaveSession();
} }
notifyListeners(); notifyListeners();
@@ -125,10 +125,19 @@ class WatchTogetherSyncManager {
// If host, start broadcasting position periodically // If host, start broadcasting position periodically
if (_session.isHost) { if (_session.isHost) {
_startPositionSync(); _startPositionSync();
// Note: sessionConfig is sent after video loads (with correct position) in buffering handler
} }
// Note: playerReady will be announced when video loads (first buffering: false) // If the video is already loaded (buffering stream already fired before we
// subscribed), announce ready now so peers aren't stuck waiting.
if (!player.state.buffering && !_hasAnnouncedReady) {
_hasAnnouncedReady = true;
_peerReady[_peerService.myPeerId!] = true;
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
appLogger.d('WatchTogether: Video already loaded on attach, announcing ready');
if (_session.isHost) {
_sendSessionConfig();
}
}
// If guest, request current session config from host in case we missed // If guest, request current session config from host in case we missed
// a mediaSwitch broadcast (e.g., host switched episodes while we were // a mediaSwitch broadcast (e.g., host switched episodes while we were
@@ -144,6 +153,8 @@ class WatchTogetherSyncManager {
/// Initialize participant tracking from existing session participants /// Initialize participant tracking from existing session participants
/// Call this before attachPlayer() to ensure we know about participants who joined before /// Call this before attachPlayer() to ensure we know about participants who joined before
void initializeParticipants(List<String> peerIds) { void initializeParticipants(List<String> peerIds) {
// Clear stale entries (e.g. host's own peerId left over from a previous detachPlayer)
_peerReady.clear();
for (final peerId in peerIds) { for (final peerId in peerIds) {
if (peerId != _peerService.myPeerId) { if (peerId != _peerService.myPeerId) {
if (_session.isHost) { if (_session.isHost) {
@@ -232,6 +243,7 @@ class WatchTogetherSyncManager {
return; return;
} }
if (isPlaying && !_firstPlayCompleted) _firstPlayCompleted = true;
if (!isPlaying) _setDeferredPlay(false); if (!isPlaying) _setDeferredPlay(false);
_broadcastPlayPause(isPlaying); _broadcastPlayPause(isPlaying);
}), }),
@@ -944,6 +956,20 @@ class WatchTogetherSyncManager {
} }
} }
/// Re-announce player readiness after reconnect.
///
/// During reconnect the host resets our _peerReady entry to false via
/// _handlePeerJoin, but our _hasAnnouncedReady flag is still true (never
/// reset because the player stays attached). Re-broadcast so the host
/// doesn't stay stuck in the deferred-play gate.
void reannounceReadyIfNeeded() {
if (_hasAnnouncedReady && _peerService.myPeerId != null) {
_peerReady[_peerService.myPeerId!] = true;
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
appLogger.d('WatchTogether: Re-announced player ready after reconnect');
}
}
/// Send join announcement to all peers /// Send join announcement to all peers
void announceJoin(String displayName) { void announceJoin(String displayName) {
_peerService.broadcast( _peerService.broadcast(
@@ -14,31 +14,24 @@ import '../models/watch_session.dart';
import '../providers/watch_together_provider.dart'; import '../providers/watch_together_provider.dart';
/// Overlay shown on the video player when in a watch together session /// Overlay shown on the video player when in a watch together session
class WatchTogetherOverlay extends StatelessWidget { /// Session indicator badge for embedding in the video controls header.
/// Callback when the user wants to leave the session /// Shows participant count, host badge, and opens the session menu on tap.
class WatchTogetherSessionIndicator extends StatelessWidget {
final VoidCallback? onLeaveSession; final VoidCallback? onLeaveSession;
const WatchTogetherOverlay({super.key, this.onLeaveSession}); const WatchTogetherSessionIndicator({super.key, this.onLeaveSession});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<WatchTogetherProvider>( return Consumer<WatchTogetherProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
if (!provider.isInSession) { return _SessionIndicator(
return const SizedBox.shrink();
}
return Positioned(
top: 16,
right: 16,
child: _SessionIndicator(
participantCount: provider.participantCount, participantCount: provider.participantCount,
isHost: provider.isHost, isHost: provider.isHost,
isSyncing: provider.isSyncing, isSyncing: provider.isSyncing,
controlMode: provider.controlMode, controlMode: provider.controlMode,
sessionId: provider.sessionId, sessionId: provider.sessionId,
onTap: () => _showSessionMenu(context, provider), onTap: () => _showSessionMenu(context, provider),
),
); );
}, },
); );
@@ -71,8 +64,6 @@ class _SessionIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material( return Material(
color: Colors.black54, color: Colors.black54,
borderRadius: const BorderRadius.all(Radius.circular(20)), borderRadius: const BorderRadius.all(Radius.circular(20)),
@@ -94,7 +85,7 @@ class _SessionIndicator extends StatelessWidget {
: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), : const CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
) )
else else
Icon(Symbols.group, size: 18, color: isHost ? theme.colorScheme.primary : Colors.white), Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -109,13 +100,13 @@ class _SessionIndicator extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: theme.colorScheme.primary, color: Colors.amber,
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: BorderRadius.all(Radius.circular(4)),
), ),
child: Text( child: Text(
t.watchTogether.hostBadge, t.watchTogether.hostBadge,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold),
), ),
), ),
], ],
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:plezy/utils/formatters.dart'; import 'package:plezy/utils/formatters.dart';
import '../../../models/plex_metadata.dart'; import '../../../models/plex_metadata.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../watch_together/widgets/watch_together_overlay.dart';
import '../../../watch_together/providers/watch_together_provider.dart';
import '../../app_bar_back_button.dart'; import '../../app_bar_back_button.dart';
/// Header layout style for video controls /// Header layout style for video controls
@@ -47,6 +50,16 @@ class VideoControlsHeader extends StatelessWidget {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()), Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
Selector<WatchTogetherProvider, bool>(
selector: (_, p) => p.isInSession,
builder: (context, inSession, child) {
if (!inSession) return const SizedBox.shrink();
return const Padding(
padding: EdgeInsets.only(right: 8),
child: WatchTogetherSessionIndicator(),
);
},
),
?trailing, ?trailing,
], ],
); );
+27 -23
View File
@@ -202,10 +202,11 @@ type serverMsg struct {
type Client struct { type Client struct {
conn *websocket.Conn conn *websocket.Conn
send chan []byte send chan []byte
done chan struct{}
} }
func newClient(conn *websocket.Conn) *Client { func newClient(conn *websocket.Conn) *Client {
c := &Client{conn: conn, send: make(chan []byte, 64)} c := &Client{conn: conn, send: make(chan []byte, 64), done: make(chan struct{})}
go c.writePump() go c.writePump()
return c return c
} }
@@ -215,13 +216,12 @@ func (c *Client) writePump() {
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case data, ok := <-c.send: case data := <-c.send:
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, nil)
return
}
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.SetWriteDeadline(time.Now().Add(writeWait))
c.conn.WriteMessage(websocket.TextMessage, data) c.conn.WriteMessage(websocket.TextMessage, data)
case <-c.done:
c.conn.WriteMessage(websocket.CloseMessage, nil)
return
case <-ticker.C: case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -231,20 +231,24 @@ func (c *Client) writePump() {
} }
} }
func (c *Client) trySend(data []byte) {
select {
case c.send <- data:
case <-c.done:
default:
}
}
func (c *Client) sendJSON(msg serverMsg) { func (c *Client) sendJSON(msg serverMsg) {
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { if err != nil {
return return
} }
select { c.trySend(data)
case c.send <- data:
default:
// Drop if buffer full (slow client)
}
} }
func (c *Client) close() { func (c *Client) close() {
close(c.send) close(c.done)
} }
// --- Room --- // --- Room ---
@@ -281,10 +285,7 @@ func (r *Room) broadcastExcept(senderID string, msg serverMsg) {
r.mu.RUnlock() r.mu.RUnlock()
for _, client := range targets { for _, client := range targets {
select { client.trySend(data)
case client.send <- data:
default:
}
} }
} }
@@ -299,12 +300,8 @@ func (r *Room) sendTo(targetID string, msg serverMsg) bool {
if !ok { if !ok {
return false return false
} }
select { client.trySend(data)
case client.send <- data:
return true return true
default:
return false
}
} }
// --- Log store --- // --- Log store ---
@@ -556,20 +553,27 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
var currentPeerID string var currentPeerID string
var isHost bool var isHost bool
// Cleanup on disconnect // Cleanup on disconnect — only if our Client is still the one in the room.
// A reconnecting peer reuses the same peerId, so the map entry may have
// been overwritten by a newer Client before this defer runs.
defer func() { defer func() {
if currentRoom != nil && currentPeerID != "" { if currentRoom != nil && currentPeerID != "" {
currentRoom.mu.Lock() currentRoom.mu.Lock()
stale := currentRoom.Peers[currentPeerID] != client
if !stale {
delete(currentRoom.Peers, currentPeerID) delete(currentRoom.Peers, currentPeerID)
}
currentRoom.mu.Unlock() currentRoom.mu.Unlock()
if !stale {
currentRoom.broadcastExcept(currentPeerID, serverMsg{ currentRoom.broadcastExcept(currentPeerID, serverMsg{
Type: "peerLeft", Type: "peerLeft",
PeerID: currentPeerID, PeerID: currentPeerID,
}) })
}
if isHost { if isHost {
s.conns.releaseRoom(ip) s.conns.releaseRoom(ip)
} }
log.Printf("peer %s left room %s", currentPeerID, currentRoom.SessionID) log.Printf("peer %s left room %s (stale=%v)", currentPeerID, currentRoom.SessionID, stale)
} }
}() }()