Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="live-player-wrap">
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></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
|
||||
@@ -65,52 +65,40 @@
|
||||
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;
|
||||
let stallRecoveryAt = 0;
|
||||
let firstLevelSynced = false;
|
||||
let lastPlaybackAt = Date.now();
|
||||
let lastPlaybackTime = 0;
|
||||
const hlsUrlStable = hlsUrl;
|
||||
|
||||
function hlsConfig() {
|
||||
return {
|
||||
enableWorker: false,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
liveDurationInfinity: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
liveSyncMode: "edge",
|
||||
maxLiveSyncPlaybackRate: 1.08,
|
||||
maxBufferHole: 1.0,
|
||||
backBufferLength: 20,
|
||||
nudgeMaxRetry: 8,
|
||||
liveSyncDurationCount: 5,
|
||||
liveMaxLatencyDurationCount: 14,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
maxBufferHole: 2.5,
|
||||
maxLiveSyncPlaybackRate: 1.0,
|
||||
backBufferLength: 30,
|
||||
nudgeMaxRetry: 12,
|
||||
xhrSetup: (xhr) => { xhr.withCredentials = true; },
|
||||
};
|
||||
}
|
||||
|
||||
let lastLiveSeekAt = 0;
|
||||
let firstLevelSynced = false;
|
||||
function markPlaybackProgress() {
|
||||
const t = video.currentTime;
|
||||
if (t > lastPlaybackTime + 0.05) {
|
||||
lastPlaybackTime = t;
|
||||
lastPlaybackAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
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 playbackStalledMs() {
|
||||
return Date.now() - lastPlaybackAt;
|
||||
}
|
||||
|
||||
function ensurePlaying() {
|
||||
@@ -122,11 +110,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
/** 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() {
|
||||
@@ -159,103 +155,59 @@
|
||||
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.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) {
|
||||
instance.startLoad(-1);
|
||||
ensurePlaying();
|
||||
window.setTimeout(() => {
|
||||
if (!streamClosed && playerStarted) {
|
||||
instance.startLoad(-1);
|
||||
ensurePlaying();
|
||||
}
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
instance.recoverMediaError();
|
||||
return;
|
||||
}
|
||||
scheduleReload(5000);
|
||||
hardReloadPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
nudgePlayback();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
hardReloadPlayer();
|
||||
}
|
||||
|
||||
/** Ultima risorsa: nuova playlist quando il buffer resta sulla copertina con RTMP attivo. */
|
||||
function hardReloadLiveOnce() {
|
||||
if (didHardReloadForLive || !Hls.isSupported()) return;
|
||||
didHardReloadForLive = true;
|
||||
function onEnterPause() {
|
||||
nudgePlayback();
|
||||
}
|
||||
|
||||
function hardReloadPlayer() {
|
||||
if (!Hls.isSupported()) return;
|
||||
firstLevelSynced = false;
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_live=${Date.now()}`;
|
||||
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_t=${Date.now()}`;
|
||||
hls = new Hls(hlsConfig());
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
bindHlsEvents(hls);
|
||||
hls.startLoad(-1);
|
||||
video.play().catch(() => {});
|
||||
playerStarted = true;
|
||||
ensurePlaying();
|
||||
}
|
||||
|
||||
/** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */
|
||||
@@ -296,53 +248,25 @@
|
||||
}
|
||||
|
||||
const onAir = !!data.on_air;
|
||||
const sessionLive = !!data.live;
|
||||
|
||||
if (onAir) {
|
||||
if (onAir || sessionLive) {
|
||||
offlineMsg.hidden = true;
|
||||
if (!publisherOnline && showingCover && !paused) {
|
||||
awaitingMsg.hidden = false;
|
||||
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app.";
|
||||
awaitingMsg.textContent = "Copertina in onda — in attesa del segnale dal telefono.";
|
||||
} else {
|
||||
awaitingMsg.hidden = !awaitingSignal;
|
||||
}
|
||||
pausedMsg.hidden = !paused;
|
||||
if (!playerStarted) startPlayer();
|
||||
|
||||
if (paused) {
|
||||
pendingLiveRecovery = false;
|
||||
liveRecoveryAttempts = 0;
|
||||
} else if (wasPaused) {
|
||||
pendingLiveRecovery = true;
|
||||
}
|
||||
if (paused && !wasPaused) onEnterPause();
|
||||
if (!paused && wasPaused && playerStarted) onResumeFromPause();
|
||||
if (publisherOnline && !wasPublisherOnline) onPublisherBack();
|
||||
|
||||
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(() => {});
|
||||
if (video.paused) ensurePlaying();
|
||||
else if (playbackStalledMs() > 12000) nudgePlayback(true);
|
||||
} else {
|
||||
offlineMsg.hidden = false;
|
||||
awaitingMsg.hidden = true;
|
||||
@@ -358,45 +282,30 @@
|
||||
}
|
||||
|
||||
if (playHint) {
|
||||
playHint.addEventListener("click", () => {
|
||||
jumpToLiveEdge();
|
||||
ensurePlaying();
|
||||
});
|
||||
playHint.addEventListener("click", () => ensurePlaying());
|
||||
}
|
||||
video.addEventListener("pause", () => {
|
||||
if (!streamClosed && playerStarted && !video.ended) {
|
||||
if (playHint) playHint.hidden = false;
|
||||
}
|
||||
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();
|
||||
if (!wasOnAir) offlineMsg.hidden = false;
|
||||
offlineMsg.hidden = wasOnAir;
|
||||
window.setTimeout(() => {
|
||||
if (playerStarted && video.readyState < 2 && !didHardReloadForLive) {
|
||||
hardReloadLiveOnce();
|
||||
}
|
||||
}, 4500);
|
||||
if (playerStarted && !firstLevelSynced) hardReloadPlayer();
|
||||
}, 12000);
|
||||
} 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);
|
||||
}
|
||||
setInterval(pollStatus, 2000);
|
||||
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user