nsfwapp/frontend/src/components/ui/LiveVideo.tsx
2026-04-27 14:40:16 +02:00

260 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(() => {})
}
}}
/>
)
}