(function () { const root = document.getElementById("regia-app"); if (!root) return; const DEFAULT_I18N = { periodUpdated: "Periodo aggiornato", nowInPeriodTemplate: "Ora in %{period}", setWonTitle: "Set vinto", setWonBodyTemplate: "%{winner} ha vinto il set. Chiudere il set?", matchWonTitle: "Partita vinta", matchWonBodyTemplate: "%{winner} ha vinto la partita. Chiudere la diretta?", closeSetTitle: "Chiudi set", closeSetConfirm: "Confermi la chiusura del set corrente?", scoreUpdateError: "Errore aggiornamento punteggio", genericError: "Errore", setsLineTemplate: "Set %{current} · Set vinti %{home}-%{away}", setPartialLabelTemplate: "Set %{number}", freeScore: "Punteggio libero", stopwatch: "Cronometro", quarterLabelTemplate: "Q%{n}", halfLabelTemplate: "%{n}° tempo", overtimeBasketTemplate: "OT%{n}", overtimeOther: "Suppl.", unknownPeriod: "—", partialsPrefix: "Parziali:", resumed: "Diretta ripresa", pausedCover: "Diretta in pausa — copertina in onda", muted: "Audio silenziato — YouTube non riceve il microfono", unmuted: "Audio microfono riattivato", closed: "Diretta chiusa", resumeError: "Errore ripresa diretta", pauseError: "Errore pausa diretta", muteError: "Errore silenziamento audio", closeError: "Errore chiusura", closeConfirm: "Chiudere definitivamente la diretta?", linkUnavailable: "Link non disponibile", linkCopied: "Link copiato", copyPrompt: "Copia il link:", shareRegiaText: "Apri questo link per gestire il punteggio della diretta:", shareLiveText: "Guarda la diretta su Match Live TV:", previewWaiting: "Anteprima in attesa del segnale…", streamEnded: "Diretta terminata", previewHide: "Nascondi anteprima", previewShow: "Mostra anteprima", previewOffHint: "Anteprima spenta · nessun download video", resumeLabel: "Riprendi diretta", pauseLabel: "Metti in pausa", muteLabel: "Silenzia audio", unmuteLabel: "Riattiva audio", endedBadge: "Terminata", pausedBadge: "In pausa", liveBadge: "In onda", waitingBadge: "In attesa", subtitlePrefix: "Regia ·", minQualityUpdated: "Qualità minima aggiornata", minQualityError: "Errore aggiornamento qualità minima", minQualityAuto: "Automatica", telemetryWaiting: "In attesa del telefono…", telemetryStale: "Telefono non risponde", telemetryJustNow: "ora", telemetrySecondsAgo: "%{n}s fa", telemetryPublisherOn: "RTMP ok", telemetryPublisherOff: "RTMP assente", telemetryThermalNominal: "OK", telemetryThermalFair: "Calda", telemetryThermalSerious: "Calda", telemetryThermalCritical: "Critica", telemetryBitrateMax: "max %{n}", telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps", telemetryBitrateTap: "Tocca per impostare la qualità minima" }; function loadI18n() { const raw = root.dataset.i18n; if (!raw) return DEFAULT_I18N; try { const parsed = JSON.parse(raw); const merged = {}; for (const key in DEFAULT_I18N) { merged[key] = parsed[key] || DEFAULT_I18N[key]; } return merged; } catch (err) { return DEFAULT_I18N; } } const I18N = loadI18n(); function fillTemplate(template, values) { return template.replace(/%\{(\w+)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match ); } const cfg = { token: root.dataset.token, board: root.dataset.board || "volley", statusUrl: root.dataset.statusUrl, scoreUrl: root.dataset.scoreUrl, pauseUrl: root.dataset.pauseUrl, resumeUrl: root.dataset.resumeUrl, audioMuteUrl: root.dataset.audioMuteUrl, stopUrl: root.dataset.stopUrl, minQualityUrl: root.dataset.minQualityUrl, cableUrl: root.dataset.cableUrl, hlsUrl: root.dataset.hlsUrl, homeName: root.dataset.homeName, awayName: root.dataset.awayName, regiaShareUrl: root.dataset.regiaShareUrl, regiaShareTitle: root.dataset.regiaShareTitle, liveShareUrl: root.dataset.liveShareUrl, liveShareTitle: root.dataset.liveShareTitle, streamClosed: root.dataset.streamClosed === "true" }; const els = { badge: document.getElementById("regia-badge"), setsLine: document.getElementById("sets-line"), scoreLine: document.getElementById("score-line"), partialsLine: document.getElementById("partials-line"), homePoints: document.getElementById("home-points"), toast: document.getElementById("regia-toast"), modal: document.getElementById("regia-modal"), modalTitle: document.getElementById("modal-title"), modalBody: document.getElementById("modal-body"), modalConfirm: document.getElementById("modal-confirm"), modalCancel: document.getElementById("modal-cancel"), preview: document.getElementById("regia-preview"), previewPlaceholder: document.getElementById("regia-preview-placeholder"), previewWrap: document.getElementById("regia-preview-wrap"), btnTogglePreview: document.getElementById("btn-toggle-preview"), btnPause: document.getElementById("btn-pause"), btnAudioMute: document.getElementById("btn-audio-mute"), minQuality: document.getElementById("min-quality"), subtitle: document.getElementById("regia-subtitle"), telemetry: document.getElementById("regia-telemetry"), telBattery: document.getElementById("tel-battery"), telNetwork: document.getElementById("tel-network"), telBitrate: document.getElementById("tel-bitrate"), telFps: document.getElementById("tel-fps"), telThermal: document.getElementById("tel-thermal"), telMeta: document.getElementById("tel-meta"), qualityPanel: document.getElementById("regia-quality") }; let pendingAction = null; let hls = null; let previewStarted = false; let previewWanted = true; let wasPausedPreview = false; let streamPaused = root.dataset.initialPaused === "true"; let audioMuted = root.dataset.initialAudioMuted === "true"; const PREVIEW_PREF_KEY = "mltv-regia-preview"; function hlsUrlFresh() { const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; return `${cfg.hlsUrl}${sep}_t=${Date.now()}`; } function hidePreviewPlaceholder() { if (els.previewPlaceholder) els.previewPlaceholder.hidden = true; } function showPreviewPlaceholder(text) { if (!els.previewPlaceholder) return; els.previewPlaceholder.textContent = text; els.previewPlaceholder.hidden = false; } function destroyPreview() { previewStarted = false; if (hls) { hls.destroy(); hls = null; } if (els.preview) { els.preview.removeAttribute("src"); els.preview.load(); } } function readPreviewPref() { try { return localStorage.getItem(PREVIEW_PREF_KEY) !== "0"; } catch (_) { return true; } } function writePreviewPref(on) { try { localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0"); } catch (_) {} } function syncPreviewToggleUi() { if (!els.previewWrap || !els.btnTogglePreview) return; const label = previewWanted ? I18N.previewHide : I18N.previewShow; els.previewWrap.classList.toggle("is-off", !previewWanted); els.btnTogglePreview.classList.toggle("is-off", !previewWanted); els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false"); els.btnTogglePreview.setAttribute("aria-label", label); els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint; } function setPreviewWanted(on) { previewWanted = !!on; writePreviewPref(previewWanted); syncPreviewToggleUi(); if (previewWanted) { startPreview(); } else { destroyPreview(); } } function refreshPreviewForCover() { if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return; destroyPreview(); previewStarted = false; startPreview(); } function toast(msg) { els.toast.textContent = msg; els.toast.classList.add("visible"); setTimeout(() => els.toast.classList.remove("visible"), 2800); } function formatPartials(partials) { if (!partials || !partials.length) return ""; return partials .map((p) => `${fillTemplate(I18N.setPartialLabelTemplate, { number: p.set || p["set"] })} ${p.home || p["home"]}-${p.away || p["away"]}`) .join(" · "); } function formatClock(secs, countUp) { const total = parseInt(secs, 10) || 0; if (total <= 0 && !countUp) return "0:00"; const m = Math.floor(total / 60); const s = total % 60; return `${m}:${String(s).padStart(2, "0")}`; } function resolvePeriodLabel(score, board) { const raw = score.period; if (typeof raw === "string" && raw.trim()) return raw.trim(); if (typeof raw === "number" && raw > 0) { return board === "basket" ? fillTemplate(I18N.quarterLabelTemplate, { n: raw }) : fillTemplate(I18N.halfLabelTemplate, { n: raw }); } const nested = score.data || {}; const dataPeriod = nested.period ?? nested["period"]; const periodNum = parseInt(dataPeriod, 10); if (periodNum > 0) { if (nested.overtime || nested["overtime"]) { return board === "basket" ? fillTemplate(I18N.overtimeBasketTemplate, { n: Math.max(periodNum - 4, 1) }) : I18N.overtimeOther; } return board === "basket" ? fillTemplate(I18N.quarterLabelTemplate, { n: periodNum }) : fillTemplate(I18N.halfLabelTemplate, { n: periodNum }); } return I18N.unknownPeriod; } function updateRegiaSubtitle(periodLabel, board) { if (!els.subtitle) return; if (board === "basket" || board === "timed") { els.subtitle.textContent = `${I18N.subtitlePrefix} ${periodLabel}`; } } function applyScorePayload(data) { const s = data.score; if (!s) return; const board = s.board_type || s.boardType || cfg.board; const homePts = s.home_points ?? s.homePoints ?? 0; const awayPts = s.away_points ?? s.awayPoints ?? 0; const homeSets = s.home_sets ?? s.homeSets; const awaySets = s.away_sets ?? s.awaySets; const currentSet = s.current_set ?? s.currentSet; const periodLabel = resolvePeriodLabel(s, board); const awayEl = document.getElementById("away-points"); const centerHint = document.getElementById("center-hint"); const clockLine = document.getElementById("clock-line"); if (els.homePoints) els.homePoints.textContent = homePts; if (awayEl) awayEl.textContent = awayPts; if (board === "basket" || board === "timed") { if (els.setsLine) els.setsLine.textContent = periodLabel; if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; if (centerHint) centerHint.textContent = periodLabel; updateRegiaSubtitle(periodLabel, board); } else if (board === "timer") { const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs; if (els.setsLine) els.setsLine.textContent = I18N.stopwatch; if (clockLine) clockLine.textContent = formatClock(clockSecs, true); } else if (board === "generic") { if (els.setsLine) els.setsLine.textContent = I18N.freeScore; if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; } else { if (els.setsLine) els.setsLine.textContent = fillTemplate(I18N.setsLineTemplate, { current: currentSet, home: homeSets, away: awaySets }); if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; const partialsText = formatPartials(s.set_partials || s.setPartials); if (els.partialsLine && partialsText) { els.partialsLine.textContent = `${I18N.partialsPrefix} ${partialsText}`; els.partialsLine.hidden = false; } } } function syncPauseButton(data) { if (!els.btnPause) return; const paused = !!(data.paused || data.status === "paused"); streamPaused = paused; const label = paused ? I18N.resumeLabel : I18N.pauseLabel; els.btnPause.classList.toggle("is-paused", paused); els.btnPause.setAttribute("aria-label", label); els.btnPause.title = label; } function syncMuteButton(data) { if (!els.btnAudioMute) return; if (typeof data.audio_muted === "boolean") { audioMuted = data.audio_muted; } const label = audioMuted ? I18N.unmuteLabel : I18N.muteLabel; els.btnAudioMute.classList.toggle("is-muted", audioMuted); els.btnAudioMute.setAttribute("aria-label", label); els.btnAudioMute.title = label; } function setBadge(data) { syncPauseButton(data); syncMuteButton(data); if (data.stream_closed) { els.badge.textContent = I18N.endedBadge; els.badge.className = "regia-badge regia-badge--ended"; return; } if (data.status === "paused" || data.paused) { els.badge.textContent = I18N.pausedBadge; els.badge.className = "regia-badge regia-badge--wait"; return; } if (data.on_air) { els.badge.textContent = I18N.liveBadge; els.badge.className = "regia-badge regia-badge--live"; } else { els.badge.textContent = I18N.waitingBadge; els.badge.className = "regia-badge regia-badge--wait"; } } async function postScore(action) { const res = await fetch(cfg.scoreUrl, { method: "PATCH", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ cmd: action }) }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || I18N.scoreUpdateError); } return res.json(); } async function handleAction(action) { const prevHome = els.homePoints?.textContent; const prevAway = document.getElementById("away-points")?.textContent; const trigger = root.querySelector(`[data-action="${action}"]`); if (trigger) trigger.disabled = true; try { const data = await postScore(action); applyScorePayload(data); setBadge(data); if (action === "advance_period") { const periodLabel = resolvePeriodLabel(data.score || {}, cfg.board); toast(periodLabel === I18N.unknownPeriod ? I18N.periodUpdated : fillTemplate(I18N.nowInPeriodTemplate, { period: periodLabel })); } if (cfg.board === "volley" || cfg.board === "racket") { if (data.set_won && action !== "close_set") { const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; openModal(I18N.setWonTitle, fillTemplate(I18N.setWonBodyTemplate, { winner }), () => postScore("close_set")); } } if (data.match_won) { const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; openModal(I18N.matchWonTitle, fillTemplate(I18N.matchWonBodyTemplate, { winner }), () => stopStream()); } } catch (e) { if (prevHome != null && els.homePoints) els.homePoints.textContent = prevHome; const awayEl = document.getElementById("away-points"); if (prevAway != null && awayEl) awayEl.textContent = prevAway; toast(e.message || I18N.genericError); } finally { if (trigger) trigger.disabled = false; } } function openModal(title, body, onConfirm) { els.modalTitle.textContent = title; els.modalBody.textContent = body; els.modal.classList.add("open"); pendingAction = onConfirm; } function closeModal() { els.modal.classList.remove("open"); pendingAction = null; } els.modalConfirm.addEventListener("click", async () => { const fn = pendingAction; closeModal(); if (fn) { try { const data = await fn(); if (data) { applyScorePayload(data); setBadge(data); } } catch (e) { toast(e.message || I18N.genericError); } } }); els.modalCancel.addEventListener("click", closeModal); root.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", () => { const action = btn.dataset.action; if (action === "close_set") { openModal(I18N.closeSetTitle, I18N.closeSetConfirm, () => postScore("close_set")); } else { handleAction(action); } }); }); function syncPreviewFromStatus(data) { if (data.stream_closed) { cfg.streamClosed = true; stopPreview(); return; } const paused = data.paused || data.status === "paused"; if (!previewWanted) { destroyPreview(); wasPausedPreview = paused; return; } if (data.on_air) { if (!previewStarted) { startPreview(); } else if (paused && !wasPausedPreview) { refreshPreviewForCover(); } else if (paused) { if (els.preview && els.preview.readyState >= 2) hidePreviewPlaceholder(); else els.preview?.play().catch(() => {}); } else if (els.preview && els.preview.readyState >= 2) { hidePreviewPlaceholder(); } } else { destroyPreview(); showPreviewPlaceholder(I18N.previewWaiting); } wasPausedPreview = paused; } function mbpsNumber(bps) { const n = Number(bps); if (!n || n <= 0) return null; const mbps = n >= 10_000 ? n / 1e6 : n / 1e3; return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1); } function formatAge(iso) { if (!iso) return null; const then = Date.parse(iso); if (!then) return null; const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); if (secs < 8) return I18N.telemetryJustNow; return fillTemplate(I18N.telemetrySecondsAgo, { n: secs }); } function setChip(el, text, level, hint) { if (!el) return; const valueEl = el.querySelector(".regia-chip__value") || el; valueEl.textContent = text; el.classList.toggle("is-ok", level === "ok"); el.classList.toggle("is-warn", level === "warn"); el.classList.toggle("is-bad", level === "bad"); const hintEl = el.querySelector(".regia-chip__hint"); if (hintEl) { hintEl.textContent = hint || ""; hintEl.hidden = !hint; } } function formatThermal(state) { const key = String(state || "").toLowerCase(); if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" }; if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" }; if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" }; if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" }; return { text: "—", level: null }; } function bitrateLevel(current, target, paused) { if (paused || !current || current <= 0) return null; if (target && target > 0) { const ratio = current / target; if (ratio >= 0.7) return "ok"; if (ratio >= 0.4) return "warn"; return "bad"; } const mbps = current >= 10_000 ? current / 1e6 : current / 1e3; if (mbps >= 2.5) return "ok"; if (mbps >= 1.5) return "warn"; return "bad"; } function bitrateTitle(currentMbps, targetMbps) { const tap = I18N.telemetryBitrateTap; if (currentMbps && targetMbps) { return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`; } if (currentMbps) return `${currentMbps} Mbps · ${tap}`; return tap; } function syncTelemetry(data) { if (!els.telemetry) return; const tel = data.telemetry || {}; const battery = tel.battery; const fps = tel.fps; const bitrate = tel.bitrate; const target = tel.target_bitrate || tel.targetBitrate; const lastSeen = tel.last_seen_at || tel.lastSeenAt; const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null; const stale = ageSecs == null || ageSecs > 25; const publishing = !!tel.publisher_online || !!tel.publisherOnline; let battLevel = null; let battText = "—"; if (battery != null && battery !== "") { battText = `${battery}%`; if (battery <= 15) battLevel = "bad"; else if (battery <= 25) battLevel = "warn"; } setChip(els.telBattery, battText, battLevel); setChip(els.telNetwork, tel.network || "—"); const currentMbps = mbpsNumber(bitrate); const targetMbps = mbpsNumber(target); const rateText = currentMbps || "—"; const rateHint = currentMbps && targetMbps ? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps }) : null; if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps); setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint); let fpsLevel = null; if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn"; setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel); const thermal = formatThermal(tel.thermal_state || tel.thermalState); setChip(els.telThermal, thermal.text, thermal.level); els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed); if (!els.telMeta) return; if (stale && !lastSeen) { els.telMeta.textContent = I18N.telemetryWaiting; return; } const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff]; const age = formatAge(lastSeen); if (age) parts.push(age); if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale); els.telMeta.textContent = parts.join(" · "); } async function pollStatus() { try { const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); if (!res.ok) return; const data = await res.json(); applyScorePayload(data); setBadge(data); syncMinQuality(data); syncTelemetry(data); syncPreviewFromStatus(data); } catch (_) {} } function bindPreviewEvents() { if (!els.preview) return; ["playing", "loadeddata", "canplay"].forEach((ev) => { els.preview.addEventListener(ev, hidePreviewPlaceholder); }); } function startPreview() { if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return; previewStarted = true; const url = hlsUrlFresh(); if (window.Hls && Hls.isSupported()) { hls = new Hls({ lowLatencyMode: false, maxBufferLength: 30, maxMaxBufferLength: 60, liveSyncDurationCount: 5, maxLiveSyncPlaybackRate: 1.0 }); hls.loadSource(url); hls.attachMedia(els.preview); hls.on(Hls.Events.MANIFEST_PARSED, () => { els.preview.play().catch(() => {}); }); hls.on(Hls.Events.ERROR, (_, err) => { if (err.fatal) { destroyPreview(); setTimeout(() => { if (!cfg.streamClosed) startPreview(); }, 2500); } }); } else if (els.preview.canPlayType("application/vnd.apple.mpegurl")) { els.preview.src = url; els.preview.addEventListener( "loadedmetadata", () => els.preview.play().catch(() => {}), { once: true } ); } } function stopPreview() { destroyPreview(); if (els.preview) els.preview.style.display = "none"; showPreviewPlaceholder(I18N.streamEnded); } async function togglePauseStream() { const url = streamPaused ? cfg.resumeUrl : cfg.pauseUrl; const res = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || (streamPaused ? I18N.resumeError : I18N.pauseError)); } const data = await res.json(); const wasPaused = streamPaused; applyScorePayload(data); setBadge(data); syncMinQuality(data); syncTelemetry(data); syncPreviewFromStatus(data); toast(wasPaused ? I18N.resumed : I18N.pausedCover); } async function toggleMuteAudio() { if (!cfg.audioMuteUrl) return; const nextMuted = !audioMuted; const res = await fetch(cfg.audioMuteUrl, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ muted: nextMuted }) }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || I18N.muteError); } const data = await res.json(); applyScorePayload(data); setBadge(data); toast(audioMuted ? I18N.muted : I18N.unmuted); } async function stopStream() { const ok = confirm(I18N.closeConfirm); if (!ok) return null; const res = await fetch(cfg.stopUrl, { method: "POST", headers: { Accept: "application/json" } }); if (!res.ok) throw new Error(I18N.closeError); const data = await res.json(); cfg.streamClosed = true; stopPreview(); toast(I18N.closed); return data; } els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); els.btnAudioMute?.addEventListener("click", () => toggleMuteAudio().catch((e) => toast(e.message))); document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); let minQualityBusy = false; function setQualityPanelOpen(open) { if (!els.qualityPanel || !els.telBitrate) return; els.qualityPanel.hidden = !open; els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false"); els.telBitrate.classList.toggle("is-open", open); if (open && els.minQuality) { window.requestAnimationFrame(() => els.minQuality.focus()); } } function syncMinQuality(data) { if (!els.minQuality || minQualityBusy) return; const value = data.min_quality_preset || "auto"; if (els.minQuality.value !== value) els.minQuality.value = value; } async function setMinQuality(preset) { if (!cfg.minQualityUrl) return; minQualityBusy = true; try { const res = await fetch(cfg.minQualityUrl, { method: "PATCH", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ min_quality_preset: preset }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || I18N.minQualityError); minQualityBusy = false; syncMinQuality(data); toast(I18N.minQualityUpdated); } catch (e) { toast(e.message || I18N.minQualityError); } finally { minQualityBusy = false; } } els.minQuality?.addEventListener("change", () => { setMinQuality(els.minQuality.value); }); els.telBitrate?.addEventListener("click", (e) => { e.stopPropagation(); const opening = !!(els.qualityPanel && els.qualityPanel.hidden); setQualityPanelOpen(opening); }); document.addEventListener("click", (e) => { if (!els.qualityPanel || els.qualityPanel.hidden) return; if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return; setQualityPanelOpen(false); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") setQualityPanelOpen(false); }); els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted)); previewWanted = readPreviewPref(); syncPreviewToggleUi(); if (!previewWanted) destroyPreview(); async function shareLink(url, title, text) { if (!url) { toast(I18N.linkUnavailable); return; } const payload = { title, text, url }; if (navigator.share) { try { await navigator.share(payload); return; } catch (err) { if (err.name === "AbortError") return; } } await copyLink(url); } async function copyLink(url) { if (!url) { toast(I18N.linkUnavailable); return; } try { await navigator.clipboard.writeText(url); toast(I18N.linkCopied); } catch (_) { prompt(I18N.copyPrompt, url); } } document.getElementById("btn-share-regia")?.addEventListener("click", () => shareLink(cfg.regiaShareUrl, cfg.regiaShareTitle, I18N.shareRegiaText) ); document.getElementById("btn-copy-regia")?.addEventListener("click", () => copyLink(cfg.regiaShareUrl)); document.getElementById("btn-share-live")?.addEventListener("click", () => shareLink(cfg.liveShareUrl, cfg.liveShareTitle, I18N.shareLiveText) ); document.getElementById("btn-copy-live")?.addEventListener("click", () => copyLink(cfg.liveShareUrl)); function bindPreviewStallRecovery() { if (!els.preview) return; let stallAt = 0; let lastProgressAt = Date.now(); let lastTime = 0; const markProgress = () => { const t = els.preview.currentTime; if (t > lastTime + 0.05) { lastTime = t; lastProgressAt = Date.now(); } }; els.preview.addEventListener("timeupdate", markProgress); els.preview.addEventListener("playing", markProgress); const nudge = (force) => { if (!previewStarted || cfg.streamClosed) return; const now = Date.now(); if (!force && now - lastProgressAt < 8000) { els.preview.play().catch(() => {}); return; } if (now - stallAt < 5000) return; stallAt = now; lastProgressAt = now; if (hls) { hls.startLoad(-1); els.preview.play().catch(() => {}); } }; setInterval(() => { if (previewStarted && !cfg.streamClosed && Date.now() - lastProgressAt > 12000) nudge(true); }, 4000); } bindPreviewEvents(); bindPreviewStallRecovery(); if (!cfg.streamClosed && cfg.hlsUrl) { pollStatus(); } else if (cfg.streamClosed) { stopPreview(); } setInterval(pollStatus, 4000); })();