Files
MatchLiveTv/backend/public/regia.js
T

595 lines
20 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",
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",
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 ·"
};
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,
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"),
btnPause: document.getElementById("btn-pause"),
btnAudioMute: document.getElementById("btn-audio-mute"),
subtitle: document.getElementById("regia-subtitle")
};
let pendingAction = null;
let hls = null;
let previewStarted = false;
let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true";
let audioMuted = root.dataset.initialAudioMuted === "true";
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 refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) 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;
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
}
function syncMuteButton(data) {
if (!els.btnAudioMute) return;
if (typeof data.audio_muted === "boolean") {
audioMuted = data.audio_muted;
}
els.btnAudioMute.textContent = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
els.btnAudioMute.classList.toggle("is-muted", audioMuted);
}
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 (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;
}
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);
syncPreviewFromStatus(data);
} catch (_) {}
}
function bindPreviewEvents() {
if (!els.preview) return;
["playing", "loadeddata", "canplay"].forEach((ev) => {
els.preview.addEventListener(ev, hidePreviewPlaceholder);
});
}
function startPreview() {
if (cfg.streamClosed || !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);
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)));
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);
})();