Files
MatchLiveTv/backend/app/views/public/live/show.html.erb
Emiliano Frascaro 854738b46d Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 15:30:55 +02:00

313 lines
11 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 || @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>
<%# 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 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;
}
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>