L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato. Co-authored-by: Cursor <cursoragent@cursor.com>
815 lines
28 KiB
JavaScript
815 lines
28 KiB
JavaScript
(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",
|
|
closed: "Diretta chiusa",
|
|
resumeError: "Errore ripresa diretta",
|
|
pauseError: "Errore pausa diretta",
|
|
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",
|
|
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,
|
|
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"),
|
|
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";
|
|
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 setBadge(data) {
|
|
syncPauseButton(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 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)));
|
|
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);
|
|
})();
|