Fix crash e race su +3, layout tabellone con squadre ai lati, safe area in diretta e rimozione overlay cronometro; backend con sync punteggio periodo, link diretta in regia e init score_state basket. Co-authored-by: Cursor <cursoragent@cursor.com>
455 lines
14 KiB
JavaScript
455 lines
14 KiB
JavaScript
(function () {
|
|
const root = document.getElementById("regia-app");
|
|
if (!root) return;
|
|
|
|
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,
|
|
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")
|
|
};
|
|
|
|
let pendingAction = null;
|
|
let hls = null;
|
|
let previewStarted = false;
|
|
let wasPausedPreview = false;
|
|
let streamPaused = root.dataset.initialPaused === "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) => `Set ${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 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 period = s.period;
|
|
const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs;
|
|
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 = period || "—";
|
|
if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
|
|
if (centerHint) centerHint.textContent = formatClock(clockSecs);
|
|
if (clockLine) clockLine.textContent = `Cronometro: ${formatClock(clockSecs)}`;
|
|
} else if (board === "timer") {
|
|
if (els.setsLine) els.setsLine.textContent = "Cronometro";
|
|
if (clockLine) clockLine.textContent = formatClock(clockSecs, true);
|
|
} else if (board === "generic") {
|
|
if (els.setsLine) els.setsLine.textContent = "Punteggio libero";
|
|
if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
|
|
} else {
|
|
if (els.setsLine) els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${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 = `Parziali: ${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 ? "Riprendi diretta" : "Metti in pausa";
|
|
}
|
|
|
|
function setBadge(data) {
|
|
syncPauseButton(data);
|
|
if (data.stream_closed) {
|
|
els.badge.textContent = "Terminata";
|
|
els.badge.className = "regia-badge regia-badge--ended";
|
|
return;
|
|
}
|
|
if (data.status === "paused" || data.paused) {
|
|
els.badge.textContent = "In pausa";
|
|
els.badge.className = "regia-badge regia-badge--wait";
|
|
return;
|
|
}
|
|
if (data.on_air) {
|
|
els.badge.textContent = "In onda";
|
|
els.badge.className = "regia-badge regia-badge--live";
|
|
} else {
|
|
els.badge.textContent = "In attesa";
|
|
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) throw new Error("Errore aggiornamento punteggio");
|
|
return res.json();
|
|
}
|
|
|
|
async function handleAction(action) {
|
|
const prevHome = els.homePoints?.textContent;
|
|
const prevAway = document.getElementById("away-points")?.textContent;
|
|
try {
|
|
const data = await postScore(action);
|
|
applyScorePayload(data);
|
|
setBadge(data);
|
|
|
|
if (cfg.board === "volley" || cfg.board === "racket") {
|
|
if (data.set_won && action !== "close_set") {
|
|
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
|
openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set"));
|
|
}
|
|
}
|
|
if (data.match_won) {
|
|
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
|
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => 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 || "Errore");
|
|
}
|
|
}
|
|
|
|
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 || "Errore");
|
|
}
|
|
}
|
|
});
|
|
|
|
els.modalCancel.addEventListener("click", closeModal);
|
|
|
|
root.querySelectorAll("[data-action]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const action = btn.dataset.action;
|
|
if (action === "close_set") {
|
|
openModal("Chiudi set", "Confermi la chiusura del set corrente?", () => 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("Anteprima in attesa del segnale…");
|
|
}
|
|
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("Diretta terminata");
|
|
}
|
|
|
|
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 ? "Errore ripresa diretta" : "Errore pausa diretta"));
|
|
}
|
|
const data = await res.json();
|
|
const wasPaused = streamPaused;
|
|
applyScorePayload(data);
|
|
setBadge(data);
|
|
syncPreviewFromStatus(data);
|
|
toast(wasPaused ? "Diretta ripresa" : "Diretta in pausa — copertina in onda");
|
|
}
|
|
|
|
async function stopStream() {
|
|
const ok = confirm("Chiudere definitivamente la diretta?");
|
|
if (!ok) return null;
|
|
const res = await fetch(cfg.stopUrl, { method: "POST", headers: { Accept: "application/json" } });
|
|
if (!res.ok) throw new Error("Errore chiusura");
|
|
const data = await res.json();
|
|
cfg.streamClosed = true;
|
|
stopPreview();
|
|
toast("Diretta chiusa");
|
|
return data;
|
|
}
|
|
|
|
els.btnPause?.addEventListener("click", () => togglePauseStream().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("Link non disponibile");
|
|
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("Link non disponibile");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
toast("Link copiato");
|
|
} catch (_) {
|
|
prompt("Copia il link:", url);
|
|
}
|
|
}
|
|
|
|
document.getElementById("btn-share-regia")?.addEventListener("click", () =>
|
|
shareLink(
|
|
cfg.regiaShareUrl,
|
|
cfg.regiaShareTitle,
|
|
"Apri questo link per gestire il punteggio della diretta:"
|
|
)
|
|
);
|
|
document.getElementById("btn-copy-regia")?.addEventListener("click", () => copyLink(cfg.regiaShareUrl));
|
|
document.getElementById("btn-share-live")?.addEventListener("click", () =>
|
|
shareLink(
|
|
cfg.liveShareUrl,
|
|
cfg.liveShareTitle,
|
|
"Guarda la diretta su Match Live TV:"
|
|
)
|
|
);
|
|
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);
|
|
})();
|