fix: remove obsolete commit checkpoints file; update WebRTCPlayer for error handling; improve stream fallback logic in StreamPage

This commit is contained in:
Nikita
2025-12-22 11:47:17 -08:00
parent 2a98dd1d0b
commit f45c28f941
4 changed files with 208 additions and 38 deletions

View File

@@ -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"
}
}

30
.gitignore vendored
View File

@@ -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/

View File

@@ -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<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(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 (

View File

@@ -45,6 +45,7 @@ function StreamPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
@@ -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 (
<JazzProvider>
@@ -162,13 +193,17 @@ function StreamPage() {
<ViewerCount username={username} />
</div>
{stream?.is_live && playback && showPlayer ? (
playback.type === "webrtc" ? (
{stream?.is_live && activePlayback && showPlayer ? (
activePlayback.type === "webrtc" ? (
<div className="relative h-full w-full">
<WebRTCPlayer
src={playback.url}
src={activePlayback.url}
muted={false}
onReady={() => setStreamReady(true)}
onError={() => {
setWebRtcFailed(true)
setStreamReady(!fallbackPlayback)
}}
/>
{!streamReady && (
<div className="absolute inset-0 flex items-center justify-center text-white">
@@ -181,11 +216,11 @@ function StreamPage() {
</div>
)}
</div>
) : playback.type === "cloudflare" ? (
) : activePlayback.type === "cloudflare" ? (
<div className="relative h-full w-full">
<CloudflareStreamPlayer
uid={playback.uid}
customerCode={playback.customerCode}
uid={activePlayback.uid}
customerCode={activePlayback.customerCode}
muted={false}
onReady={() => setStreamReady(true)}
/>
@@ -201,9 +236,9 @@ function StreamPage() {
)}
</div>
) : (
<VideoPlayer src={playback.url} muted={false} />
<VideoPlayer src={activePlayback.url} muted={false} />
)
) : stream?.is_live && playback ? (
) : stream?.is_live && activePlayback ? (
<div className="flex h-full w-full items-center justify-center text-white">
<div className="text-center">
<div className="animate-pulse text-4xl">🔴</div>