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>
This commit is contained in:
@@ -13,23 +13,6 @@
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<%= live_match_page_heading(@match) %>
|
||||
<p>
|
||||
<% if @stream_closed %>
|
||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
||||
<% elsif @on_air %>
|
||||
<span id="status-badge" class="badge badge-on-air">In onda</span>
|
||||
<% else %>
|
||||
<span id="status-badge" class="badge badge-wait">In attesa</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div id="scoreboard" class="scoreboard">
|
||||
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
|
||||
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@session.score_state) %>
|
||||
</p>
|
||||
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
|
||||
</div>
|
||||
|
||||
<% if @stream_closed %>
|
||||
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
||||
@@ -43,76 +26,94 @@
|
||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||
<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 badge = document.getElementById("status-badge");
|
||||
const setsLine = document.getElementById("sets-line");
|
||||
const partialsLine = document.getElementById("partials-line");
|
||||
const scoreLine = document.getElementById("score-line");
|
||||
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 stallPolls = 0;
|
||||
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 setBadge(status, live, onAir, closed) {
|
||||
if (closed) {
|
||||
badge.textContent = "Diretta chiusa";
|
||||
badge.className = "badge badge-ended";
|
||||
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;
|
||||
}
|
||||
if (onAir) {
|
||||
badge.textContent = "In onda";
|
||||
badge.className = "badge badge-on-air";
|
||||
} else if (live) {
|
||||
badge.textContent = "LIVE";
|
||||
badge.className = "badge badge-live";
|
||||
} else if (status === "reconnecting") {
|
||||
badge.textContent = "Riconnessione";
|
||||
badge.className = "badge badge-wait";
|
||||
} else {
|
||||
badge.textContent = status;
|
||||
badge.className = "badge badge-wait";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSets(score) {
|
||||
if (!score) return "—";
|
||||
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
|
||||
}
|
||||
|
||||
function formatPartials(score) {
|
||||
const partials = score?.set_partials;
|
||||
if (!partials?.length) return "";
|
||||
return partials
|
||||
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function formatPoints(data) {
|
||||
if (!data.score || !data.home_name || !data.away_name) return "—";
|
||||
const s = data.score;
|
||||
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
const score = data.score;
|
||||
setsLine.textContent = formatSets(score);
|
||||
const partials = formatPartials(score);
|
||||
if (partials) {
|
||||
partialsLine.textContent = `Parziali: ${partials}`;
|
||||
partialsLine.hidden = false;
|
||||
} else {
|
||||
partialsLine.hidden = true;
|
||||
if (force || lag > 2) {
|
||||
video.currentTime = Math.max(0, pos - 1);
|
||||
lastLiveSeekAt = now;
|
||||
}
|
||||
scoreLine.textContent = formatPoints(data);
|
||||
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() {
|
||||
@@ -120,7 +121,7 @@
|
||||
streamClosed = true;
|
||||
destroyPlayer();
|
||||
video.style.display = "none";
|
||||
offlineMsg.style.display = "none";
|
||||
offlineMsg.hidden = true;
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "stream-ended";
|
||||
panel.className = "stream-ended";
|
||||
@@ -129,18 +130,14 @@
|
||||
<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">Il punteggio finale resta visibile sopra.</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 hlsUrlFresh() {
|
||||
const sep = hlsUrl.includes("?") ? "&" : "?";
|
||||
return `${hlsUrl}${sep}_ts=${Date.now()}`;
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
playerStarted = false;
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
@@ -149,24 +146,119 @@
|
||||
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() {
|
||||
destroyPlayer();
|
||||
const url = hlsUrlFresh();
|
||||
if (playerStarted) return;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
|
||||
hls.loadSource(url);
|
||||
hls = new Hls(hlsConfig());
|
||||
hls.loadSource(hlsUrlStable);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
scheduleReload(2500);
|
||||
}
|
||||
});
|
||||
bindHlsEvents(hls);
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
video.src = hlsUrlStable;
|
||||
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
||||
}
|
||||
playerStarted = true;
|
||||
}
|
||||
|
||||
function scheduleReload(delayMs) {
|
||||
@@ -179,11 +271,12 @@
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(`/live/${sessionId}/status.json`);
|
||||
const res = await fetch(`/live/${sessionId}/status.json?t=${Date.now()}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
setBadge(data.status, data.live, data.on_air, data.stream_closed);
|
||||
updateScoreboard(data);
|
||||
|
||||
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;
|
||||
@@ -192,39 +285,106 @@
|
||||
const onAir = !!data.on_air;
|
||||
|
||||
if (onAir) {
|
||||
offlineMsg.style.display = "none";
|
||||
if (!wasOnAir) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
} else if (video.readyState < 2) {
|
||||
stallPolls += 1;
|
||||
if (stallPolls >= 2) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
offlineMsg.hidden = true;
|
||||
if (!publisherOnline && showingCover && !paused) {
|
||||
awaitingMsg.hidden = false;
|
||||
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app.";
|
||||
} else {
|
||||
stallPolls = 0;
|
||||
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.style.display = "block";
|
||||
destroyPlayer();
|
||||
stallPolls = 0;
|
||||
offlineMsg.hidden = false;
|
||||
awaitingMsg.hidden = true;
|
||||
pausedMsg.hidden = true;
|
||||
}
|
||||
|
||||
wasOnAir = onAir;
|
||||
wasPublisherOnline = publisherOnline;
|
||||
wasPaused = paused;
|
||||
} catch (_) {
|
||||
scheduleReload(5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (wasOnAir) {
|
||||
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.style.display = "block";
|
||||
offlineMsg.hidden = true;
|
||||
}
|
||||
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 3000);
|
||||
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>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card replay-show__status">
|
||||
<p>Registrazione in elaborazione. Riceverai un’email quando sarà pronta.</p>
|
||||
</div>
|
||||
<% elsif @recording.ready? %>
|
||||
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
||||
<div class="replay-show__player card">
|
||||
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
|
||||
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
|
||||
@@ -46,6 +46,11 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif @recording.ready? %>
|
||||
<div class="card replay-show__status">
|
||||
<p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p>
|
||||
<p class="replay-show__details-line">Durata registrata: <%= @recording.duration_label %> · <%= @recording.byte_size_label %></p>
|
||||
</div>
|
||||
<% elsif @recording.status == "failed" %>
|
||||
<div class="card replay-show__status">
|
||||
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
|
||||
|
||||
Reference in New Issue
Block a user