Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.
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>
This commit is contained in:
+262
-5
@@ -37,13 +37,32 @@
|
||||
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 ·"
|
||||
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() {
|
||||
@@ -77,6 +96,7 @@
|
||||
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,
|
||||
@@ -102,15 +122,28 @@
|
||||
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"),
|
||||
subtitle: document.getElementById("regia-subtitle")
|
||||
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("?") ? "&" : "?";
|
||||
@@ -139,8 +172,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
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) return;
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||
destroyPreview();
|
||||
previewStarted = false;
|
||||
startPreview();
|
||||
@@ -243,7 +311,10 @@
|
||||
if (!els.btnPause) return;
|
||||
const paused = !!(data.paused || data.status === "paused");
|
||||
streamPaused = paused;
|
||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||
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) {
|
||||
@@ -364,6 +435,11 @@
|
||||
}
|
||||
|
||||
const paused = data.paused || data.status === "paused";
|
||||
if (!previewWanted) {
|
||||
destroyPreview();
|
||||
wasPausedPreview = paused;
|
||||
return;
|
||||
}
|
||||
if (data.on_air) {
|
||||
if (!previewStarted) {
|
||||
startPreview();
|
||||
@@ -382,6 +458,119 @@
|
||||
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" } });
|
||||
@@ -389,6 +578,8 @@
|
||||
const data = await res.json();
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -401,7 +592,7 @@
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
|
||||
previewStarted = true;
|
||||
const url = hlsUrlFresh();
|
||||
@@ -454,6 +645,8 @@
|
||||
const wasPaused = streamPaused;
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||
}
|
||||
@@ -473,6 +666,70 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user