Files
MatchLiveTv/backend/app/views/public/live/show.html.erb
Emiliano Frascaro 731b43ea88 Stabilizza diretta iOS e aggiunge test simulatore pre-release.
Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento
con debounce, condivisione su iPad e script test_ios_simulator per CI locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:02:14 +02:00

336 lines
12 KiB
Plaintext
Raw Permalink 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 è visibile nel video se la diretta era ancora in corso.</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/player_overlays", 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 streamBadge = document.getElementById("live-stream-badge");
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;
}
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 = "In riconnessione - in attesa del video dall'evento";
} 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>