updated pending autostart
This commit is contained in:
parent
fbe9f306a3
commit
f0541b9fe6
253
backend/pending_autostart.go
Normal file
253
backend/pending_autostart.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
// backend\pending_autostart.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PendingAutoStartItem struct {
|
||||||
|
ModelKey string `json:"modelKey"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Mode string `json:"mode,omitempty"` // wait_public | probe_retry
|
||||||
|
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry
|
||||||
|
CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingAutoStartResponse struct {
|
||||||
|
Items []PendingAutoStartItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingAutoStartFile struct {
|
||||||
|
Items []PendingAutoStartItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingAutoStartMu sync.Mutex
|
||||||
|
|
||||||
|
func normalizePendingModeServer(v string) string {
|
||||||
|
if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" {
|
||||||
|
return "probe_retry"
|
||||||
|
}
|
||||||
|
return "wait_public"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePendingShowServer(v string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(v)) {
|
||||||
|
case "public":
|
||||||
|
return "public"
|
||||||
|
case "private":
|
||||||
|
return "private"
|
||||||
|
case "hidden":
|
||||||
|
return "hidden"
|
||||||
|
case "away":
|
||||||
|
return "away"
|
||||||
|
case "offline":
|
||||||
|
return "offline"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeUserKeyForFile(v string) string {
|
||||||
|
re := regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||||
|
return re.ReplaceAllString(v, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingAutoStartFilePath(userKey string) string {
|
||||||
|
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
|
||||||
|
path := pendingAutoStartFilePath(userKey)
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []PendingAutoStartItem{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var f pendingAutoStartFile
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
// kaputte Datei => lieber leer zurückgeben statt alles zu crashen
|
||||||
|
return []PendingAutoStartItem{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Items == nil {
|
||||||
|
f.Items = []PendingAutoStartItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noch einmal sauber normalisieren
|
||||||
|
out := make([]PendingAutoStartItem, 0, len(f.Items))
|
||||||
|
for _, it := range f.Items {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
||||||
|
u := strings.TrimSpace(it.URL)
|
||||||
|
if key == "" || u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, PendingAutoStartItem{
|
||||||
|
ModelKey: key,
|
||||||
|
URL: u,
|
||||||
|
Mode: normalizePendingModeServer(it.Mode),
|
||||||
|
NextProbeAtMs: it.NextProbeAtMs,
|
||||||
|
CurrentShow: normalizePendingShowServer(it.CurrentShow),
|
||||||
|
ImageURL: strings.TrimSpace(it.ImageURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error {
|
||||||
|
path := pendingAutoStartFilePath(userKey)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := pendingAutoStartFile{
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := auth.getSession(r)
|
||||||
|
if sess == nil || !sess.Authed || strings.TrimSpace(sess.User) == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userKey := strings.ToLower(strings.TrimSpace(sess.User))
|
||||||
|
|
||||||
|
pendingAutoStartMu.Lock()
|
||||||
|
defer pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
items, err := loadPendingAutoStartItems(userKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(PendingAutoStartResponse{
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var in PendingAutoStartItem
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "invalid json body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
in.ModelKey = strings.ToLower(strings.TrimSpace(in.ModelKey))
|
||||||
|
in.URL = strings.TrimSpace(in.URL)
|
||||||
|
in.Mode = normalizePendingModeServer(in.Mode)
|
||||||
|
in.CurrentShow = normalizePendingShowServer(in.CurrentShow)
|
||||||
|
in.ImageURL = strings.TrimSpace(in.ImageURL)
|
||||||
|
|
||||||
|
if in.ModelKey == "" {
|
||||||
|
http.Error(w, "missing modelKey", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.URL == "" {
|
||||||
|
http.Error(w, "missing url", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(in.URL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
http.Error(w, "invalid url", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := loadPendingAutoStartItems(userKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced := false
|
||||||
|
for i := range items {
|
||||||
|
if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == in.ModelKey {
|
||||||
|
items[i] = in
|
||||||
|
replaced = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !replaced {
|
||||||
|
items = append(items, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := savePendingAutoStartItems(userKey, items); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
modelKey := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("modelKey")))
|
||||||
|
if modelKey == "" {
|
||||||
|
http.Error(w, "missing modelKey", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := loadPendingAutoStartItems(userKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := make([]PendingAutoStartItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.ToLower(strings.TrimSpace(item.ModelKey)) == modelKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := savePendingAutoStartItems(userKey, next); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/settings/browse", settingsBrowse)
|
api.HandleFunc("/api/settings/browse", settingsBrowse)
|
||||||
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
||||||
|
|
||||||
|
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||||
api.HandleFunc("/api/record", startRecordingFromRequest)
|
api.HandleFunc("/api/record", startRecordingFromRequest)
|
||||||
api.HandleFunc("/api/record/status", recordStatus)
|
api.HandleFunc("/api/record/status", recordStatus)
|
||||||
api.HandleFunc("/api/record/stop", recordStop)
|
api.HandleFunc("/api/record/stop", recordStop)
|
||||||
|
|||||||
@ -202,6 +202,21 @@ type AutostartState = {
|
|||||||
pausedByDisk?: boolean
|
pausedByDisk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingAutoStartMode = 'wait_public' | 'probe_retry'
|
||||||
|
|
||||||
|
type PendingAutoStartItem = {
|
||||||
|
modelKey: string
|
||||||
|
url: string
|
||||||
|
mode?: PendingAutoStartMode
|
||||||
|
nextProbeAtMs?: number
|
||||||
|
currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingAutoStartResponse = {
|
||||||
|
items?: PendingAutoStartItem[]
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||||||
const s = String(v ?? '').trim().toLowerCase()
|
const s = String(v ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
@ -530,6 +545,8 @@ export default function App() {
|
|||||||
|
|
||||||
const doneCountInFlightRef = useRef(false)
|
const doneCountInFlightRef = useRef(false)
|
||||||
const doneCountLastAtRef = useRef(0)
|
const doneCountLastAtRef = useRef(0)
|
||||||
|
const pendingLoadInFlightRef = useRef(false)
|
||||||
|
const pendingLoadLastAtRef = useRef(0)
|
||||||
|
|
||||||
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
||||||
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||||||
@ -560,6 +577,18 @@ export default function App() {
|
|||||||
|
|
||||||
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
|
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
|
||||||
const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
|
const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
|
||||||
|
const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
|
||||||
|
const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey)
|
||||||
|
useEffect(() => {
|
||||||
|
pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey
|
||||||
|
}, [pendingAutoStartModeByKey])
|
||||||
|
|
||||||
|
const pendingBlindRetryAtByKeyRef = useRef(pendingBlindRetryAtByKey)
|
||||||
|
useEffect(() => {
|
||||||
|
pendingBlindRetryAtByKeyRef.current = pendingBlindRetryAtByKey
|
||||||
|
}, [pendingBlindRetryAtByKey])
|
||||||
|
|
||||||
// "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
|
// "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
|
||||||
const busyRef = useRef(false)
|
const busyRef = useRef(false)
|
||||||
@ -650,18 +679,21 @@ export default function App() {
|
|||||||
|
|
||||||
startedToastByJobIdRef.current = {}
|
startedToastByJobIdRef.current = {}
|
||||||
jobsInitDoneRef.current = false
|
jobsInitDoneRef.current = false
|
||||||
|
|
||||||
setPendingWatchedRooms([])
|
setPendingWatchedRooms([])
|
||||||
setPendingAutoStartByKey({})
|
setPendingAutoStartByKey({})
|
||||||
|
setPendingAutoStartModeByKey({})
|
||||||
|
setPendingBlindRetryAtByKey({})
|
||||||
|
|
||||||
|
pendingAutoStartByKeyRef.current = {}
|
||||||
|
pendingAutoStartModeByKeyRef.current = {}
|
||||||
|
pendingBlindRetryAtByKeyRef.current = {}
|
||||||
|
|
||||||
// optional: URL-Feld leeren
|
// optional: URL-Feld leeren
|
||||||
setSourceUrl('')
|
setSourceUrl('')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void checkAuth()
|
|
||||||
}, [checkAuth])
|
|
||||||
|
|
||||||
const isCookieGateError = (msg: string) => {
|
const isCookieGateError = (msg: string) => {
|
||||||
const m = (msg || '').toLowerCase()
|
const m = (msg || '').toLowerCase()
|
||||||
return (
|
return (
|
||||||
@ -869,6 +901,62 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [includeKeep])
|
}, [includeKeep])
|
||||||
|
|
||||||
|
const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => {
|
||||||
|
const force = Boolean(opts?.force)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (pendingLoadInFlightRef.current) return
|
||||||
|
if (!force && now - pendingLoadLastAtRef.current < 1500) return
|
||||||
|
|
||||||
|
pendingLoadInFlightRef.current = true
|
||||||
|
pendingLoadLastAtRef.current = now
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiJSON<PendingAutoStartResponse>('/api/pending-autostart', {
|
||||||
|
cache: 'no-store' as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : []
|
||||||
|
|
||||||
|
const nextByKey: Record<string, string> = {}
|
||||||
|
const nextModeByKey: Record<string, PendingAutoStartMode> = {}
|
||||||
|
const nextBlindRetryAtByKey: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const keyLower = String(item?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
const norm0 = normalizeHttpUrl(String(item?.url ?? ''))
|
||||||
|
const mode: PendingAutoStartMode =
|
||||||
|
String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public'
|
||||||
|
|
||||||
|
if (!keyLower || !norm0) continue
|
||||||
|
|
||||||
|
const norm = canonicalizeProviderUrl(norm0)
|
||||||
|
|
||||||
|
nextByKey[keyLower] = norm
|
||||||
|
nextModeByKey[keyLower] = mode
|
||||||
|
|
||||||
|
if (mode === 'probe_retry') {
|
||||||
|
const ts = Number(item?.nextProbeAtMs ?? 0)
|
||||||
|
nextBlindRetryAtByKey[keyLower] =
|
||||||
|
Number.isFinite(ts) && ts > 0 ? ts : Date.now() + 60_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAutoStartByKey(nextByKey)
|
||||||
|
pendingAutoStartByKeyRef.current = nextByKey
|
||||||
|
|
||||||
|
setPendingAutoStartModeByKey(nextModeByKey)
|
||||||
|
pendingAutoStartModeByKeyRef.current = nextModeByKey
|
||||||
|
|
||||||
|
setPendingBlindRetryAtByKey(nextBlindRetryAtByKey)
|
||||||
|
pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
pendingLoadInFlightRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/record/list', { cache: 'no-store' as any })
|
const res = await fetch('/api/record/list', { cache: 'no-store' as any })
|
||||||
@ -944,6 +1032,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void checkAuth()
|
||||||
|
}, [checkAuth])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed) return
|
||||||
|
void loadPendingAutoStarts()
|
||||||
|
}, [authed, loadPendingAutoStarts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
|
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
|
||||||
@ -1246,13 +1343,21 @@ export default function App() {
|
|||||||
|
|
||||||
const model = modelsByKeyRef.current[keyLower]
|
const model = modelsByKeyRef.current[keyLower]
|
||||||
if (!model?.watching) {
|
if (!model?.watching) {
|
||||||
lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw)
|
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
||||||
|
|
||||||
|
// unknown nicht als "letzten echten Status" speichern
|
||||||
|
if (nextStatus !== 'unknown') {
|
||||||
|
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
||||||
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
|
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
|
||||||
|
|
||||||
|
// unknown niemals melden und auch nicht als letzten echten Status überschreiben
|
||||||
|
if (nextStatus === 'unknown') return
|
||||||
|
|
||||||
// keine Änderung
|
// keine Änderung
|
||||||
if (prevStatus === nextStatus) return
|
if (prevStatus === nextStatus) return
|
||||||
|
|
||||||
@ -1283,7 +1388,7 @@ export default function App() {
|
|||||||
notifyRef.current?.info(title, message, {
|
notifyRef.current?.info(title, message, {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageAlt: displayName,
|
imageAlt: displayName,
|
||||||
durationMs: 5000,
|
durationMs: 3000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('open-model-details', {
|
new CustomEvent('open-model-details', {
|
||||||
@ -1328,6 +1433,17 @@ export default function App() {
|
|||||||
|
|
||||||
if (msg?.type === 'job_upsert') {
|
if (msg?.type === 'job_upsert') {
|
||||||
if (modelKey) {
|
if (modelKey) {
|
||||||
|
const statusLower = String(msg?.status ?? '').trim().toLowerCase()
|
||||||
|
const phaseLower = String(msg?.phase ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const isActiveVisibleRecording =
|
||||||
|
statusLower === 'running' &&
|
||||||
|
(phaseLower === '' || phaseLower === 'recording')
|
||||||
|
|
||||||
|
if (isActiveVisibleRecording) {
|
||||||
|
removePendingAutoStart(modelKey)
|
||||||
|
}
|
||||||
|
|
||||||
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (roomStatus) {
|
if (roomStatus) {
|
||||||
@ -1484,7 +1600,8 @@ export default function App() {
|
|||||||
type StartQueueItem = {
|
type StartQueueItem = {
|
||||||
url: string
|
url: string
|
||||||
silent: boolean
|
silent: boolean
|
||||||
pendingKeyLower?: string // wenn aus pendingAutoStartByKey kommt
|
pendingKeyLower?: string
|
||||||
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const startQueueRef = useRef<StartQueueItem[]>([])
|
const startQueueRef = useRef<StartQueueItem[]>([])
|
||||||
@ -1498,11 +1615,13 @@ export default function App() {
|
|||||||
busyRef.current = v
|
busyRef.current = v
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function doStartNow(normUrl: string, silent: boolean): Promise<boolean> {
|
async function doStartNow(
|
||||||
|
normUrl: string,
|
||||||
|
silent: boolean,
|
||||||
|
opts?: { hidden?: boolean }
|
||||||
|
): Promise<boolean> {
|
||||||
normUrl = canonicalizeProviderUrl(normUrl)
|
normUrl = canonicalizeProviderUrl(normUrl)
|
||||||
|
|
||||||
// ✅ Duplicate-running guard (wie vorher)
|
|
||||||
const alreadyRunning = jobsRef.current.some((j) => {
|
const alreadyRunning = jobsRef.current.some((j) => {
|
||||||
if (String(j.status || '').toLowerCase() !== 'running') return false
|
if (String(j.status || '').toLowerCase() !== 'running') return false
|
||||||
if ((j as any).endedAt) return false
|
if ((j as any).endedAt) return false
|
||||||
@ -1533,23 +1652,29 @@ export default function App() {
|
|||||||
const created = await apiJSON<RecordJob>('/api/record', {
|
const created = await apiJSON<RecordJob>('/api/record', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: normUrl, cookie: cookieString }),
|
body: JSON.stringify({
|
||||||
|
url: normUrl,
|
||||||
|
cookie: cookieString,
|
||||||
|
hidden: Boolean(opts?.hidden),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
|
if (created?.id && !opts?.hidden) {
|
||||||
|
startedToastByJobIdRef.current[String(created.id)] = true
|
||||||
|
}
|
||||||
|
|
||||||
// UI sofort aktualisieren (optional)
|
if (!(opts?.hidden || Boolean((created as any)?.hidden))) {
|
||||||
setJobs((prev) => {
|
setJobs((prev) => {
|
||||||
const next = upsertJobById(prev, created)
|
const next = upsertJobById(prev, created)
|
||||||
jobsRef.current = next
|
jobsRef.current = next
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message ?? String(e)
|
const msg = e?.message ?? String(e)
|
||||||
|
|
||||||
// ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
|
|
||||||
if (isCookieGateError(msg)) {
|
if (isCookieGateError(msg)) {
|
||||||
showMissingCookiesMessage({ silent })
|
showMissingCookiesMessage({ silent })
|
||||||
return false
|
return false
|
||||||
@ -1569,7 +1694,7 @@ export default function App() {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const ok = await doStartNow(next.url, next.silent)
|
const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden })
|
||||||
|
|
||||||
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
|
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
|
||||||
if (ok && next.pendingKeyLower) {
|
if (ok && next.pendingKeyLower) {
|
||||||
@ -1706,26 +1831,63 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||||
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
||||||
|
|
||||||
const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => {
|
const queuePendingAutoStart = useCallback((
|
||||||
if (!keyLower) return
|
keyLower: string,
|
||||||
|
url: string,
|
||||||
|
show?: string,
|
||||||
|
imageUrl?: string,
|
||||||
|
opts?: {
|
||||||
|
mode?: PendingAutoStartMode
|
||||||
|
nextProbeAtMs?: number
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const key = String(keyLower ?? '').trim().toLowerCase()
|
||||||
|
const norm0 = normalizeHttpUrl(String(url ?? ''))
|
||||||
|
if (!key || !norm0) return
|
||||||
|
|
||||||
|
const norm = canonicalizeProviderUrl(norm0)
|
||||||
|
const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public'
|
||||||
|
const nextProbeAtMs =
|
||||||
|
mode === 'probe_retry'
|
||||||
|
? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000))
|
||||||
|
: undefined
|
||||||
|
|
||||||
setPendingAutoStartByKey((prev) => {
|
setPendingAutoStartByKey((prev) => {
|
||||||
const next = { ...(prev || {}), [keyLower]: url }
|
const next = { ...(prev || {}), [key]: norm }
|
||||||
pendingAutoStartByKeyRef.current = next
|
pendingAutoStartByKeyRef.current = next
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setPendingAutoStartModeByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}), [key]: mode }
|
||||||
|
pendingAutoStartModeByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setPendingBlindRetryAtByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}) }
|
||||||
|
|
||||||
|
if (mode === 'probe_retry') {
|
||||||
|
next[key] = Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : Date.now() + 60_000
|
||||||
|
} else {
|
||||||
|
delete next[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingBlindRetryAtByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) => {
|
setPendingWatchedRooms((prev) => {
|
||||||
const nextItem: PendingWatchedRoom = {
|
const nextItem: PendingWatchedRoom = {
|
||||||
id: keyLower,
|
id: key,
|
||||||
modelKey: keyLower,
|
modelKey: key,
|
||||||
url,
|
url: norm,
|
||||||
currentShow: normalizePendingShow(show),
|
currentShow: normalizePendingShow(show),
|
||||||
imageUrl: imageUrl || undefined,
|
imageUrl: imageUrl || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx = prev.findIndex(
|
const idx = prev.findIndex(
|
||||||
(x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower
|
(x) => String(x.modelKey ?? '').trim().toLowerCase() === key
|
||||||
)
|
)
|
||||||
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@ -1736,21 +1898,77 @@ export default function App() {
|
|||||||
|
|
||||||
return [nextItem, ...prev]
|
return [nextItem, ...prev]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
|
||||||
|
const prevMode = String(pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public').trim()
|
||||||
|
const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
|
||||||
|
const nextShow = normalizePendingShow(show)
|
||||||
|
const nextRetryAt = mode === 'probe_retry'
|
||||||
|
? (Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 0)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const sameUrl = prevUrl === norm
|
||||||
|
const sameMode = prevMode === mode
|
||||||
|
const sameRetryAt =
|
||||||
|
mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000
|
||||||
|
|
||||||
|
if (sameUrl && sameMode && sameRetryAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetch('/api/pending-autostart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
modelKey: key,
|
||||||
|
url: norm,
|
||||||
|
mode,
|
||||||
|
nextProbeAtMs: mode === 'probe_retry' ? nextProbeAtMs : undefined,
|
||||||
|
currentShow: nextShow,
|
||||||
|
imageUrl: imageUrl || undefined,
|
||||||
|
}),
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const removePendingAutoStart = useCallback((keyLower: string) => {
|
const removePendingAutoStart = useCallback((keyLower: string) => {
|
||||||
if (!keyLower) return
|
const key = String(keyLower ?? '').trim().toLowerCase()
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const hadPending =
|
||||||
|
Boolean(pendingAutoStartByKeyRef.current[key]) ||
|
||||||
|
Boolean(pendingAutoStartModeByKeyRef.current[key]) ||
|
||||||
|
Boolean(pendingBlindRetryAtByKeyRef.current[key])
|
||||||
|
|
||||||
|
if (!hadPending) return
|
||||||
|
|
||||||
setPendingAutoStartByKey((prev) => {
|
setPendingAutoStartByKey((prev) => {
|
||||||
const next = { ...(prev || {}) }
|
const next = { ...(prev || {}) }
|
||||||
delete next[keyLower]
|
delete next[key]
|
||||||
pendingAutoStartByKeyRef.current = next
|
pendingAutoStartByKeyRef.current = next
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setPendingAutoStartModeByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}) }
|
||||||
|
delete next[key]
|
||||||
|
pendingAutoStartModeByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setPendingBlindRetryAtByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}) }
|
||||||
|
delete next[key]
|
||||||
|
pendingBlindRetryAtByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) =>
|
setPendingWatchedRooms((prev) =>
|
||||||
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower)
|
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
void fetch(`/api/pending-autostart?modelKey=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedTabRef = useRef(selectedTab)
|
const selectedTabRef = useRef(selectedTab)
|
||||||
@ -1844,21 +2062,31 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const next: PendingWatchedRoom[] = keys.map((rawKey) => {
|
setPendingWatchedRooms((prev) => {
|
||||||
const key = String(rawKey ?? '').trim().toLowerCase()
|
const prevByKey = new Map(
|
||||||
const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim()
|
prev.map((x) => [String(x.modelKey ?? '').trim().toLowerCase(), x] as const)
|
||||||
const model = (modelsByKey as any)?.[key] ?? null
|
)
|
||||||
|
|
||||||
return {
|
const next: PendingWatchedRoom[] = keys.map((rawKey) => {
|
||||||
id: key,
|
const key = String(rawKey ?? '').trim().toLowerCase()
|
||||||
modelKey: key,
|
const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim()
|
||||||
url,
|
const model = (modelsByKey as any)?.[key] ?? null
|
||||||
currentShow: normalizePendingShow(model?.roomStatus),
|
const prevItem = prevByKey.get(key)
|
||||||
imageUrl: String(model?.imageUrl ?? '').trim() || undefined,
|
|
||||||
}
|
return {
|
||||||
|
id: key,
|
||||||
|
modelKey: key,
|
||||||
|
url,
|
||||||
|
currentShow: normalizePendingShow(model?.roomStatus ?? prevItem?.currentShow),
|
||||||
|
imageUrl:
|
||||||
|
String(model?.imageUrl ?? '').trim() ||
|
||||||
|
prevItem?.imageUrl ||
|
||||||
|
undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setPendingWatchedRooms(next)
|
|
||||||
}, [pendingAutoStartByKey, modelsByKey])
|
}, [pendingAutoStartByKey, modelsByKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1924,10 +2152,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modeMap = pendingAutoStartModeByKeyRef.current || {}
|
||||||
|
const retryAtMap = pendingBlindRetryAtByKeyRef.current || {}
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const snap =
|
const snap =
|
||||||
byKey[key] ??
|
byKey[key] ?? {
|
||||||
{
|
|
||||||
show: 'unknown' as const,
|
show: 'unknown' as const,
|
||||||
imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
|
imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
|
||||||
chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
|
chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
|
||||||
@ -1942,6 +2172,8 @@ export default function App() {
|
|||||||
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
||||||
if (!url) continue
|
if (!url) continue
|
||||||
|
|
||||||
|
const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode
|
||||||
|
|
||||||
if (snap.show === 'public') {
|
if (snap.show === 'public') {
|
||||||
enqueueStart({
|
enqueueStart({
|
||||||
url,
|
url,
|
||||||
@ -1951,12 +2183,39 @@ export default function App() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snap.show === 'offline') {
|
if (mode === 'probe_retry') {
|
||||||
removePendingAutoStart(key)
|
// Sobald API das Model als private/hidden/away kennt:
|
||||||
|
// nicht mehr blind probieren, sondern einfach auf public warten.
|
||||||
|
if (
|
||||||
|
snap.show === 'private' ||
|
||||||
|
snap.show === 'hidden' ||
|
||||||
|
snap.show === 'away'
|
||||||
|
) {
|
||||||
|
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
|
||||||
|
mode: 'wait_public',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProbeAtMs = Number(retryAtMap[key] ?? 0)
|
||||||
|
if (!busyRef.current && Date.now() >= nextProbeAtMs) {
|
||||||
|
enqueueStart({
|
||||||
|
url,
|
||||||
|
silent: true,
|
||||||
|
hidden: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
|
||||||
|
mode: 'probe_retry',
|
||||||
|
nextProbeAtMs: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// private / hidden / away / unknown bleiben in der Warteschlange
|
// mode === 'wait_public'
|
||||||
|
// private / hidden / away / offline / unknown => einfach warten
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -1965,12 +2224,20 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = (ms: number) => {
|
const schedule = (ms?: number) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
|
const retryMap = pendingBlindRetryAtByKeyRef.current || {}
|
||||||
|
const hasProbeRetry = Object.keys(retryMap).length > 0
|
||||||
|
|
||||||
|
const nextMs =
|
||||||
|
ms ??
|
||||||
|
(hasProbeRetry ? 1000 : (document.hidden ? 8000 : 3000))
|
||||||
|
|
||||||
timer = window.setTimeout(async () => {
|
timer = window.setTimeout(async () => {
|
||||||
await tick()
|
await tick()
|
||||||
schedule(document.hidden ? 8000 : 3000)
|
schedule()
|
||||||
}, ms)
|
}, nextMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule(0)
|
schedule(0)
|
||||||
@ -2101,7 +2368,10 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
if (alreadyRunning) return true
|
if (alreadyRunning) return true
|
||||||
|
|
||||||
// ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart
|
// ✅ Chaturbate-Startlogik mit Online-API:
|
||||||
|
// public -> direkt starten
|
||||||
|
// private/hidden/away -> wait_public
|
||||||
|
// offline/unknown -> probe_retry mit hidden start
|
||||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
|
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
|
||||||
try {
|
try {
|
||||||
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
||||||
@ -2116,14 +2386,13 @@ export default function App() {
|
|||||||
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
|
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
|
||||||
let resolvedImageUrl: string | undefined
|
let resolvedImageUrl: string | undefined
|
||||||
let resolvedChatRoomUrl: string | undefined
|
let resolvedChatRoomUrl: string | undefined
|
||||||
|
let foundInApi = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ q: [mkLower] }),
|
body: JSON.stringify({ q: [mkLower] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -2134,6 +2403,7 @@ export default function App() {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
|
foundInApi = true
|
||||||
resolvedShow = normalizePendingShow(room.current_show)
|
resolvedShow = normalizePendingShow(room.current_show)
|
||||||
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
|
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
|
||||||
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
|
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
|
||||||
@ -2149,27 +2419,47 @@ export default function App() {
|
|||||||
chatRoomUrl: resolvedChatRoomUrl,
|
chatRoomUrl: resolvedChatRoomUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
// 1) in API + public => sofort normal starten
|
||||||
resolvedShow === 'private' ||
|
if (foundInApi && resolvedShow === 'public') {
|
||||||
resolvedShow === 'hidden' ||
|
removePendingAutoStart(mkLower)
|
||||||
resolvedShow === 'away' ||
|
// unten normal weiter
|
||||||
resolvedShow === 'unknown'
|
} else if (
|
||||||
|
foundInApi &&
|
||||||
|
(resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away')
|
||||||
) {
|
) {
|
||||||
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl)
|
// 2) in API + nicht public => warten bis public
|
||||||
|
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
|
||||||
|
mode: 'wait_public',
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
// 3) nicht in API ODER offline/unknown => Blind-Probe jetzt starten
|
||||||
|
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
|
||||||
|
mode: 'probe_retry',
|
||||||
|
nextProbeAtMs: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ok = await doStartNow(norm, silent, { hidden: true })
|
||||||
|
if (!ok) {
|
||||||
|
removePendingAutoStart(mkLower)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedShow === 'offline') {
|
|
||||||
removePendingAutoStart(mkLower)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// public => unten normal starten
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const mkLower = providerKeyLowerFromUrl(norm)
|
const mkLower = providerKeyLowerFromUrl(norm)
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
queuePendingAutoStart(mkLower, norm, 'unknown')
|
queuePendingAutoStart(mkLower, norm, 'unknown', undefined, {
|
||||||
|
mode: 'probe_retry',
|
||||||
|
nextProbeAtMs: Date.now() + 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ok = await doStartNow(norm, silent, { hidden: true })
|
||||||
|
if (!ok) {
|
||||||
|
removePendingAutoStart(mkLower)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2566,7 +2856,7 @@ export default function App() {
|
|||||||
eventSourceRef.current = null
|
eventSourceRef.current = null
|
||||||
modelEventNamesRef.current = new Set()
|
modelEventNamesRef.current = new Set()
|
||||||
}
|
}
|
||||||
}, [authed, loadJobs, loadDoneCount])
|
}, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const desired = new Set<string>()
|
const desired = new Set<string>()
|
||||||
|
|||||||
@ -559,6 +559,8 @@ export default function Downloads({
|
|||||||
|
|
||||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||||
|
|
||||||
|
const [thumbTick, setThumbTick] = useState(0)
|
||||||
|
|
||||||
const refreshWatchedState = useCallback(async () => {
|
const refreshWatchedState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await onRefreshAutostartState?.()
|
await onRefreshAutostartState?.()
|
||||||
@ -567,6 +569,29 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
}, [onRefreshAutostartState])
|
}, [onRefreshAutostartState])
|
||||||
|
|
||||||
|
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onVis = () => setPageVisible(!document.hidden)
|
||||||
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVis)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasLiveJobs = jobs.some((j) => {
|
||||||
|
if ((j as any).endedAt) return false
|
||||||
|
return String(j.status ?? '').trim().toLowerCase() === 'running'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasLiveJobs || !pageVisible) return
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setThumbTick((t) => t + 1)
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [jobs, pageVisible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPaused = Boolean(autostartState?.paused)
|
const nextPaused = Boolean(autostartState?.paused)
|
||||||
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
||||||
@ -922,6 +947,7 @@ export default function Downloads({
|
|||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
live={true}
|
live={true}
|
||||||
|
thumbTick={thumbTick}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={effectiveRoomStatusOfJob(
|
roomStatus={effectiveRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
@ -1571,6 +1597,7 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
|
thumbTick={thumbTick}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
@ -1628,6 +1655,7 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
|
thumbTick={thumbTick}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
@ -1709,6 +1737,7 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
|
thumbTick={thumbTick}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ type ModelFlags = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
r: DownloadRow
|
r: DownloadRow
|
||||||
nowMs: number
|
nowMs: number
|
||||||
|
thumbTick?: number
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
modelsByKey: Record<string, ModelFlags>
|
modelsByKey: Record<string, ModelFlags>
|
||||||
roomStatusByModelKey: Record<string, string>
|
roomStatusByModelKey: Record<string, string>
|
||||||
@ -421,6 +422,7 @@ function postWorkLabel(
|
|||||||
export default function DownloadsCardRow({
|
export default function DownloadsCardRow({
|
||||||
r,
|
r,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
thumbTick,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
modelsByKey,
|
modelsByKey,
|
||||||
roomStatusByModelKey,
|
roomStatusByModelKey,
|
||||||
@ -673,6 +675,7 @@ export default function DownloadsCardRow({
|
|||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
live={true}
|
live={true}
|
||||||
|
thumbTick={thumbTick}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={previewRoomStatusOfJob(
|
roomStatus={previewRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
|
|||||||
@ -16,7 +16,6 @@ type Props = {
|
|||||||
modelKey?: string
|
modelKey?: string
|
||||||
live?: boolean
|
live?: boolean
|
||||||
thumbTick?: number
|
thumbTick?: number
|
||||||
autoTickMs?: number
|
|
||||||
blur?: boolean
|
blur?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
fit?: 'cover' | 'contain'
|
fit?: 'cover' | 'contain'
|
||||||
@ -44,7 +43,6 @@ export default function ModelPreview({
|
|||||||
jobId,
|
jobId,
|
||||||
live = false,
|
live = false,
|
||||||
thumbTick,
|
thumbTick,
|
||||||
autoTickMs = 10_000,
|
|
||||||
blur = false,
|
blur = false,
|
||||||
className,
|
className,
|
||||||
fit = 'cover',
|
fit = 'cover',
|
||||||
@ -53,8 +51,8 @@ export default function ModelPreview({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [inView, setInView] = useState(false)
|
const [inView, setInView] = useState(false)
|
||||||
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
const [liveSrc, setLiveSrc] = useState('')
|
const [liveSrc, setLiveSrc] = useState('')
|
||||||
const [hoverOpen, setHoverOpen] = useState(false)
|
const [hoverOpen, setHoverOpen] = useState(false)
|
||||||
|
|
||||||
@ -82,16 +80,19 @@ export default function ModelPreview({
|
|||||||
|
|
||||||
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
|
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
|
||||||
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
|
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
|
||||||
|
|
||||||
const [previewVersion, setPreviewVersion] = useState(0)
|
|
||||||
const [imgLoading, setImgLoading] = useState(false)
|
|
||||||
|
|
||||||
const previewSrc = useMemo(() => {
|
const previewSrc = useMemo(() => {
|
||||||
if (!live) {
|
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
||||||
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
|
||||||
|
if (!live) return base
|
||||||
|
|
||||||
|
// thumbTick kommt zentral vom Parent
|
||||||
|
if (typeof thumbTick === 'number') {
|
||||||
|
return `${base}&v=${thumbTick}`
|
||||||
}
|
}
|
||||||
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${previewVersion}`
|
|
||||||
}, [jobId, live, previewVersion])
|
return base
|
||||||
|
}, [jobId, live, thumbTick])
|
||||||
|
|
||||||
const fallbackImgSrc = useMemo(() => {
|
const fallbackImgSrc = useMemo(() => {
|
||||||
const s = String(fallbackSrc ?? '').trim()
|
const s = String(fallbackSrc ?? '').trim()
|
||||||
@ -100,26 +101,20 @@ export default function ModelPreview({
|
|||||||
}, [fallbackSrc, jobId])
|
}, [fallbackSrc, jobId])
|
||||||
|
|
||||||
const liveStreamSrc = useMemo(
|
const liveStreamSrc = useMemo(
|
||||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1`,
|
||||||
[jobId]
|
[jobId]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVis = () => setPageVisible(!document.hidden)
|
setImgSrc(previewSrc)
|
||||||
document.addEventListener('visibilitychange', onVis)
|
setImgError(false)
|
||||||
return () => document.removeEventListener('visibilitychange', onVis)
|
}, [previewSrc])
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inView) return
|
|
||||||
setImgLoading(true)
|
|
||||||
}, [previewSrc, inView])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
|
|
||||||
const loadLive = async () => {
|
const loadLive = async () => {
|
||||||
if (!hoverOpen) {
|
if (!live || !hoverOpen) {
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
@ -134,7 +129,7 @@ export default function ModelPreview({
|
|||||||
try {
|
try {
|
||||||
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
|
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
|
||||||
const prepareRes = await fetch(
|
const prepareRes = await fetch(
|
||||||
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&prepare=1`,
|
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@ -170,7 +165,7 @@ export default function ModelPreview({
|
|||||||
return () => {
|
return () => {
|
||||||
ac.abort()
|
ac.abort()
|
||||||
}
|
}
|
||||||
}, [hoverOpen, jobId, liveStreamSrc])
|
}, [hoverOpen, live, jobId, liveStreamSrc])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
@ -193,25 +188,11 @@ export default function ModelPreview({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
|
setImgSrc(previewSrc)
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
}, [jobId])
|
}, [jobId, previewSrc])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!live) return
|
|
||||||
if (typeof thumbTick === 'number') return
|
|
||||||
if (!inView) return
|
|
||||||
if (!pageVisible) return
|
|
||||||
if (imgError) return
|
|
||||||
if (imgLoading) return
|
|
||||||
|
|
||||||
const id = window.setTimeout(() => {
|
|
||||||
setPreviewVersion((x) => x + 1)
|
|
||||||
}, autoTickMs)
|
|
||||||
|
|
||||||
return () => window.clearTimeout(id)
|
|
||||||
}, [live, thumbTick, inView, pageVisible, autoTickMs, imgError, imgLoading, previewVersion])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
@ -317,28 +298,30 @@ export default function ModelPreview({
|
|||||||
{inView ? (
|
{inView ? (
|
||||||
!imgError ? (
|
!imgError ? (
|
||||||
<img
|
<img
|
||||||
src={previewSrc}
|
src={imgSrc || previewSrc}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
alt=""
|
alt=""
|
||||||
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImgLoading(false)
|
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setImgLoading(false)
|
const current = String(imgSrc || '').trim()
|
||||||
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
|
||||||
|
// Erst von preview.jpg auf Provider-Fallback wechseln
|
||||||
|
if (fallback && current !== fallback) {
|
||||||
|
setImgSrc(fallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn selbst das Fallback nicht lädt: endgültig Fehler
|
||||||
setImgError(true)
|
setImgError(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
||||||
src={fallbackImgSrc}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
alt=""
|
|
||||||
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
||||||
|
|||||||
@ -21,6 +21,23 @@ import Button from './Button'
|
|||||||
import { apiUrl, apiFetch } from '../../lib/api'
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
import LiveVideo from './LiveVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
import { formatResolution, formatFps } from './formatters'
|
import { formatResolution, formatFps } from './formatters'
|
||||||
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
|
||||||
|
function buildChaturbateCookieHeader(): string {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('record_cookies')
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const obj = JSON.parse(raw) as Record<string, string>
|
||||||
|
return Object.entries(obj ?? {})
|
||||||
|
.map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const)
|
||||||
|
.filter(([k, v]) => k && v)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@ -312,6 +329,9 @@ export default function Player({
|
|||||||
|
|
||||||
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
||||||
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
||||||
|
const [livePreparing, setLivePreparing] = React.useState(false)
|
||||||
|
const [livePreparedSrc, setLivePreparedSrc] = React.useState('')
|
||||||
|
const [liveReady, setLiveReady] = React.useState(false)
|
||||||
|
|
||||||
// ✅ Backend erwartet "id=" (nicht "name=")
|
// ✅ Backend erwartet "id=" (nicht "name=")
|
||||||
// running: echte job.id (jobs-map lookup)
|
// running: echte job.id (jobs-map lookup)
|
||||||
@ -416,6 +436,65 @@ export default function Player({
|
|||||||
|
|
||||||
const [previewSrc, setPreviewSrc] = React.useState(previewA)
|
const [previewSrc, setPreviewSrc] = React.useState(previewA)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
setLivePreparing(false)
|
||||||
|
setLivePreparedSrc('')
|
||||||
|
setLiveReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const ac = new AbortController()
|
||||||
|
|
||||||
|
const prepareLive = async () => {
|
||||||
|
setLivePreparing(true)
|
||||||
|
setLiveReady(false)
|
||||||
|
setLivePreparedSrc('')
|
||||||
|
|
||||||
|
const cookieHeader = buildChaturbateCookieHeader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prepareUrl = apiUrl(
|
||||||
|
`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1&prepare=1`
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await apiFetch(prepareUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ac.signal,
|
||||||
|
headers: cookieHeader
|
||||||
|
? { 'X-Chaturbate-Cookie': cookieHeader }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cancelled || ac.signal.aborted) return
|
||||||
|
|
||||||
|
await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (cancelled || ac.signal.aborted) return
|
||||||
|
|
||||||
|
setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`)
|
||||||
|
} catch {
|
||||||
|
if (cancelled || ac.signal.aborted) return
|
||||||
|
|
||||||
|
// best effort: Stream trotzdem versuchen
|
||||||
|
setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && !ac.signal.aborted) {
|
||||||
|
setLivePreparing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void prepareLive()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
ac.abort()
|
||||||
|
}
|
||||||
|
}, [isLive, previewId, liveHlsSrc])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPreviewSrc(previewA)
|
setPreviewSrc(previewA)
|
||||||
}, [previewA])
|
}, [previewA])
|
||||||
@ -1706,9 +1785,15 @@ export default function Player({
|
|||||||
{isLive ? (
|
{isLive ? (
|
||||||
<div className="absolute inset-0 bg-black">
|
<div className="absolute inset-0 bg-black">
|
||||||
<LiveVideo
|
<LiveVideo
|
||||||
src={liveHlsSrc}
|
src={livePreparedSrc}
|
||||||
muted={liveMuted}
|
muted={liveMuted}
|
||||||
volume={liveVolume}
|
volume={liveVolume}
|
||||||
|
onLoadingStart={() => {
|
||||||
|
setLiveReady(false)
|
||||||
|
}}
|
||||||
|
onReady={() => {
|
||||||
|
setLiveReady(true)
|
||||||
|
}}
|
||||||
onVolumeChange={(nextVolume, nextMuted) => {
|
onVolumeChange={(nextVolume, nextMuted) => {
|
||||||
setLiveVolume(nextVolume)
|
setLiveVolume(nextVolume)
|
||||||
setLiveMuted(nextMuted)
|
setLiveMuted(nextMuted)
|
||||||
@ -1716,6 +1801,15 @@ export default function Player({
|
|||||||
className="w-full h-full object-cover object-center"
|
className="w-full h-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(livePreparing || !liveReady) ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
|
||||||
|
<LoadingSpinner />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
|
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user