Files
MatchLiveTv/backend/app/views/public/live/show.html.erb
Emiliano Frascaro ae36d17adb Stabilizza regia, live web e sync punteggi app nativa.
Corregge scadenza token regia oltre le 8h, tabellone HTML sulla pagina live,
pulsanti pausa/ripresa in regia, link admin e broadcast ActionCable diretto.
App Android: overlay branding, sync score da regia via WebSocket con reconnect e poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:30:43 +02:00

398 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<% club_name = @match.team.club&.name %>
<% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
<% content_for :meta_description do %>Segui in diretta live <%= club_name %> — <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
<% content_for :robots, @session.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
<% content_for :head do %>
<% unless @stream_closed || @session.platform == "youtube" %>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
<% end %>
<% end %>
<div class="wrap">
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
<%= live_match_page_heading(@match) %>
<% if @stream_closed %>
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
<div class="stream-ended-icon" aria-hidden="true">📡</div>
<h2>Diretta terminata</h2>
<p>Lo streaming di questa partita è stato chiuso.</p>
<% if @session.ended_at %>
<p class="stream-ended-meta">Chiusa il <%= l_local(@session.ended_at) %></p>
<% end %>
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
</div>
<% elsif @session.platform == "youtube" %>
<div class="stream-ended youtube-live-cta" role="status" aria-live="polite">
<div class="stream-ended-icon" aria-hidden="true">▶</div>
<h2>Diretta su YouTube</h2>
<% if @session.youtube_watch_url.present? %>
<p>Questa partita è trasmessa sul canale YouTube Match Live TV, non sul player del sito.</p>
<%= link_to "Guarda su YouTube", @session.youtube_watch_url, class: "btn stream-ended-btn", target: "_blank", rel: "noopener" %>
<% else %>
<p>Stiamo preparando la diretta sul canale YouTube. Aggiorna tra qualche secondo.</p>
<meta http-equiv="refresh" content="8">
<% end %>
<p class="stream-ended-hint">Condividi solo il link YouTube con tifosi e società.</p>
</div>
<% else %>
<div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
<%= render "public/live/scorebug", match: @match, session: @session %>
<button type="button" id="play-hint" class="live-play-hint" hidden>
▶ Avvia la diretta
</button>
</div>
<p id="offline-msg" class="live-status-msg" hidden>La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
<p id="awaiting-msg" class="live-status-msg" hidden>In attesa del segnale dal telefono — apri la camera nell'app e verifica che la diretta sia avviata (non in pausa).</p>
<p id="paused-msg" class="live-status-msg" hidden>Trasmissione in pausa — il flusso continua (copertina server).</p>
<script>
const sessionId = "<%= @session.id %>";
const hlsUrl = "<%= j @session.hls_playback_url %>";
const video = document.getElementById("player");
const offlineMsg = document.getElementById("offline-msg");
const pausedMsg = document.getElementById("paused-msg");
const awaitingMsg = document.getElementById("awaiting-msg");
const playHint = document.getElementById("play-hint");
const scorebug = document.getElementById("live-scorebug");
const streamBadge = document.getElementById("live-stream-badge");
const homeName = "<%= j @match.team.name %>";
const awayName = "<%= j @match.opponent_name %>";
function abbrevName(name, max = 16) {
const n = String(name || "");
return n.length <= max ? n : `${n.slice(0, max - 1)}…`;
}
function scoreColumnsFromPayload(score) {
if (!score) return { labels: ["1"], home: ["0"], away: ["0"], liveIndex: 0 };
const partials = score.set_partials || score.setPartials || [];
const labels = partials.map((p) => String(p.set ?? p["set"] ?? ""));
const home = partials.map((p) => String(p.home ?? p["home"] ?? "0"));
const away = partials.map((p) => String(p.away ?? p["away"] ?? "0"));
const currentSet = score.current_set ?? score.currentSet ?? 1;
labels.push(String(currentSet));
home.push(String(score.home_points ?? score.homePoints ?? 0));
away.push(String(score.away_points ?? score.awayPoints ?? 0));
return { labels, home, away, liveIndex: labels.length - 1 };
}
function renderLiveScorebug(score) {
if (!scorebug) return;
const cols = scoreColumnsFromPayload(score);
const homeLabel = abbrevName(scorebug.dataset.homeName || homeName);
const awayLabel = abbrevName(scorebug.dataset.awayName || awayName);
const headCells = cols.labels
.map((label) => `<th class="live-scorebug__col-set live-scorebug__col-h" scope="col">${label}</th>`)
.join("");
const homeCells = cols.home
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
const awayCells = cols.away
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
scorebug.innerHTML = `
<table class="live-scorebug__table" aria-label="Tabellone">
<caption class="visually-hidden">Punteggio live</caption>
<thead><tr>
<th class="live-scorebug__corner" scope="col">Squadra</th>${headCells}
</tr></thead>
<tbody>
<tr class="live-scorebug__row--home">
<th class="live-scorebug__team" scope="row">${homeLabel}</th>${homeCells}
</tr>
<tr class="live-scorebug__row--away">
<th class="live-scorebug__team" scope="row">${awayLabel}</th>${awayCells}
</tr>
</tbody>
</table>
<div class="live-scorebug__brand" aria-hidden="true">
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
</div>`;
}
function syncStreamBadge(data) {
if (!streamBadge) return;
if (data.stream_closed) {
streamBadge.textContent = "Terminata";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
return;
}
const paused = !!(data.paused || data.status === "paused");
if (paused) {
streamBadge.textContent = "In pausa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} else if (data.on_air) {
streamBadge.textContent = "LIVE";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
} else {
streamBadge.textContent = "In attesa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
}
}
let hls = null;
let playerStarted = false;
let wasOnAir = <%= @on_air ? "true" : "false" %>;
let wasPublisherOnline = <%= @publisher_online ? "true" : "false" %>;
let wasPaused = <%= @session.paused? ? "true" : "false" %>;
let reloadTimer = null;
let streamClosed = false;
let stallRecoveryAt = 0;
let firstLevelSynced = false;
let lastPlaybackAt = Date.now();
let lastPlaybackTime = 0;
const hlsUrlStable = hlsUrl;
function hlsConfig() {
return {
enableWorker: true,
lowLatencyMode: false,
liveDurationInfinity: true,
liveSyncDurationCount: 5,
liveMaxLatencyDurationCount: 14,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferHole: 2.5,
maxLiveSyncPlaybackRate: 1.0,
backBufferLength: 30,
nudgeMaxRetry: 12,
xhrSetup: (xhr) => { xhr.withCredentials = true; },
};
}
function markPlaybackProgress() {
const t = video.currentTime;
if (t > lastPlaybackTime + 0.05) {
lastPlaybackTime = t;
lastPlaybackAt = Date.now();
}
}
function playbackStalledMs() {
return Date.now() - lastPlaybackAt;
}
function ensurePlaying() {
if (streamClosed || !playerStarted) return;
video.play().then(() => {
if (playHint) playHint.hidden = true;
}).catch(() => {
if (playHint) playHint.hidden = false;
});
}
/** Evita loop: startLoad su ogni waiting resetta il buffer e blocca HLS.js. */
function nudgePlayback(force) {
if (!hls || !playerStarted || streamClosed) return;
const now = Date.now();
if (!force && playbackStalledMs() < 8000) {
ensurePlaying();
return;
}
if (now - stallRecoveryAt < 5000) return;
stallRecoveryAt = now;
lastPlaybackAt = now;
hls.startLoad(-1);
ensurePlaying();
}
function showStreamEnded() {
if (streamClosed) return;
streamClosed = true;
destroyPlayer();
video.style.display = "none";
offlineMsg.hidden = true;
const panel = document.createElement("div");
panel.id = "stream-ended";
panel.className = "stream-ended";
panel.setAttribute("role", "status");
panel.innerHTML = `
<div class="stream-ended-icon" aria-hidden="true">📡</div>
<h2>Diretta terminata</h2>
<p>Lo streaming di questa partita è stato chiuso.</p>
<p class="stream-ended-hint">Ricarica la pagina per lultimo stato registrato.</p>
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
`;
video.parentNode.insertBefore(panel, video.nextSibling);
}
function destroyPlayer() {
playerStarted = false;
if (hls) {
hls.destroy();
hls = null;
}
video.removeAttribute("src");
video.load();
}
function bindHlsEvents(instance) {
instance.on(Hls.Events.MANIFEST_PARSED, () => {
firstLevelSynced = true;
ensurePlaying();
});
instance.on(Hls.Events.LEVEL_LOADED, () => ensurePlaying());
instance.on(Hls.Events.FRAG_BUFFERED, () => markPlaybackProgress());
instance.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
window.setTimeout(() => {
if (!streamClosed && playerStarted) {
instance.startLoad(-1);
ensurePlaying();
}
}, 1500);
return;
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
instance.recoverMediaError();
return;
}
hardReloadPlayer();
});
}
function onPublisherBack() {
nudgePlayback();
}
function onResumeFromPause() {
hardReloadPlayer();
}
function onEnterPause() {
nudgePlayback();
}
function hardReloadPlayer() {
if (!Hls.isSupported()) return;
firstLevelSynced = false;
if (hls) {
hls.destroy();
hls = null;
}
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_t=${Date.now()}`;
hls = new Hls(hlsConfig());
hls.loadSource(url);
hls.attachMedia(video);
bindHlsEvents(hls);
hls.startLoad(-1);
playerStarted = true;
ensurePlaying();
}
/** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */
function startPlayer() {
if (playerStarted) return;
if (Hls.isSupported()) {
hls = new Hls(hlsConfig());
hls.loadSource(hlsUrlStable);
hls.attachMedia(video);
bindHlsEvents(hls);
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = hlsUrlStable;
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
}
playerStarted = true;
}
function scheduleReload(delayMs) {
if (reloadTimer) return;
reloadTimer = setTimeout(() => {
reloadTimer = null;
pollStatus();
}, delayMs);
}
async function pollStatus() {
try {
const res = await fetch(`/live/${sessionId}/status.json?t=${Date.now()}`, { cache: "no-store" });
const data = await res.json();
const paused = !!data.paused || data.status === "paused";
const publisherOnline = !!data.publisher_online;
const awaitingSignal = !!data.awaiting_signal;
const showingCover = !!data.showing_cover;
if (data.stream_closed) {
showStreamEnded();
return;
}
renderLiveScorebug(data.score);
syncStreamBadge(data);
const onAir = !!data.on_air;
const sessionLive = !!data.live;
if (onAir || sessionLive) {
offlineMsg.hidden = true;
if (!publisherOnline && showingCover && !paused) {
awaitingMsg.hidden = false;
awaitingMsg.textContent = "Copertina in onda — in attesa del segnale dal telefono.";
} else {
awaitingMsg.hidden = !awaitingSignal;
}
pausedMsg.hidden = !paused;
if (!playerStarted) startPlayer();
if (paused && !wasPaused) onEnterPause();
if (!paused && wasPaused && playerStarted) onResumeFromPause();
if (publisherOnline && !wasPublisherOnline) onPublisherBack();
if (video.paused) ensurePlaying();
else if (playbackStalledMs() > 12000) nudgePlayback(true);
} else {
offlineMsg.hidden = false;
awaitingMsg.hidden = true;
pausedMsg.hidden = true;
}
wasOnAir = onAir;
wasPublisherOnline = publisherOnline;
wasPaused = paused;
} catch (_) {
scheduleReload(5000);
}
}
if (playHint) {
playHint.addEventListener("click", () => ensurePlaying());
}
video.addEventListener("pause", () => {
if (streamClosed || !playerStarted || video.ended || wasPaused) return;
if (playHint) playHint.hidden = false;
});
video.addEventListener("timeupdate", markPlaybackProgress);
video.addEventListener("playing", () => {
markPlaybackProgress();
if (playHint) playHint.hidden = true;
});
if (!streamClosed) {
startPlayer();
offlineMsg.hidden = wasOnAir;
window.setTimeout(() => {
if (playerStarted && !firstLevelSynced) hardReloadPlayer();
}, 12000);
} else {
offlineMsg.hidden = true;
}
pollStatus();
setInterval(pollStatus, 2000);
</script>
<% end %>
</div>