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
This commit is contained in:
Josh Hawkins 2026-01-07 08:37:04 -06:00
parent c8f55ac41f
commit d55326cb09

View File

@ -80,6 +80,7 @@ function MSEPlayer({
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTIDRef = useRef<number | null>(null); const reconnectTIDRef = useRef<number | null>(null);
const intentionalDisconnectRef = useRef<boolean>(false);
const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null); const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null);
const onmessageRef = useRef<{ const onmessageRef = useRef<{
[key: string]: (msg: { value: string; type: string }) => void; [key: string]: (msg: { value: string; type: string }) => void;
@ -152,8 +153,11 @@ function MSEPlayer({
}, []); }, []);
const onConnect = useCallback(() => { 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); setWsState(WebSocket.CONNECTING);
setConnectTS(Date.now()); setConnectTS(Date.now());
@ -172,13 +176,44 @@ function MSEPlayer({
setBufferTimeout(undefined); setBufferTimeout(undefined);
} }
// Clear any pending reconnect attempts
if (reconnectTIDRef.current !== null) {
clearTimeout(reconnectTIDRef.current);
reconnectTIDRef.current = null;
}
setIsPlaying(false); setIsPlaying(false);
if (wsRef.current) { if (wsRef.current) {
setWsState(WebSocket.CLOSED); const ws = wsRef.current;
wsRef.current.close();
wsRef.current = null; 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]); }, [bufferTimeout]);
const handlePause = useCallback(() => { const handlePause = useCallback(() => {
@ -188,7 +223,14 @@ function MSEPlayer({
} }
}, [isPlaying, playbackEnabled]); }, [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); setWsState(WebSocket.OPEN);
wsRef.current?.addEventListener("message", (ev) => { wsRef.current?.addEventListener("message", (ev) => {
@ -206,9 +248,16 @@ function MSEPlayer({
onmessageRef.current = {}; onmessageRef.current = {};
onMse(); onMse();
}; // onMse is defined below and stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reconnect = (timeout?: number) => { const reconnect = (timeout?: number) => {
// Don't reconnect if intentional disconnect was flagged
if (intentionalDisconnectRef.current) {
return;
}
setWsState(WebSocket.CONNECTING); setWsState(WebSocket.CONNECTING);
wsRef.current = null; wsRef.current = null;
@ -221,10 +270,19 @@ function MSEPlayer({
}, delay); }, 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; if (wsState === WebSocket.CLOSED) return;
reconnect(); reconnect();
}; // reconnect is defined below and stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsState]);
const sendWithTimeout = (value: object, timeout: number) => { const sendWithTimeout = (value: object, timeout: number) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
@ -232,17 +290,26 @@ function MSEPlayer({
reject(new Error("Timeout waiting for response")); reject(new Error("Timeout waiting for response"));
}, timeout); }, timeout);
send(value);
// Override the onmessageRef handler for mse type to resolve the promise on response // Override the onmessageRef handler for mse type to resolve the promise on response
const originalHandler = onmessageRef.current["mse"]; const originalHandler = onmessageRef.current["mse"];
onmessageRef.current["mse"] = (msg) => { onmessageRef.current["mse"] = (msg) => {
if (msg.type === "mse") { if (msg.type === "mse") {
clearTimeout(timeoutId); 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(); resolve();
} }
}; };
send(value);
}); });
}; };