mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +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
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -44,3 +73,4 @@ test_scripts/
|
|||||||
|
|
||||||
# Rust
|
# Rust
|
||||||
target/
|
target/
|
||||||
|
.ai/
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ type WebRTCPlayerProps = {
|
|||||||
autoPlay?: boolean
|
autoPlay?: boolean
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
onReady?: () => void
|
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({
|
export function WebRTCPlayer({
|
||||||
@@ -12,6 +82,7 @@ export function WebRTCPlayer({
|
|||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
muted = false,
|
muted = false,
|
||||||
onReady,
|
onReady,
|
||||||
|
onError,
|
||||||
}: WebRTCPlayerProps) {
|
}: WebRTCPlayerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -21,7 +92,8 @@ export function WebRTCPlayer({
|
|||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || !src) return
|
if (!video || !src) return
|
||||||
|
|
||||||
let pc: RTCPeerConnection | null = new RTCPeerConnection()
|
let pc: RTCPeerConnection | null = null
|
||||||
|
let sessionUrl: string | null = null
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
readyRef.current = false
|
readyRef.current = false
|
||||||
|
|
||||||
@@ -31,8 +103,17 @@ export function WebRTCPlayer({
|
|||||||
onReady?.()
|
onReady?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportError = (message: string) => {
|
||||||
|
if (abortController.signal.aborted) return
|
||||||
|
setError(message)
|
||||||
|
onError?.(message)
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
|
if (sessionUrl) {
|
||||||
|
fetch(sessionUrl, { method: "DELETE" }).catch(() => {})
|
||||||
|
}
|
||||||
if (pc) {
|
if (pc) {
|
||||||
pc.ontrack = null
|
pc.ontrack = null
|
||||||
pc.onconnectionstatechange = null
|
pc.onconnectionstatechange = null
|
||||||
@@ -63,11 +144,28 @@ export function WebRTCPlayer({
|
|||||||
pc.addEventListener("icegatheringstatechange", onStateChange)
|
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 () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
if (!pc) return
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
const iceServers = await getIceServers()
|
||||||
|
pc = new RTCPeerConnection(iceServers.length ? { iceServers } : undefined)
|
||||||
|
|
||||||
pc.addTransceiver("video", { direction: "recvonly" })
|
pc.addTransceiver("video", { direction: "recvonly" })
|
||||||
pc.addTransceiver("audio", { direction: "recvonly" })
|
pc.addTransceiver("audio", { direction: "recvonly" })
|
||||||
|
|
||||||
@@ -86,6 +184,15 @@ export function WebRTCPlayer({
|
|||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
if (pc?.connectionState === "connected") {
|
if (pc?.connectionState === "connected") {
|
||||||
markReady()
|
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")
|
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 })
|
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
const message = err instanceof Error ? err.message : "WebRTC failed"
|
const message = err instanceof Error ? err.message : "WebRTC failed"
|
||||||
setError(message)
|
reportError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +240,7 @@ export function WebRTCPlayer({
|
|||||||
start()
|
start()
|
||||||
|
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [autoPlay, muted, onReady, src])
|
}, [autoPlay, muted, onError, onReady, src])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function StreamPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [streamReady, setStreamReady] = useState(false)
|
const [streamReady, setStreamReady] = useState(false)
|
||||||
|
const [webRtcFailed, setWebRtcFailed] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true
|
let isActive = true
|
||||||
@@ -68,20 +69,20 @@ function StreamPage() {
|
|||||||
setError(next)
|
setError(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const setWebRtcFailedSafe = (next: boolean) => {
|
||||||
|
if (isActive) {
|
||||||
|
setWebRtcFailed(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setReadySafe(false)
|
setReadySafe(false)
|
||||||
|
setWebRtcFailedSafe(false)
|
||||||
|
|
||||||
// Special handling for nikiv - hardcoded stream
|
// Special handling for nikiv - hardcoded stream
|
||||||
if (username === "nikiv") {
|
if (username === "nikiv") {
|
||||||
setDataSafe(NIKIV_DATA)
|
setDataSafe(NIKIV_DATA)
|
||||||
setLoadingSafe(false)
|
setLoadingSafe(false)
|
||||||
|
|
||||||
if (NIKIV_PLAYBACK?.type === "hls") {
|
|
||||||
fetch(NIKIV_PLAYBACK.url)
|
|
||||||
.then((res) => setReadySafe(res.ok))
|
|
||||||
.catch(() => setReadySafe(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isActive = false
|
isActive = false
|
||||||
}
|
}
|
||||||
@@ -93,14 +94,6 @@ function StreamPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await getStreamByUsername(username)
|
const result = await getStreamByUsername(username)
|
||||||
setDataSafe(result)
|
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) {
|
} catch (err) {
|
||||||
setErrorSafe("Failed to load stream")
|
setErrorSafe("Failed to load stream")
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -115,6 +108,45 @@ function StreamPage() {
|
|||||||
}
|
}
|
||||||
}, [username])
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
<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 { user } = data
|
||||||
const playback = stream?.playback
|
|
||||||
const showPlayer =
|
const showPlayer =
|
||||||
playback?.type === "cloudflare" ||
|
activePlayback?.type === "cloudflare" ||
|
||||||
playback?.type === "webrtc" ||
|
activePlayback?.type === "webrtc" ||
|
||||||
(playback?.type === "hls" && streamReady)
|
(activePlayback?.type === "hls" && streamReady)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JazzProvider>
|
<JazzProvider>
|
||||||
@@ -162,13 +193,17 @@ function StreamPage() {
|
|||||||
<ViewerCount username={username} />
|
<ViewerCount username={username} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stream?.is_live && playback && showPlayer ? (
|
{stream?.is_live && activePlayback && showPlayer ? (
|
||||||
playback.type === "webrtc" ? (
|
activePlayback.type === "webrtc" ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<WebRTCPlayer
|
<WebRTCPlayer
|
||||||
src={playback.url}
|
src={activePlayback.url}
|
||||||
muted={false}
|
muted={false}
|
||||||
onReady={() => setStreamReady(true)}
|
onReady={() => setStreamReady(true)}
|
||||||
|
onError={() => {
|
||||||
|
setWebRtcFailed(true)
|
||||||
|
setStreamReady(!fallbackPlayback)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{!streamReady && (
|
{!streamReady && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-white">
|
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||||
@@ -181,11 +216,11 @@ function StreamPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : playback.type === "cloudflare" ? (
|
) : activePlayback.type === "cloudflare" ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<CloudflareStreamPlayer
|
<CloudflareStreamPlayer
|
||||||
uid={playback.uid}
|
uid={activePlayback.uid}
|
||||||
customerCode={playback.customerCode}
|
customerCode={activePlayback.customerCode}
|
||||||
muted={false}
|
muted={false}
|
||||||
onReady={() => setStreamReady(true)}
|
onReady={() => setStreamReady(true)}
|
||||||
/>
|
/>
|
||||||
@@ -201,9 +236,9 @@ function StreamPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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="flex h-full w-full items-center justify-center text-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-pulse text-4xl">🔴</div>
|
<div className="animate-pulse text-4xl">🔴</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user