updated live video
This commit is contained in:
parent
a0a869c5a5
commit
d578d4e6aa
@ -61,6 +61,37 @@ func serveLiveNotReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte(body))
|
_, _ = w.Write([]byte(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeBlockHLSOnPreview(w http.ResponseWriter, r *http.Request, basePath, file string) bool {
|
||||||
|
// Nur /api/preview (nicht /api/preview/live)
|
||||||
|
if strings.TrimSpace(basePath) != "/api/preview" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
low := strings.ToLower(strings.TrimSpace(file))
|
||||||
|
if low == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur echte HLS-Dateien blocken (Manifest/Segmente)
|
||||||
|
if strings.HasSuffix(low, ".m3u8") || strings.HasSuffix(low, ".ts") || strings.HasSuffix(low, ".m4s") {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Preview-HLS-Disabled", "1")
|
||||||
|
|
||||||
|
// Optionales Debug (hilft dir, den Auslöser zu finden)
|
||||||
|
fmt.Printf("[HLS-BLOCK] %s file=%q referer=%q ua=%q\n",
|
||||||
|
r.URL.String(),
|
||||||
|
file,
|
||||||
|
r.Referer(),
|
||||||
|
r.Header.Get("User-Agent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Error(w, "HLS disabled on /api/preview; use /api/preview/live", http.StatusGone) // 410
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// stopPreview stops the running ffmpeg HLS preview process for a job and resets state.
|
// stopPreview stops the running ffmpeg HLS preview process for a job and resets state.
|
||||||
func stopPreview(job *RecordJob) {
|
func stopPreview(job *RecordJob) {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
@ -81,8 +112,54 @@ func stopPreview(job *RecordJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
|
func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
|
||||||
// identisch zu /api/preview, aber m3u8 rewriting soll auf /api/preview/live zeigen
|
// ✅ Route bleibt /api/preview/live
|
||||||
recordPreviewWithBase(w, r, "/api/preview/live")
|
// Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4).
|
||||||
|
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||||
|
if file == "" {
|
||||||
|
recordPreviewLiveFMP4(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst)
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
servePreviewHLSFileWithBase(w, r, id, file, "/api/preview/live")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPreviewFile serves ONLY the HLS file requests for /api/preview?file=...
|
||||||
|
// preview.webp bleibt in preview.go (servePreviewWebPAlias).
|
||||||
|
func recordPreviewFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
if id == "" {
|
||||||
|
id = strings.TrimSpace(r.URL.Query().Get("name"))
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||||
|
if file == "" {
|
||||||
|
http.Error(w, "file fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ HLS auf /api/preview blocken (Manifest/Segmente), um Polling-Storm zu verhindern.
|
||||||
|
// Wenn du es NICHT blocken willst, diese if-Zeile entfernen.
|
||||||
|
if maybeBlockHLSOnPreview(w, r, "/api/preview", file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alles andere (falls doch erlaubt) über die gemeinsame Serving-Funktion
|
||||||
|
servePreviewHLSFileWithBase(w, r, id, file, "/api/preview")
|
||||||
}
|
}
|
||||||
|
|
||||||
// servePreviewHLSFileWithBase serves a single HLS file (index/segment) for a job.
|
// servePreviewHLSFileWithBase serves a single HLS file (index/segment) for a job.
|
||||||
|
|||||||
@ -1493,15 +1493,18 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLS / file serving
|
// file serving
|
||||||
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
|
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
|
||||||
low := strings.ToLower(file)
|
low := strings.ToLower(strings.TrimSpace(file))
|
||||||
|
|
||||||
|
// ✅ preview.webp weiterhin hier behandeln
|
||||||
if low == "preview.webp" {
|
if low == "preview.webp" {
|
||||||
servePreviewWebPAlias(w, r, id)
|
servePreviewWebPAlias(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ✅ Wichtig: HLS rewrite soll auf basePath zeigen (/api/preview oder /api/preview/live)
|
|
||||||
servePreviewHLSFileWithBase(w, r, id, file, basePath)
|
// ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go
|
||||||
|
recordPreviewFile(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,15 @@ func RecordStream(
|
|||||||
return fmt.Errorf("playlist abrufen: %w", err)
|
return fmt.Errorf("playlist abrufen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
|
||||||
|
if job != nil {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||||
|
job.PreviewCookie = httpCookie
|
||||||
|
job.PreviewUA = hc.userAgent
|
||||||
|
jobsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
|
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
|
||||||
assetID := assetIDForJob(job)
|
assetID := assetIDForJob(job)
|
||||||
if strings.TrimSpace(assetID) == "" {
|
if strings.TrimSpace(assetID) == "" {
|
||||||
|
|||||||
@ -75,6 +75,15 @@ func RecordStreamMFC(
|
|||||||
return fmt.Errorf("mfc: keine m3u8 URL gefunden")
|
return fmt.Errorf("mfc: keine m3u8 URL gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
|
||||||
|
if job != nil {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.PreviewM3U8 = strings.TrimSpace(m3u8URL)
|
||||||
|
job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen
|
||||||
|
job.PreviewUA = hc.userAgent
|
||||||
|
jobsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar)
|
// ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar)
|
||||||
if job != nil {
|
if job != nil {
|
||||||
_ = publishJob(job.ID)
|
_ = publishJob(job.ID)
|
||||||
|
|||||||
@ -1,262 +0,0 @@
|
|||||||
// frontend\src\components\ui\LiveHlsVideo.tsx
|
|
||||||
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import Hls from 'hls.js'
|
|
||||||
import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
|
|
||||||
|
|
||||||
function withNonce(url: string, nonce: number) {
|
|
||||||
const sep = url.includes('?') ? '&' : '?'
|
|
||||||
return `${url}${sep}v=${nonce}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LiveHlsVideo({
|
|
||||||
src,
|
|
||||||
muted = DEFAULT_INLINE_MUTED,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
src: string
|
|
||||||
muted?: boolean
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
|
||||||
const [broken, setBroken] = useState(false)
|
|
||||||
const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null)
|
|
||||||
|
|
||||||
// ✅ Nur für "harte Reloads" (triggert Effect neu)
|
|
||||||
const [reloadKey, setReloadKey] = useState(1)
|
|
||||||
|
|
||||||
// ✅ manifestUrl ist stabil pro reloadKey
|
|
||||||
const manifestUrl = useMemo(() => withNonce(src, reloadKey), [src, reloadKey])
|
|
||||||
|
|
||||||
const lastReloadAtRef = useRef(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
let hls: Hls | null = null
|
|
||||||
let stallTimer: number | null = null
|
|
||||||
let watchdogTimer: number | null = null
|
|
||||||
|
|
||||||
const videoEl = ref.current
|
|
||||||
if (!videoEl) return
|
|
||||||
const video: HTMLVideoElement = videoEl
|
|
||||||
|
|
||||||
setBroken(false)
|
|
||||||
setBrokenReason(null)
|
|
||||||
|
|
||||||
applyInlineVideoPolicy(video, { muted })
|
|
||||||
|
|
||||||
const cleanupTimers = () => {
|
|
||||||
if (stallTimer) window.clearTimeout(stallTimer)
|
|
||||||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
|
||||||
stallTimer = null
|
|
||||||
watchdogTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const hardReload = () => {
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
// ✅ verhindert Reload-Stürme (z.B. wenn hls.js kurz zickt)
|
|
||||||
if (now - lastReloadAtRef.current < 4000) return
|
|
||||||
lastReloadAtRef.current = now
|
|
||||||
|
|
||||||
cleanupTimers()
|
|
||||||
setReloadKey((x) => x + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForManifestWithSegments(): Promise<{ ok: boolean; reason?: 'private' | 'offline' }> {
|
|
||||||
const started = Date.now()
|
|
||||||
|
|
||||||
while (!cancelled && Date.now() - started < 90_000) {
|
|
||||||
try {
|
|
||||||
// ✅ immer frisch pollen (Cache umgehen)
|
|
||||||
const url = withNonce(src, Date.now())
|
|
||||||
const r = await fetch(url, { cache: 'no-store' })
|
|
||||||
|
|
||||||
if (r.status === 403) return { ok: false, reason: 'private' }
|
|
||||||
if (r.status === 404) return { ok: false, reason: 'offline' }
|
|
||||||
|
|
||||||
if (r.ok) {
|
|
||||||
const txt = await r.text()
|
|
||||||
if (txt.includes('#EXTINF')) return { ok: true }
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
await new Promise((res) => setTimeout(res, 1200))
|
|
||||||
}
|
|
||||||
|
|
||||||
// kein reason => "noch nicht ready"
|
|
||||||
return { ok: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
const res = await waitForManifestWithSegments()
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
// ✅ Nur echte Endzustände dauerhaft anzeigen
|
|
||||||
if (res.reason === 'private' || res.reason === 'offline') {
|
|
||||||
setBrokenReason(res.reason)
|
|
||||||
setBroken(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Sonst: retry per reloadKey (ohne broken)
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (!cancelled) hardReload()
|
|
||||||
}, 800)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Safari / iOS: Native HLS
|
|
||||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
video.pause()
|
|
||||||
video.removeAttribute('src')
|
|
||||||
video.load()
|
|
||||||
|
|
||||||
video.src = manifestUrl
|
|
||||||
video.load()
|
|
||||||
video.play().catch((e) => console.debug('[LiveHlsVideo] play() failed', e))
|
|
||||||
|
|
||||||
// ---- Stall Handling (native) ----
|
|
||||||
let lastProgressTs = Date.now()
|
|
||||||
let lastTime = -1
|
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
|
||||||
if (video.currentTime > lastTime + 0.01) {
|
|
||||||
lastTime = video.currentTime
|
|
||||||
lastProgressTs = Date.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleStallReload = () => {
|
|
||||||
if (stallTimer) return
|
|
||||||
stallTimer = window.setTimeout(() => {
|
|
||||||
stallTimer = null
|
|
||||||
if (!cancelled && Date.now() - lastProgressTs > 3500) hardReload()
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
|
|
||||||
video.addEventListener('timeupdate', onTimeUpdate)
|
|
||||||
video.addEventListener('waiting', scheduleStallReload)
|
|
||||||
video.addEventListener('stalled', scheduleStallReload)
|
|
||||||
video.addEventListener('error', scheduleStallReload)
|
|
||||||
|
|
||||||
watchdogTimer = window.setInterval(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
if (!video.paused && Date.now() - lastProgressTs > 6000) hardReload()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener('timeupdate', onTimeUpdate)
|
|
||||||
video.removeEventListener('waiting', scheduleStallReload)
|
|
||||||
video.removeEventListener('stalled', scheduleStallReload)
|
|
||||||
video.removeEventListener('error', scheduleStallReload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Nicht-Safari: hls.js
|
|
||||||
if (!Hls.isSupported()) {
|
|
||||||
setBroken(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hls = new Hls({
|
|
||||||
lowLatencyMode: false,
|
|
||||||
|
|
||||||
// ✅ Live: nicht super-aggressiv hinterherlaufen
|
|
||||||
liveSyncDurationCount: 3,
|
|
||||||
liveMaxLatencyDurationCount: 10,
|
|
||||||
|
|
||||||
// Buffer
|
|
||||||
maxBufferLength: 12,
|
|
||||||
backBufferLength: 30,
|
|
||||||
|
|
||||||
// ✅ Netzwerk-Retry-Backoff (verhindert Request-Stürme)
|
|
||||||
manifestLoadingTimeOut: 8000,
|
|
||||||
manifestLoadingMaxRetry: 6,
|
|
||||||
manifestLoadingRetryDelay: 1000,
|
|
||||||
manifestLoadingMaxRetryTimeout: 8000,
|
|
||||||
|
|
||||||
levelLoadingTimeOut: 8000,
|
|
||||||
levelLoadingMaxRetry: 6,
|
|
||||||
levelLoadingRetryDelay: 1000,
|
|
||||||
levelLoadingMaxRetryTimeout: 8000,
|
|
||||||
|
|
||||||
fragLoadingTimeOut: 8000,
|
|
||||||
fragLoadingMaxRetry: 6,
|
|
||||||
fragLoadingRetryDelay: 1000,
|
|
||||||
fragLoadingMaxRetryTimeout: 8000,
|
|
||||||
})
|
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
||||||
if (!hls) return
|
|
||||||
|
|
||||||
if (data.fatal) {
|
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
||||||
hls.startLoad()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
||||||
hls.recoverMediaError()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ✅ statt "für immer kaputt": einmal hart reloaden
|
|
||||||
hardReload()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
hls.loadSource(manifestUrl)
|
|
||||||
hls.attachMedia(video)
|
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
||||||
video.play().catch((e) => console.debug('[LiveHlsVideo] play() failed', e))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let nativeCleanup: void | (() => void) = undefined
|
|
||||||
void (async () => {
|
|
||||||
const maybeCleanup = await start()
|
|
||||||
if (typeof maybeCleanup === 'function') nativeCleanup = maybeCleanup
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
cleanupTimers()
|
|
||||||
try {
|
|
||||||
nativeCleanup?.()
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
hls?.destroy()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, [src, muted, manifestUrl]) // <- manifestUrl ist OK, weil reloadKey NICHT im Effect gesetzt wird
|
|
||||||
|
|
||||||
if (broken) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-gray-400 italic">
|
|
||||||
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : '–'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
ref={ref}
|
|
||||||
className={className}
|
|
||||||
playsInline
|
|
||||||
autoPlay
|
|
||||||
muted={muted}
|
|
||||||
preload="auto"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
onClick={() => {
|
|
||||||
const v = ref.current
|
|
||||||
if (v) {
|
|
||||||
v.muted = false
|
|
||||||
v.play().catch(() => {})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
111
frontend/src/components/ui/LiveVideo.tsx
Normal file
111
frontend/src/components/ui/LiveVideo.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// 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,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string
|
||||||
|
muted?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
|
const [broken, setBroken] = useState(false)
|
||||||
|
const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
let watchdogTimer: number | null = null
|
||||||
|
|
||||||
|
const video = ref.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
setBroken(false)
|
||||||
|
setBrokenReason(null)
|
||||||
|
|
||||||
|
applyInlineVideoPolicy(video, { muted })
|
||||||
|
|
||||||
|
const hardReset = () => {
|
||||||
|
try {
|
||||||
|
video.pause()
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) src setzen (fMP4/MP4 stream, single HTTP response)
|
||||||
|
hardReset()
|
||||||
|
video.src = src
|
||||||
|
video.load()
|
||||||
|
video.play().catch(() => {})
|
||||||
|
|
||||||
|
// 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen
|
||||||
|
let lastT = Date.now()
|
||||||
|
const onTime = () => {
|
||||||
|
lastT = Date.now()
|
||||||
|
}
|
||||||
|
video.addEventListener('timeupdate', onTime)
|
||||||
|
|
||||||
|
watchdogTimer = window.setInterval(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
// wenn nicht paused, aber 12s keine timeupdate => neu verbinden
|
||||||
|
if (!video.paused && Date.now() - lastT > 12_000) {
|
||||||
|
hardReset()
|
||||||
|
video.src = src
|
||||||
|
video.load()
|
||||||
|
video.play().catch(() => {})
|
||||||
|
lastT = Date.now()
|
||||||
|
}
|
||||||
|
}, 4_000)
|
||||||
|
|
||||||
|
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
|
||||||
|
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
|
||||||
|
const onError = () => {
|
||||||
|
// best effort: 403/404 kannst du im Backend zusätzlich über Query/JSON o.ä. signalisieren,
|
||||||
|
// aber hier: generic broken
|
||||||
|
setBroken(true)
|
||||||
|
}
|
||||||
|
video.addEventListener('error', onError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||||||
|
watchdogTimer = null
|
||||||
|
video.removeEventListener('timeupdate', onTime)
|
||||||
|
video.removeEventListener('error', onError)
|
||||||
|
hardReset()
|
||||||
|
}
|
||||||
|
}, [src, muted])
|
||||||
|
|
||||||
|
if (broken) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400 italic">
|
||||||
|
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : '–'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
muted={muted}
|
||||||
|
preload="auto"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
onClick={() => {
|
||||||
|
const v = ref.current
|
||||||
|
if (v) {
|
||||||
|
v.muted = false
|
||||||
|
v.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@ import TagOverflowRow from './TagOverflowRow'
|
|||||||
import PreviewScrubber from './PreviewScrubber'
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
import { formatResolution } from './formatters'
|
import { formatResolution } from './formatters'
|
||||||
import Pagination from './Pagination'
|
import Pagination from './Pagination'
|
||||||
import LiveHlsVideo from './LiveHlsVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
@ -2735,8 +2735,8 @@ export default function ModelDetails({
|
|||||||
{/* Live HLS inline (statt Teaser) */}
|
{/* Live HLS inline (statt Teaser) */}
|
||||||
{showLive ? (
|
{showLive ? (
|
||||||
<div className="absolute inset-0 z-10">
|
<div className="absolute inset-0 z-10">
|
||||||
<LiveHlsVideo
|
<LiveVideo
|
||||||
src={`/api/preview/live?id=${encodeURIComponent(String((j as any)?.id ?? ''))}&file=index_hq.m3u8&hover=1`}
|
src={`/api/preview/live?id=${encodeURIComponent(String((j as any)?.id ?? ''))}&hover=1`}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
muted
|
muted
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import HoverPopover from './HoverPopover'
|
import HoverPopover from './HoverPopover'
|
||||||
import LiveHlsVideo from './LiveHlsVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -237,7 +237,7 @@ export default function ModelPreview({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hq = useMemo(
|
const hq = useMemo(
|
||||||
() => `/api/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
|
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
||||||
[jobId]
|
[jobId]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -269,7 +269,7 @@ export default function ModelPreview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<LiveHlsVideo src={hq} muted={true} className="w-full h-full object-contain object-bottom relative z-0" />
|
<LiveVideo src={hq} muted={true} className="w-full h-full object-contain object-bottom relative z-0" />
|
||||||
|
|
||||||
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
|
|||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { apiUrl, apiFetch } from '../../lib/api'
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
import LiveHlsVideo from './LiveHlsVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
|
|
||||||
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||||||
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||||
@ -1762,7 +1762,7 @@ export default function Player({
|
|||||||
>
|
>
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<div className="absolute inset-0 bg-black">
|
<div className="absolute inset-0 bg-black">
|
||||||
<LiveHlsVideo
|
<LiveVideo
|
||||||
src={liveHlsSrc}
|
src={liveHlsSrc}
|
||||||
muted={startMuted}
|
muted={startMuted}
|
||||||
className="w-full h-full object-contain object-bottom"
|
className="w-full h-full object-contain object-bottom"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user