mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 14:30:26 +01:00
fix: remove obsolete commit checkpoints file; update WebRTCPlayer for error handling; improve stream fallback logic in StreamPage
This commit is contained in:
@@ -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
30
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user