From d55326cb0933fae1f52a916ab67a27558e8a93a3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 7 Jan 2026 08:37:04 -0600 Subject: [PATCH] mse player improvements - fix WebSocket race condition by registering message handlers before sending and avoid closing CONNECTING sockets to eliminate "Socket is not connected" errors. - attempt to resolve Safari MSE timeout and handler issues by wrapping temporary handlers in try/catch and stabilizing the permanent mse handler so SourceBuffer setup completes reliably. - add intentional disconnect tracking to prevent unwanted reconnects during navigation/StrictMode cycles --- web/src/components/player/MsePlayer.tsx | 87 ++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index bab00a2f8..84d220eca 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -80,6 +80,7 @@ function MSEPlayer({ const videoRef = useRef(null); const wsRef = useRef(null); const reconnectTIDRef = useRef(null); + const intentionalDisconnectRef = useRef(false); const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null); const onmessageRef = useRef<{ [key: string]: (msg: { value: string; type: string }) => void; @@ -152,8 +153,11 @@ function MSEPlayer({ }, []); const onConnect = useCallback(() => { - if (!videoRef.current?.isConnected || !wsURL || wsRef.current) return false; + if (!videoRef.current?.isConnected || !wsURL || wsRef.current) { + return false; + } + intentionalDisconnectRef.current = false; setWsState(WebSocket.CONNECTING); setConnectTS(Date.now()); @@ -172,13 +176,44 @@ function MSEPlayer({ setBufferTimeout(undefined); } + // Clear any pending reconnect attempts + if (reconnectTIDRef.current !== null) { + clearTimeout(reconnectTIDRef.current); + reconnectTIDRef.current = null; + } + setIsPlaying(false); if (wsRef.current) { - setWsState(WebSocket.CLOSED); - wsRef.current.close(); + const ws = wsRef.current; wsRef.current = null; + const currentReadyState = ws.readyState; + + intentionalDisconnectRef.current = true; + setWsState(WebSocket.CLOSED); + + // Remove event listeners to prevent them firing during close + try { + ws.removeEventListener("open", onOpen); + ws.removeEventListener("close", onClose); + } catch { + // Ignore errors removing listeners + } + + // Only call close() if the socket is OPEN or CLOSING + // For CONNECTING or CLOSED sockets, just let it die + if ( + currentReadyState === WebSocket.OPEN || + currentReadyState === WebSocket.CLOSING + ) { + try { + ws.close(); + } catch { + // Ignore close errors + } + } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [bufferTimeout]); const handlePause = useCallback(() => { @@ -188,7 +223,14 @@ function MSEPlayer({ } }, [isPlaying, playbackEnabled]); - const onOpen = () => { + const onOpen = useCallback(() => { + // If we were marked for intentional disconnect while connecting, close immediately + if (intentionalDisconnectRef.current) { + wsRef.current?.close(); + wsRef.current = null; + return; + } + setWsState(WebSocket.OPEN); wsRef.current?.addEventListener("message", (ev) => { @@ -206,9 +248,16 @@ function MSEPlayer({ onmessageRef.current = {}; onMse(); - }; + // onMse is defined below and stable + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const reconnect = (timeout?: number) => { + // Don't reconnect if intentional disconnect was flagged + if (intentionalDisconnectRef.current) { + return; + } + setWsState(WebSocket.CONNECTING); wsRef.current = null; @@ -221,10 +270,19 @@ function MSEPlayer({ }, delay); }; - const onClose = () => { + const onClose = useCallback(() => { + // Don't reconnect if this was an intentional disconnect + if (intentionalDisconnectRef.current) { + // Reset the flag so future connects are allowed + intentionalDisconnectRef.current = false; + return; + } + if (wsState === WebSocket.CLOSED) return; reconnect(); - }; + // reconnect is defined below and stable + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wsState]); const sendWithTimeout = (value: object, timeout: number) => { return new Promise((resolve, reject) => { @@ -232,17 +290,26 @@ function MSEPlayer({ reject(new Error("Timeout waiting for response")); }, timeout); - send(value); - // Override the onmessageRef handler for mse type to resolve the promise on response const originalHandler = onmessageRef.current["mse"]; onmessageRef.current["mse"] = (msg) => { if (msg.type === "mse") { clearTimeout(timeoutId); - if (originalHandler) originalHandler(msg); + + // Call original handler in try-catch so errors don't prevent promise resolution + if (originalHandler) { + try { + originalHandler(msg); + } catch (e) { + // Don't reject - we got the response, just let the error bubble + } + } + resolve(); } }; + + send(value); }); };