Merge branch 'feature/adaptive-bitrate' into collaudo

Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 20:16:26 +02:00
co-authored by Cursor
65 changed files with 2727 additions and 138 deletions
+287 -41
View File
@@ -4,6 +4,8 @@
--regia-surface: #14141c;
--regia-red: #e53935;
--regia-yellow: #f9a825;
--regia-green: #43a047;
--regia-orange: #fb8c00;
--regia-text: #f5f5f5;
--regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -22,37 +24,176 @@
.regia-page {
max-width: 480px;
margin: 0 auto;
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
}
.regia-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.regia-header__text {
min-width: 0;
flex: 1;
}
.regia-header h1 {
margin: 0;
font-size: 1.1rem;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
}
.regia-header p {
margin: 4px 0 0;
font-size: 0.85rem;
margin: 2px 0 0;
font-size: 0.78rem;
color: var(--regia-muted);
}
.regia-badge {
display: inline-block;
margin-top: 8px;
padding: 4px 10px;
margin-top: 0;
padding: 3px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
flex-shrink: 0;
}
.regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; color: #ddd; }
.regia-badge--ended { background: #374151; color: #ddd; }
.regia-toolbar {
margin: 8px 0 6px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.regia-tool-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.regia-tool-group__label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-tool-group__btns {
display: flex;
gap: 3px;
}
.regia-tool-group--stream {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid #333;
}
.regia-icon-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid #333;
border-radius: 10px;
background: #16161f;
color: var(--regia-text);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.regia-icon-btn svg {
width: 20px;
height: 20px;
pointer-events: none;
}
.regia-icon-btn svg.icon-fill {
fill: currentColor;
}
.regia-icon-btn svg.icon-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.regia-icon-btn .icon-eye-off,
.regia-icon-btn .icon-play,
.regia-icon-btn .icon-mic-off {
display: none;
}
.regia-icon-btn.is-off .icon-eye {
display: none;
}
.regia-icon-btn.is-off .icon-eye-off {
display: block;
}
.regia-icon-btn.is-paused .icon-pause {
display: none;
}
.regia-icon-btn.is-paused .icon-play {
display: block;
}
.regia-icon-btn.is-muted .icon-mic {
display: none;
}
.regia-icon-btn.is-muted .icon-mic-off {
display: block;
}
.regia-icon-btn:active { transform: scale(0.96); }
.regia-icon-btn.is-off,
.regia-icon-btn.is-muted {
color: var(--regia-yellow);
border-color: #5a4a1a;
}
.regia-icon-btn--danger {
color: var(--regia-red);
border-color: #7a2a28;
}
.regia-preview-wrap {
margin: 6px 0 10px;
}
.regia-preview-wrap.is-off {
margin: 0;
height: 0;
overflow: hidden;
}
.regia-preview {
margin: 14px 0;
margin: 0;
border-radius: 12px;
overflow: hidden;
background: #000;
@@ -90,29 +231,29 @@
.regia-scoreboard {
background: var(--regia-surface);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
padding: 10px 12px;
margin-bottom: 8px;
}
.regia-sets {
text-align: center;
font-size: 0.82rem;
font-size: 0.78rem;
color: var(--regia-muted);
margin: 0 0 8px;
margin: 0 0 4px;
}
.regia-score-line {
text-align: center;
font-size: 1.5rem;
font-size: 1.2rem;
font-weight: 800;
margin: 0;
}
.regia-partials {
text-align: center;
font-size: 0.78rem;
font-size: 0.75rem;
color: var(--regia-muted);
margin: 8px 0 0;
margin: 4px 0 0;
}
.regia-team-row {
@@ -120,7 +261,7 @@
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
margin-top: 16px;
margin-top: 8px;
}
.regia-team-name {
@@ -131,7 +272,7 @@
}
.regia-points {
font-size: 2rem;
font-size: 1.55rem;
font-weight: 800;
text-align: center;
}
@@ -146,7 +287,7 @@
.regia-btn {
border: none;
border-radius: 10px;
padding: 16px 12px;
padding: 10px 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
@@ -156,8 +297,8 @@
.regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
.regia-btn--outline {
background: transparent;
border: 1px solid #444;
@@ -177,26 +318,6 @@
margin-top: 8px;
}
.regia-share-group {
margin: 12px 0;
}
.regia-share-label {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
}
.regia-share {
display: flex;
gap: 8px;
}
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
.regia-toast {
position: fixed;
bottom: calc(16px + var(--regia-safe-bottom));
@@ -244,7 +365,7 @@
text-align: center;
color: var(--regia-muted);
font-size: 0.72rem;
padding-top: 24px;
padding-top: 8px;
}
.regia-clock-line {
@@ -259,3 +380,128 @@
gap: 8px;
margin-top: 12px;
}
.regia-quality {
margin: 6px 0 0;
padding: 8px 10px 10px;
background: var(--regia-surface);
border-radius: 10px;
}
.regia-quality[hidden] { display: none !important; }
.regia-quality__hint {
margin: 0 0 8px;
font-size: 0.68rem;
line-height: 1.35;
color: var(--regia-muted);
}
.regia-quality label {
display: block;
font-size: 0.68rem;
color: var(--regia-muted);
margin: 0 0 4px;
}
.regia-quality select {
width: 100%;
background: #0a0a0e;
color: var(--regia-text);
border: 1px solid #2a2a35;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.82rem;
}
.regia-telemetry {
margin-top: 8px;
}
.regia-telemetry__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.regia-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
padding: 6px 2px 5px;
border-radius: 8px;
background: var(--regia-surface);
color: var(--regia-text);
border: 1px solid transparent;
}
.regia-chip--action {
appearance: none;
-webkit-appearance: none;
font: inherit;
cursor: pointer;
width: 100%;
}
.regia-chip--action:focus-visible {
outline: 2px solid var(--regia-yellow);
outline-offset: 1px;
}
.regia-chip--action.is-open {
border-color: #3a3a48;
}
.regia-chip svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.regia-chip__label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__value {
font-size: 0.72rem;
font-weight: 800;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.regia-chip__hint {
font-size: 0.55rem;
font-weight: 600;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__hint[hidden] { display: none; }
.regia-chip.is-ok { color: var(--regia-green); }
.regia-chip.is-warn { color: var(--regia-orange); }
.regia-chip.is-bad { color: var(--regia-red); }
.regia-telemetry__meta {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--regia-muted);
text-align: center;
}
.regia-telemetry.is-stale .regia-telemetry__meta {
color: var(--regia-yellow);
}
+265 -6
View File
@@ -40,6 +40,9 @@
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",
@@ -48,7 +51,23 @@
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() {
@@ -83,6 +102,7 @@
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,
@@ -108,17 +128,30 @@
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"),
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";
let audioMuted = root.dataset.initialAudioMuted === "true";
const PREVIEW_PREF_KEY = "mltv-regia-preview";
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -147,8 +180,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();
@@ -251,7 +319,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 syncMuteButton(data) {
@@ -259,8 +330,10 @@
if (typeof data.audio_muted === "boolean") {
audioMuted = data.audio_muted;
}
els.btnAudioMute.textContent = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
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) {
@@ -382,6 +455,11 @@
}
const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) {
if (!previewStarted) {
startPreview();
@@ -400,6 +478,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" } });
@@ -407,6 +598,8 @@
const data = await res.json();
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
} catch (_) {}
}
@@ -419,7 +612,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();
@@ -472,6 +665,8 @@
const wasPaused = streamPaused;
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
}
@@ -510,6 +705,70 @@
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);