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>
336 lines
12 KiB
Plaintext
336 lines
12 KiB
Plaintext
<% 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 l’ultimo 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>
|