Evita buchi HLS/YouTube in pausa (path slate ricreata, reload player, no patch recording inutili) e richiede conferma su tasti laterali app/regia. Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
12 KiB
ERB
347 lines
12 KiB
ERB
<% club_name = @match.team.club&.name %>
|
|
<% content_for :title do %><%= t("live.show.title", club_name: club_name, team_name: @match.team.name, opponent_name: @match.opponent_name) %><% end %>
|
|
<% content_for :meta_description do %><%= t(
|
|
"live.show.meta_description",
|
|
club_name: club_name,
|
|
team_name: @match.team.name,
|
|
opponent_name: @match.opponent_name,
|
|
location: @match.location.present? ? t("live.show.meta_description_location", location: @match.location) : ""
|
|
) %><% 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 t("live.show.back_to_all"), 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><%= t("live.show.stream_ended_title") %></h2>
|
|
<p><%= t("live.show.stream_ended_body") %></p>
|
|
<% if @session.ended_at %>
|
|
<p class="stream-ended-meta"><%= t("live.show.stream_ended_closed_at", date: l_local(@session.ended_at)) %></p>
|
|
<% end %>
|
|
<p class="stream-ended-hint"><%= t("live.show.stream_ended_hint") %></p>
|
|
<%= link_to t("live.show.all_live_link"), 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><%= t("live.show.youtube_title") %></h2>
|
|
<% if @session.youtube_watch_url.present? %>
|
|
<p><%= t("live.show.youtube_available_body") %></p>
|
|
<%= link_to t("live.show.youtube_watch_link"), @session.youtube_watch_url, class: "btn stream-ended-btn", target: "_blank", rel: "noopener" %>
|
|
<% else %>
|
|
<p><%= t("live.show.youtube_preparing_body") %></p>
|
|
<meta http-equiv="refresh" content="8">
|
|
<% end %>
|
|
<p class="stream-ended-hint"><%= t("live.show.youtube_hint") %></p>
|
|
</div>
|
|
<% else %>
|
|
<div class="live-player-wrap">
|
|
<video id="player" controls playsinline muted autoplay poster="<%= @cover_poster_url %>"></video>
|
|
<%= render "public/live/player_overlays", match: @match, session: @session %>
|
|
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
|
<%= t("live.show.play_hint") %>
|
|
</button>
|
|
</div>
|
|
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
|
|
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
|
|
<p id="reconnecting-msg" class="live-status-msg" hidden><%= t("live.show.js_reconnecting_awaiting") %></p>
|
|
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></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 reconnectingMsg = document.getElementById("reconnecting-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 = <%= raw t("live.overlays.badge_ended").to_json %>;
|
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
|
|
return;
|
|
}
|
|
const paused = !!(data.paused || data.status === "paused");
|
|
if (paused) {
|
|
streamBadge.textContent = <%= raw t("live.index.badge_paused").to_json %>;
|
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
|
} else if (data.on_air) {
|
|
streamBadge.textContent = <%= raw t("live.index.badge_live").to_json %>;
|
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
|
|
} else {
|
|
streamBadge.textContent = <%= raw t("live.overlays.badge_waiting").to_json %>;
|
|
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><%= j t("live.show.stream_ended_title") %></h2>
|
|
<p><%= j t("live.show.stream_ended_body") %></p>
|
|
<p class="stream-ended-hint"><%= j t("live.show.stream_ended_reload_hint") %></p>
|
|
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn"><%= j t("live.show.all_live_link") %></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() {
|
|
// Camera → slate cambia risoluzione/codec: senza reload HLS.js resta spesso bloccato.
|
|
hardReloadPlayer();
|
|
}
|
|
|
|
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 = true;
|
|
if (reconnectingMsg) reconnectingMsg.hidden = false;
|
|
} else {
|
|
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
|
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;
|
|
if (reconnectingMsg) reconnectingMsg.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>
|