260 lines
6.0 KiB
TypeScript
260 lines
6.0 KiB
TypeScript
// frontend/src/components/ui/LiveVideo.tsx
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
|
||
|
||
export default function LiveVideo({
|
||
src,
|
||
muted = DEFAULT_INLINE_MUTED,
|
||
volume = 1,
|
||
className,
|
||
roomStatus,
|
||
onVolumeChange,
|
||
onLoadingStart,
|
||
onReady,
|
||
}: {
|
||
src: string
|
||
muted?: boolean
|
||
volume?: number
|
||
className?: string
|
||
roomStatus?: string
|
||
onVolumeChange?: (volume: number, muted: boolean) => void
|
||
onLoadingStart?: () => void
|
||
onReady?: () => void
|
||
}) {
|
||
const ref = useRef<HTMLVideoElement>(null)
|
||
const [broken, setBroken] = useState(false)
|
||
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null)
|
||
|
||
const onLoadingStartRef = useRef(onLoadingStart)
|
||
const onReadyRef = useRef(onReady)
|
||
|
||
useEffect(() => {
|
||
onLoadingStartRef.current = onLoadingStart
|
||
}, [onLoadingStart])
|
||
|
||
useEffect(() => {
|
||
onReadyRef.current = onReady
|
||
}, [onReady])
|
||
|
||
const normalizeBrokenReason = (
|
||
status?: string
|
||
): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => {
|
||
const s = String(status ?? '').trim().toLowerCase()
|
||
|
||
if (s === 'private') return 'private'
|
||
if (s === 'hidden') return 'hidden'
|
||
if (s === 'away') return 'away'
|
||
if (s === 'offline') return 'offline'
|
||
return 'unknown'
|
||
}
|
||
|
||
const brokenMessage = (
|
||
reason: 'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null
|
||
): string => {
|
||
switch (reason) {
|
||
case 'hidden':
|
||
return 'Cam is hidden'
|
||
case 'away':
|
||
return 'Model is away'
|
||
case 'private':
|
||
return 'Private show in progress.'
|
||
case 'offline':
|
||
return 'Model is offline'
|
||
case 'unknown':
|
||
return 'Live video unavailable'
|
||
default:
|
||
return 'Live video unavailable'
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
let watchdogTimer: number | null = null
|
||
let readySent = false
|
||
let stalledRetries = 0
|
||
let attemptStartedAt = Date.now()
|
||
let lastProgressAt = Date.now()
|
||
|
||
const video = ref.current
|
||
if (!video) return
|
||
|
||
setBroken(false)
|
||
setBrokenReason(null)
|
||
|
||
applyInlineVideoPolicy(video, { muted })
|
||
video.volume = Math.max(0, Math.min(1, volume))
|
||
video.muted = muted || volume <= 0
|
||
|
||
const hardReset = () => {
|
||
try {
|
||
video.pause()
|
||
video.removeAttribute('src')
|
||
video.load()
|
||
} catch {}
|
||
}
|
||
|
||
const markBroken = () => {
|
||
const reason = normalizeBrokenReason(roomStatus)
|
||
setBroken(true)
|
||
setBrokenReason(reason)
|
||
}
|
||
|
||
const beginLoad = () => {
|
||
readySent = false
|
||
attemptStartedAt = Date.now()
|
||
lastProgressAt = Date.now()
|
||
|
||
onLoadingStartRef.current?.()
|
||
|
||
hardReset()
|
||
video.src = src
|
||
video.load()
|
||
|
||
const playPromise = video.play()
|
||
if (playPromise && typeof playPromise.catch === 'function') {
|
||
playPromise.catch(() => {
|
||
// Nicht sofort abbrechen – Watchdog entscheidet nach Timeout.
|
||
})
|
||
}
|
||
}
|
||
|
||
hardReset()
|
||
|
||
if (!src) {
|
||
setBroken(false)
|
||
setBrokenReason(null)
|
||
return
|
||
}
|
||
|
||
const markReady = () => {
|
||
lastProgressAt = Date.now()
|
||
|
||
if (readySent) return
|
||
readySent = true
|
||
onReadyRef.current?.()
|
||
}
|
||
|
||
const onTime = () => {
|
||
lastProgressAt = Date.now()
|
||
markReady()
|
||
}
|
||
|
||
const onLoadedData = () => {
|
||
lastProgressAt = Date.now()
|
||
markReady()
|
||
}
|
||
|
||
const onCanPlay = () => {
|
||
lastProgressAt = Date.now()
|
||
markReady()
|
||
}
|
||
|
||
const onPlaying = () => {
|
||
lastProgressAt = Date.now()
|
||
markReady()
|
||
}
|
||
|
||
const onError = () => {
|
||
markBroken()
|
||
}
|
||
|
||
video.addEventListener('timeupdate', onTime)
|
||
video.addEventListener('loadeddata', onLoadedData)
|
||
video.addEventListener('canplay', onCanPlay)
|
||
video.addEventListener('playing', onPlaying)
|
||
video.addEventListener('error', onError)
|
||
|
||
beginLoad()
|
||
|
||
watchdogTimer = window.setInterval(() => {
|
||
const now = Date.now()
|
||
|
||
// Stream wurde nie ready -> nicht ewig Spinner zeigen
|
||
if (!readySent && now - attemptStartedAt > 12_000) {
|
||
if (stalledRetries < 1) {
|
||
stalledRetries += 1
|
||
beginLoad()
|
||
return
|
||
}
|
||
|
||
markBroken()
|
||
return
|
||
}
|
||
|
||
// Stream war ready, hängt aber später
|
||
if (readySent && !video.paused && now - lastProgressAt > 12_000) {
|
||
if (stalledRetries < 1) {
|
||
stalledRetries += 1
|
||
beginLoad()
|
||
return
|
||
}
|
||
|
||
markBroken()
|
||
}
|
||
}, 1_000)
|
||
|
||
return () => {
|
||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||
watchdogTimer = null
|
||
|
||
video.removeEventListener('timeupdate', onTime)
|
||
video.removeEventListener('loadeddata', onLoadedData)
|
||
video.removeEventListener('canplay', onCanPlay)
|
||
video.removeEventListener('playing', onPlaying)
|
||
video.removeEventListener('error', onError)
|
||
|
||
hardReset()
|
||
}
|
||
}, [src, roomStatus])
|
||
|
||
useEffect(() => {
|
||
const video = ref.current
|
||
if (!video) return
|
||
|
||
const safeVolume = Math.max(0, Math.min(1, volume))
|
||
video.volume = safeVolume
|
||
video.muted = muted || safeVolume <= 0
|
||
}, [volume, muted])
|
||
|
||
useEffect(() => {
|
||
const video = ref.current
|
||
if (!video || !onVolumeChange) return
|
||
|
||
const emit = () => {
|
||
onVolumeChange(video.volume, video.muted)
|
||
}
|
||
|
||
video.addEventListener('volumechange', emit)
|
||
return () => video.removeEventListener('volumechange', emit)
|
||
}, [onVolumeChange])
|
||
|
||
if (broken) {
|
||
return (
|
||
<div className="grid h-full w-full place-items-center bg-black/80 px-4 text-center">
|
||
<div className="text-sm font-medium text-white">
|
||
{brokenMessage(brokenReason)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<video
|
||
ref={ref}
|
||
className={className}
|
||
playsInline
|
||
autoPlay
|
||
muted={muted}
|
||
preload="auto"
|
||
crossOrigin="anonymous"
|
||
onClick={() => {
|
||
const v = ref.current
|
||
if (v) {
|
||
v.play().catch(() => {})
|
||
}
|
||
}}
|
||
/>
|
||
)
|
||
} |