Files
MatchLiveTv/backend/app/views/public/live/show.html.erb
Emiliano Frascaro ab9cb02083 Aggiunge overlay server-side burn-in e stabilizza diretta live.
Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay;
sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata
e badge CONNECTING in ripresa da pausa.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 23:55:17 +02:00

391 lines
13 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, "noindex, follow" %>
<% content_for :head do %>
<% unless @stream_closed %>
<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>
<% else %>
<div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay></video>
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %>
<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");
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 liveEdgeTimer = null;
let publisherStuckPolls = 0;
let didHardReloadForLive = false;
let pendingLiveRecovery = false;
let liveRecoveryAttempts = 0;
const MAX_LIVE_RECOVERY_ATTEMPTS = 10;
let streamClosed = false;
const hlsUrlStable = hlsUrl;
function hlsConfig() {
return {
enableWorker: false,
lowLatencyMode: false,
liveDurationInfinity: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 8,
liveSyncMode: "edge",
maxLiveSyncPlaybackRate: 1.08,
maxBufferHole: 1.0,
backBufferLength: 20,
nudgeMaxRetry: 8,
};
}
let lastLiveSeekAt = 0;
let firstLevelSynced = false;
function jumpToLiveEdge(force = false) {
if (!hls || !playerStarted) return;
const pos = hls.liveSyncPosition;
if (pos == null || !Number.isFinite(pos)) {
ensurePlaying();
return;
}
const lag = Math.max(0, pos - video.currentTime);
const now = Date.now();
// Evita seek continui (causano video a scatti): solo se molto indietro o recovery forzato.
if (!force && lag < 4 && now - lastLiveSeekAt < 8000) {
ensurePlaying();
return;
}
if (force || lag > 2) {
video.currentTime = Math.max(0, pos - 1);
lastLiveSeekAt = now;
}
ensurePlaying();
}
function ensurePlaying() {
if (streamClosed || !playerStarted) return;
video.play().then(() => {
if (playHint) playHint.hidden = true;
}).catch(() => {
if (playHint) playHint.hidden = false;
});
}
function liveEdgeLagSec() {
if (!hls || !playerStarted) return 0;
const pos = hls.liveSyncPosition;
if (pos == null || !Number.isFinite(pos)) return 0;
return Math.max(0, pos - video.currentTime);
}
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();
}
/** Segue il live dopo switch copertina→camera (stesso player, stesso URL). */
function recoverLiveVideo() {
if (!hls || !playerStarted) return;
try {
if (hls.levels?.length) {
hls.startLoad(-1);
window.setTimeout(() => jumpToLiveEdge(true), 300);
}
} catch (_) {
ensurePlaying();
}
}
function scheduleLiveEdgeSync() {
if (liveEdgeTimer) return;
liveEdgeTimer = setTimeout(() => {
liveEdgeTimer = null;
recoverLiveVideo();
}, 400);
}
function bindHlsEvents(instance) {
instance.on(Hls.Events.MANIFEST_PARSED, () => ensurePlaying());
instance.on(Hls.Events.LEVEL_LOADED, () => {
if (!firstLevelSynced) {
firstLevelSynced = true;
jumpToLiveEdge(true);
}
});
instance.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
instance.startLoad(-1);
ensurePlaying();
return;
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
instance.recoverMediaError();
return;
}
scheduleReload(5000);
});
}
function isCaughtUpWithLive() {
if (!hls || !playerStarted) return false;
const lag = liveEdgeLagSec();
return video.readyState >= 2 && lag < 1.2 && (video.videoWidth || 0) > 0;
}
function tryEscapeCoverBuffer() {
if (!playerStarted || streamClosed || liveRecoveryAttempts >= MAX_LIVE_RECOVERY_ATTEMPTS) return;
liveRecoveryAttempts += 1;
didHardReloadForLive = false;
recoverLiveVideo();
if (liveRecoveryAttempts >= 2) {
hardReloadLiveOnce();
}
}
function onPublisherBack() {
pendingLiveRecovery = true;
didHardReloadForLive = false;
publisherStuckPolls = 0;
liveRecoveryAttempts = 0;
recoverLiveVideo();
window.setTimeout(() => tryEscapeCoverBuffer(), 500);
window.setTimeout(() => {
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
}, 2000);
}
/** Dopo pausa il buffer HLS resta sulla copertina: forza reload finché il live è allineato. */
function onResumeFromPause() {
pendingLiveRecovery = true;
didHardReloadForLive = false;
publisherStuckPolls = 0;
liveRecoveryAttempts = 0;
tryEscapeCoverBuffer();
}
/** Ultima risorsa: nuova playlist quando il buffer resta sulla copertina con RTMP attivo. */
function hardReloadLiveOnce() {
if (didHardReloadForLive || !Hls.isSupported()) return;
didHardReloadForLive = true;
if (hls) {
hls.destroy();
hls = null;
}
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_live=${Date.now()}`;
hls = new Hls(hlsConfig());
hls.loadSource(url);
hls.attachMedia(video);
bindHlsEvents(hls);
hls.startLoad(-1);
video.play().catch(() => {});
playerStarted = true;
}
/** 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;
}
const onAir = !!data.on_air;
if (onAir) {
offlineMsg.hidden = true;
if (!publisherOnline && showingCover && !paused) {
awaitingMsg.hidden = false;
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app.";
} else {
awaitingMsg.hidden = !awaitingSignal;
}
pausedMsg.hidden = !paused;
if (!playerStarted) startPlayer();
if (paused) {
pendingLiveRecovery = false;
liveRecoveryAttempts = 0;
} else if (wasPaused) {
pendingLiveRecovery = true;
}
if (publisherOnline && !wasPublisherOnline) {
onPublisherBack();
} else if (!paused && wasPaused && playerStarted) {
onResumeFromPause();
} else if (publisherOnline && !paused && playerStarted) {
if (pendingLiveRecovery && !isCaughtUpWithLive()) {
tryEscapeCoverBuffer();
} else if (pendingLiveRecovery && isCaughtUpWithLive()) {
pendingLiveRecovery = false;
liveRecoveryAttempts = 0;
publisherStuckPolls = 0;
} else {
const lag = liveEdgeLagSec();
if (lag > 4) {
publisherStuckPolls += 1;
pendingLiveRecovery = true;
if (publisherStuckPolls >= 2) tryEscapeCoverBuffer();
else if (publisherStuckPolls >= 1) recoverLiveVideo();
} else {
publisherStuckPolls = 0;
}
}
}
if (!publisherOnline) {
didHardReloadForLive = false;
publisherStuckPolls = 0;
}
if (video.paused) video.play().catch(() => {});
} else {
offlineMsg.hidden = false;
awaitingMsg.hidden = true;
pausedMsg.hidden = true;
}
wasOnAir = onAir;
wasPublisherOnline = publisherOnline;
wasPaused = paused;
} catch (_) {
scheduleReload(5000);
}
}
if (playHint) {
playHint.addEventListener("click", () => {
jumpToLiveEdge();
ensurePlaying();
});
}
video.addEventListener("pause", () => {
if (!streamClosed && playerStarted && !video.ended) {
if (playHint) playHint.hidden = false;
}
});
video.addEventListener("playing", () => {
if (playHint) playHint.hidden = true;
});
if (!streamClosed) {
startPlayer();
if (!wasOnAir) offlineMsg.hidden = false;
window.setTimeout(() => {
if (playerStarted && video.readyState < 2 && !didHardReloadForLive) {
hardReloadLiveOnce();
}
}, 4500);
} else {
offlineMsg.hidden = true;
}
pollStatus();
setInterval(pollStatus, 1500);
setInterval(() => {
if (!playerStarted || streamClosed || !pendingLiveRecovery) return;
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
}, 6000);
if (!streamClosed && wasPublisherOnline && !wasPaused) {
pendingLiveRecovery = true;
window.setTimeout(() => {
if (playerStarted && !isCaughtUpWithLive()) tryEscapeCoverBuffer();
}, 2500);
}
</script>
<% end %>
</div>