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>
391 lines
13 KiB
Plaintext
391 lines
13 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, "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 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();
|
||
}
|
||
|
||
/** 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>
|