From f45c28f94101d28c1a1fd86de96d89faa4579ef3 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 22 Dec 2025 11:47:17 -0800 Subject: [PATCH] fix: remove obsolete commit checkpoints file; update WebRTCPlayer for error handling; improve stream fallback logic in StreamPage --- .ai/commit-checkpoints.json | 7 -- .gitignore | 30 +++++ packages/web/src/components/WebRTCPlayer.tsx | 120 ++++++++++++++++++- packages/web/src/routes/$username.tsx | 89 +++++++++----- 4 files changed, 208 insertions(+), 38 deletions(-) delete mode 100644 .ai/commit-checkpoints.json diff --git a/.ai/commit-checkpoints.json b/.ai/commit-checkpoints.json deleted file mode 100644 index 6a895d86..00000000 --- a/.ai/commit-checkpoints.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "last_commit": { - "timestamp": "2025-12-22T02:36:05.675814+00:00", - "session_id": "019b43e9-b86b-7a80-939f-6ee733a40258", - "last_entry_timestamp": "2025-12-22T02:36:02.528Z" - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 387fac49..be8836fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,32 @@ +# core +.DS_Store +.env +.env*.local +.env.production +output +dist +target +.idea +.cache +.output +node_modules +package-lock.json +yarn.lock +.vercel +*.db +.repo_ignore +i.* +i-* +i/ +internal/ +past.* +past-* +past/ +*.log +private +.blade +.npm-cache + # Logs logs *.log @@ -44,3 +73,4 @@ test_scripts/ # Rust target/ +.ai/ diff --git a/packages/web/src/components/WebRTCPlayer.tsx b/packages/web/src/components/WebRTCPlayer.tsx index f7fc1703..d036ded3 100644 --- a/packages/web/src/components/WebRTCPlayer.tsx +++ b/packages/web/src/components/WebRTCPlayer.tsx @@ -5,6 +5,76 @@ type WebRTCPlayerProps = { autoPlay?: boolean muted?: boolean onReady?: () => void + onError?: (message: string) => void +} + +const splitHeaderParts = (value: string) => { + const parts: string[] = [] + let current = "" + let inQuotes = false + for (const char of value) { + if (char === "\"") { + inQuotes = !inQuotes + } + if (char === "," && !inQuotes) { + if (current.trim()) { + parts.push(current.trim()) + } + current = "" + continue + } + current += char + } + if (current.trim()) { + parts.push(current.trim()) + } + return parts +} + +const stripQuotes = (value: string) => { + if (value.startsWith("\"") && value.endsWith("\"")) { + return value.slice(1, -1) + } + return value +} + +const parseIceServersFromLinkHeader = (header: string | null): RTCIceServer[] => { + if (!header) return [] + const servers: RTCIceServer[] = [] + + for (const part of splitHeaderParts(header)) { + const urlMatch = part.match(/<([^>]+)>/) + if (!urlMatch) continue + + const url = urlMatch[1].trim() + const params = part.split(";").map((item) => item.trim()) + let rel = "" + let username = "" + let credential = "" + let credentialType = "" + + for (const param of params.slice(1)) { + const [key, ...rest] = param.split("=") + if (!key) continue + const value = stripQuotes(rest.join("=").trim()) + if (key === "rel") rel = value + if (key === "username") username = value + if (key === "credential") credential = value + if (key === "credential-type") credentialType = value + } + + if (!rel.includes("ice-server")) continue + + const server: RTCIceServer = { urls: url } + if (username) server.username = username + if (credential) server.credential = credential + if (credentialType) { + server.credentialType = credentialType as RTCIceCredentialType + } + servers.push(server) + } + + return servers } export function WebRTCPlayer({ @@ -12,6 +82,7 @@ export function WebRTCPlayer({ autoPlay = true, muted = false, onReady, + onError, }: WebRTCPlayerProps) { const videoRef = useRef(null) const [error, setError] = useState(null) @@ -21,7 +92,8 @@ export function WebRTCPlayer({ const video = videoRef.current if (!video || !src) return - let pc: RTCPeerConnection | null = new RTCPeerConnection() + let pc: RTCPeerConnection | null = null + let sessionUrl: string | null = null const abortController = new AbortController() readyRef.current = false @@ -31,8 +103,17 @@ export function WebRTCPlayer({ onReady?.() } + const reportError = (message: string) => { + if (abortController.signal.aborted) return + setError(message) + onError?.(message) + } + const cleanup = () => { abortController.abort() + if (sessionUrl) { + fetch(sessionUrl, { method: "DELETE" }).catch(() => {}) + } if (pc) { pc.ontrack = null pc.onconnectionstatechange = null @@ -63,11 +144,28 @@ export function WebRTCPlayer({ pc.addEventListener("icegatheringstatechange", onStateChange) }) + const getIceServers = async () => { + try { + const response = await fetch(src, { + method: "OPTIONS", + signal: abortController.signal, + }) + if (!response.ok) { + return [] + } + return parseIceServersFromLinkHeader(response.headers.get("Link")) + } catch { + return [] + } + } + const start = async () => { try { - if (!pc) return setError(null) + const iceServers = await getIceServers() + pc = new RTCPeerConnection(iceServers.length ? { iceServers } : undefined) + pc.addTransceiver("video", { direction: "recvonly" }) pc.addTransceiver("audio", { direction: "recvonly" }) @@ -86,6 +184,15 @@ export function WebRTCPlayer({ pc.onconnectionstatechange = () => { if (pc?.connectionState === "connected") { markReady() + } else if (pc?.connectionState === "failed") { + reportError("WebRTC connection failed") + } + } + + pc.oniceconnectionstatechange = () => { + if (!pc) return + if (pc.iceConnectionState === "failed") { + reportError("WebRTC ICE failed") } } @@ -116,11 +223,16 @@ export function WebRTCPlayer({ throw new Error("Empty WebRTC answer") } + const locationHeader = response.headers.get("Location") + if (locationHeader) { + sessionUrl = new URL(locationHeader, src).toString() + } + await pc.setRemoteDescription({ type: "answer", sdp: answerSdp }) } catch (err) { if (!abortController.signal.aborted) { const message = err instanceof Error ? err.message : "WebRTC failed" - setError(message) + reportError(message) } } } @@ -128,7 +240,7 @@ export function WebRTCPlayer({ start() return cleanup - }, [autoPlay, muted, onReady, src]) + }, [autoPlay, muted, onError, onReady, src]) if (error) { return ( diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 9446021c..5051d283 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -45,6 +45,7 @@ function StreamPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [streamReady, setStreamReady] = useState(false) + const [webRtcFailed, setWebRtcFailed] = useState(false) useEffect(() => { let isActive = true @@ -68,20 +69,20 @@ function StreamPage() { setError(next) } } + const setWebRtcFailedSafe = (next: boolean) => { + if (isActive) { + setWebRtcFailed(next) + } + } setReadySafe(false) + setWebRtcFailedSafe(false) // Special handling for nikiv - hardcoded stream if (username === "nikiv") { setDataSafe(NIKIV_DATA) setLoadingSafe(false) - if (NIKIV_PLAYBACK?.type === "hls") { - fetch(NIKIV_PLAYBACK.url) - .then((res) => setReadySafe(res.ok)) - .catch(() => setReadySafe(false)) - } - return () => { isActive = false } @@ -93,14 +94,6 @@ function StreamPage() { try { const result = await getStreamByUsername(username) setDataSafe(result) - - const playback = result?.stream?.playback - if (playback?.type === "hls") { - const res = await fetch(playback.url) - setReadySafe(res.ok) - } else { - setReadySafe(false) - } } catch (err) { setErrorSafe("Failed to load stream") console.error(err) @@ -115,6 +108,45 @@ function StreamPage() { } }, [username]) + const stream = data?.stream ?? null + const playback = stream?.playback ?? null + const fallbackPlayback = stream?.hls_url + ? resolveStreamPlayback({ hlsUrl: stream.hls_url, webrtcUrl: null }) + : null + const activePlayback = + playback?.type === "webrtc" && webRtcFailed + ? fallbackPlayback ?? playback + : playback + + useEffect(() => { + let isActive = true + if (!activePlayback || activePlayback.type !== "hls") { + return () => { + isActive = false + } + } + + setStreamReady(false) + fetch(activePlayback.url) + .then((res) => { + if (isActive) { + setStreamReady(res.ok) + } + }) + .catch(() => { + if (isActive) { + setStreamReady(false) + } + }) + + return () => { + isActive = false + } + }, [ + activePlayback?.type, + activePlayback?.type === "hls" ? activePlayback.url : null, + ]) + if (loading) { return (
@@ -147,12 +179,11 @@ function StreamPage() { ) } - const { user, stream } = data - const playback = stream?.playback + const { user } = data const showPlayer = - playback?.type === "cloudflare" || - playback?.type === "webrtc" || - (playback?.type === "hls" && streamReady) + activePlayback?.type === "cloudflare" || + activePlayback?.type === "webrtc" || + (activePlayback?.type === "hls" && streamReady) return ( @@ -162,13 +193,17 @@ function StreamPage() {
- {stream?.is_live && playback && showPlayer ? ( - playback.type === "webrtc" ? ( + {stream?.is_live && activePlayback && showPlayer ? ( + activePlayback.type === "webrtc" ? (
setStreamReady(true)} + onError={() => { + setWebRtcFailed(true) + setStreamReady(!fallbackPlayback) + }} /> {!streamReady && (
@@ -181,11 +216,11 @@ function StreamPage() {
)}
- ) : playback.type === "cloudflare" ? ( + ) : activePlayback.type === "cloudflare" ? (
setStreamReady(true)} /> @@ -201,9 +236,9 @@ function StreamPage() { )}
) : ( - + ) - ) : stream?.is_live && playback ? ( + ) : stream?.is_live && activePlayback ? (
🔴