openvibers/openvibe.live Commits
Fix viewers stranded on "Stream has ended" after a quick restart
Changes
@@@ -4949,9 +4949,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script>--<script src="/live-notify.js?v=2" defer></script>--<script src="/js/app.js?v=121" defer></script>--<script src="/js/stream-player.js?v=17" defer></script>++<script src="/live-notify.js?v=3" defer></script>++<script src="/js/app.js?v=122" defer></script>++<script src="/js/stream-player.js?v=18" defer></script><script src="/js/chat-shared.js?v=3" defer></script><script src="/js/emotes.js?v=8" defer></script><script src="/js/gif-picker.js?v=1" defer></script>@@@ -3972,7 +3972,7 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n// OpenVibe.Live-native viewer badge — styled like the platform restream badges, in brand// green, so it reads as "this is the count HERE" alongside the RS/Twitch/etc badges.if (liveStreams.length > 0) {-- html += `<span class="ch-restream-badge" style="color:var(--accent)" title="Watching live on OpenVibe.Live${streamCount > 1 ? ` (across ${streamCount} streams)` : ''}"><i class="fa-solid fa-circle-nodes"></i> OpenVibe <i class="fa-solid fa-eye" style="font-size:0.75em"></i> ${hsTotal}</span>`;++ html += `<span class="ch-restream-badge" style="color:var(--accent)" title="Watching live on OpenVibe.Live${streamCount > 1 ? ` (across ${streamCount} streams)` : ''}"><i class="fa-solid fa-circle-nodes"></i> OV <i class="fa-solid fa-eye" style="font-size:0.75em"></i> ${hsTotal}</span>`;}// RS restream badge — reflect the WATCHED slot's robot only (not the first slot's).@@@ -4196,11 +4196,49 @@ function activateChannelStream(stream) {/* ── Stream Status Polling — auto-detect online/offline ──────── */let _streamPollTimer = null;const STREAM_POLL_INTERVAL = 15000; // 15 seconds++// After the player reports the stream ended (or we otherwise know the channel just++// flipped state), poll far more aggressively for a short window. A streamer who++// bounces offline→online in a couple of seconds would otherwise leave viewers++// parked on the "Stream has ended" card for up to 2 × STREAM_POLL_INTERVAL: one++// interval for the live poll to notice the stream died, another for the offline++// poll to notice it came back.++const STREAM_POLL_FAST_INTERVAL = 2000; // 2 seconds++const STREAM_POLL_FAST_WINDOW = 90000; // burst for 90s, then fall back++let _streamPollFastUntil = 0;++// Whether the currently-scheduled interval was armed at the fast cadence, so we++// only tear the timer down and rebuild it when the cadence actually needs to change.++let _streamPollFast = false;function stopStreamStatusPoll() {if (_streamPollTimer) { clearInterval(_streamPollTimer); _streamPollTimer = null; }++ _streamPollFast = false;}++/**++ * Currently-desired poll cadence — fast while inside the burst window. The fast++ * cadence carries per-client jitter so that when a popular channel drops, its++ * waiting viewers don't all hit the endpoint on the same 2s beat.++ */++function _streamPollInterval() {++ if (Date.now() >= _streamPollFastUntil) return STREAM_POLL_INTERVAL;++ return STREAM_POLL_FAST_INTERVAL + Math.floor(Math.random() * 1500);++}++++/**++ * Open (or extend) the fast-poll burst window and re-arm the running poll at the++ * fast cadence right away, so the next check lands in ~2s instead of ~15s.++ */++function _accelerateStreamStatusPoll() {++ _streamPollFastUntil = Date.now() + STREAM_POLL_FAST_WINDOW;++ if (_streamPollTimer && !_streamPollFast && _streamPollRearm) {++ _streamPollRearm();++ }++}++++// Set by whichever poll (live/offline) is currently armed; lets++// _accelerateStreamStatusPoll() rebuild the interval at the new cadence.++let _streamPollRearm = null;++// Render/update the live stream's AI overview under the stream info. Only re-renders// when the text actually changes, so it never clobbers a viewer's expanded state.function _renderChStreamAi(overview, short) {@@@ -5397,10 +5435,17 @@ function startStreamStatusPoll(stream) {stopStreamStatusPoll();if (!currentChannelUsername) return;const username = currentChannelUsername;++ // We're on a healthy live player, so the viewer isn't stranded any more — close++ // any open burst window. Keeping it open would have every viewer of a busy++ // channel hitting the poll endpoint every 2s for no benefit; the WS++ // 'stream-ended' push already tells us the moment this stream dies.++ _streamPollFastUntil = 0;-- _streamPollTimer = setInterval(async () => {++ const tick = async () => {// Stop polling if user navigated away from the channel pageif (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }++ // Drop back to the lazy cadence once the burst window has expired.++ if (_streamPollFast && Date.now() >= _streamPollFastUntil) arm();try {// pollOnly=1 skips the heavy VOD/clip listing queries — the poll only needs// live status + viewer counts + restream info.@@@ -5458,7 +5503,15 @@ function startStreamStatusPoll(stream) {// Update cumulative viewersupdateCumulativeViewers(liveStreams, rsRestream, restreamLinks, extViewers);} catch { /* silent — network error, retry next interval */ }-- }, STREAM_POLL_INTERVAL);++ };++++ const arm = () => {++ if (_streamPollTimer) clearInterval(_streamPollTimer);++ _streamPollFast = Date.now() < _streamPollFastUntil;++ _streamPollTimer = setInterval(tick, _streamPollInterval());++ };++ _streamPollRearm = arm;++ arm();}// Start offline poll — detects when a channel comes online@@@ -5477,9 +5530,15 @@ window.addEventListener('openvibe:stream-live', (e) => {// navigating away, so the page check is essential.const page = document.getElementById('page-channel');if (!page || !page.classList.contains('active')) return;-- // Already showing the live area? nothing to do.++ // Already showing the live area with a healthy player? nothing to do.++ // The player phase check matters: when a streamer bounces offline→online the++ // live area is still on screen (the poll hasn't swapped in the offline card++ // yet) but the player is parked on "Stream has ended". Bailing out here on++ // visibility alone is exactly what used to strand viewers on that card.const liveArea = document.getElementById('ch-live-area');-- if (liveArea && liveArea.style.display !== 'none') return;++ const playerDead = typeof playerLoadState !== 'undefined'++ && (playerLoadState?.phase === 'ended' || playerLoadState?.severity === 'error');++ if (liveArea && liveArea.style.display !== 'none' && !playerDead) return;// Debounce against duplicate SSE + the poll firing together.const now = Date.now();if (now - _lastFastLiveLoad < 4000) return;@@@ -5490,10 +5549,41 @@ window.addEventListener('openvibe:stream-live', (e) => {} catch { /* non-critical accelerator */ }});++// The player tells us the moment the stream dies (server 'stream-ended', or a++// broadcaster that never came back). Without this the page only learns the stream++// dropped on the next 15s poll tick, and only learns it returned on the tick after++// that — so a 1-second offline blip could strand a viewer for ~30s. React now:++// swap to the offline card immediately and burst-poll for the comeback.++let _lastEndedReload = 0;++window.addEventListener('openvibe:stream-ended', (e) => {++ try {++ const page = document.getElementById('page-channel');++ if (!page || !page.classList.contains('active') || !currentChannelUsername) return;++ // Only react to the stream the viewer is actually watching. Player teardown++ // during navigation can surface an "ended" signal for a stream we already++ // moved off of; reloading the channel then would be a spurious jump.++ const endedId = e && e.detail && e.detail.streamId;++ if (endedId && currentStreamId && endedId !== currentStreamId) return;++ // One drop can surface as several "ended" signals (server push + a protocol++ // teardown); collapse them so we reload the channel once.++ const now = Date.now();++ if (now - _lastEndedReload < 4000) return;++ _lastEndedReload = now;++ _accelerateStreamStatusPoll();++ // Re-resolve channel state right away instead of waiting for a tick. If the++ // streamer is already back this lands on the live player; if not, it renders++ // the offline card, which then burst-polls via startOfflineStatusPoll().++ loadChannelPage(currentChannelUsername);++ } catch { /* non-critical accelerator */ }++});++function startOfflineStatusPoll(username) {stopStreamStatusPoll();-- _streamPollTimer = setInterval(async () => {++++ const tick = async () => {if (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }++ // Drop back to the lazy cadence once the burst window has expired.++ if (_streamPollFast && Date.now() >= _streamPollFastUntil) arm();try {// Lightweight live-only endpoint — offline viewers only need to detect go-live,// not refetch VODs/clips/counts every 15s (the heavy channel endpoint).@@@ -5505,7 +5595,19 @@ function startOfflineStatusPoll(username) {toast(`${username} is now live!`, 'success');}} catch { /* silent */ }-- }, STREAM_POLL_INTERVAL);++ };++++ const arm = () => {++ if (_streamPollTimer) clearInterval(_streamPollTimer);++ _streamPollFast = Date.now() < _streamPollFastUntil;++ _streamPollTimer = setInterval(tick, _streamPollInterval());++ };++ _streamPollRearm = arm;++ arm();++ // A viewer landing on the offline card right after the stream dropped is the++ // most likely person to be waiting on a quick restart — check once immediately++ // rather than burning the first full interval.++ if (Date.now() < _streamPollFastUntil) tick();}async function toggleChannelFollow(username) {@@@ -3755,6 +3755,14 @@ function showStreamEnded() {});document.getElementById('video-canvas').style.display = 'none';document.getElementById('video-element').style.display = 'none';++ // Tell the channel page immediately. It owns the live/offline swap and the++ // go-live polling, and would otherwise only notice on its next 15s tick —++ // leaving viewers staring at this card long after a quick restart.++ try {++ window.dispatchEvent(new CustomEvent('openvibe:stream-ended', {++ detail: { streamId: streamRef?.id || null },++ }));++ } catch { /* non-critical accelerator */ }}function showStreamError(msg) {@@@ -97,13 +97,23 @@es.addEventListener('stream-live', function (e) {var d; try { d = JSON.parse(e.data); } catch (_) { return; }if (!d || !d.username) return;-- var key = String(d.managed_id || d.stream_id);++ // Dedupe on the session, not the slot: a managed slot keeps the same++ // managed_id across restarts, so keying on it made every re-go-live++ // within the hour look like a duplicate.++ var key = String(d.stream_id || d.managed_id);var now = Date.now();-- if (seen[key] && now - seen[key] < DEDUPE_MS) return;-- seen[key] = now;-- show(d);++ // d.repeat is the server's "already announced this slot recently" flag —++ // it suppresses the toast only. The local `seen` map is a backstop for++ // duplicate delivery (e.g. SSE reconnect replay).++ var dupe = d.repeat || (seen[key] && now - seen[key] < DEDUPE_MS);++ if (!dupe) {++ seen[key] = now;++ show(d);++ }// Let the app (if loaded) fast-load the stream when the viewer is already// on this streamer's channel page — instead of waiting for the 15s poll.++ // Dispatched even when the toast is deduped: suppressing a repeat toast is++ // cosmetic, but suppressing this leaves the viewer on a dead player.try { window.dispatchEvent(new CustomEvent('openvibe:stream-live', { detail: d })); } catch (_) {}});// EventSource auto-reconnects on error; nothing to do.@@@ -38,19 +38,28 @@ function broadcast(event, data) {}}--// Announce a go-live to every subscribed page (once per slot per hour).++// Announce a go-live to every subscribed page.++//++// The once-per-slot-per-hour rate limit exists so a flappy stream can't spam the++// on-screen toast. It must NOT suppress the event itself: viewers already sitting++// on the channel page rely on this to swap a dead player back to the live feed the++// instant the streamer returns, and a streamer bouncing offline→online is by++// definition inside the hour window. So we always broadcast, and mark repeats with++// `repeat: true` — the client shows a toast only for non-repeats but always acts on++// the event.function announceGoLive(stream, streamer) {if (!stream) return;const slotKey = String(stream.managed_stream_id || `s${stream.id}`);const now = Date.now();-- if (now - (lastAnnounced.get(slotKey) || 0) < ONE_HOUR_MS) return;-- lastAnnounced.set(slotKey, now);++ const repeat = now - (lastAnnounced.get(slotKey) || 0) < ONE_HOUR_MS;++ if (!repeat) lastAnnounced.set(slotKey, now);// occasional cleanup so the Map can't grow unboundedif (lastAnnounced.size > 500) {for (const [k, t] of lastAnnounced) if (now - t > ONE_HOUR_MS) lastAnnounced.delete(k);}try {broadcast('stream-live', {++ repeat,username: (streamer && streamer.username) || stream.username || null,display_name: (streamer && streamer.display_name) || stream.display_name || null,avatar_url: (streamer && streamer.avatar_url) || stream.avatar_url || null,
@@ -4949,9 +4949,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -4949,9 +4949,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
-<script src="/live-notify.js?v=2" defer></script>
-<script src="/js/app.js?v=121" defer></script>
-<script src="/js/stream-player.js?v=17" defer></script>
+<script src="/live-notify.js?v=3" defer></script>
+<script src="/js/app.js?v=122" defer></script>
+<script src="/js/stream-player.js?v=18" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
@@ -3972,7 +3972,7 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
@@ -3972,7 +3972,7 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
// OpenVibe.Live-native viewer badge — styled like the platform restream badges, in brand
// OpenVibe.Live-native viewer badge — styled like the platform restream badges, in brand
// green, so it reads as "this is the count HERE" alongside the RS/Twitch/etc badges.
// green, so it reads as "this is the count HERE" alongside the RS/Twitch/etc badges.
if (liveStreams.length > 0) {
if (liveStreams.length > 0) {
- html += `<span class="ch-restream-badge" style="color:var(--accent)" title="Watching live on OpenVibe.Live${streamCount > 1 ? ` (across ${streamCount} streams)` : ''}"><i class="fa-solid fa-circle-nodes"></i> OpenVibe <i class="fa-solid fa-eye" style="font-size:0.75em"></i> ${hsTotal}</span>`;
+ html += `<span class="ch-restream-badge" style="color:var(--accent)" title="Watching live on OpenVibe.Live${streamCount > 1 ? ` (across ${streamCount} streams)` : ''}"><i class="fa-solid fa-circle-nodes"></i> OV <i class="fa-solid fa-eye" style="font-size:0.75em"></i> ${hsTotal}</span>`;
}
}
// RS restream badge — reflect the WATCHED slot's robot only (not the first slot's).
// RS restream badge — reflect the WATCHED slot's robot only (not the first slot's).
@@ -4196,11 +4196,49 @@ function activateChannelStream(stream) {
@@ -4196,11 +4196,49 @@ function activateChannelStream(stream) {
/* ── Stream Status Polling — auto-detect online/offline ──────── */
/* ── Stream Status Polling — auto-detect online/offline ──────── */
let _streamPollTimer = null;
let _streamPollTimer = null;
const STREAM_POLL_INTERVAL = 15000; // 15 seconds
const STREAM_POLL_INTERVAL = 15000; // 15 seconds
+// After the player reports the stream ended (or we otherwise know the channel just
+// flipped state), poll far more aggressively for a short window. A streamer who
+// bounces offline→online in a couple of seconds would otherwise leave viewers
+// parked on the "Stream has ended" card for up to 2 × STREAM_POLL_INTERVAL: one
+// interval for the live poll to notice the stream died, another for the offline
+// poll to notice it came back.
+const STREAM_POLL_FAST_INTERVAL = 2000; // 2 seconds
+const STREAM_POLL_FAST_WINDOW = 90000; // burst for 90s, then fall back
+let _streamPollFastUntil = 0;
+// Whether the currently-scheduled interval was armed at the fast cadence, so we
+// only tear the timer down and rebuild it when the cadence actually needs to change.
+let _streamPollFast = false;
function stopStreamStatusPoll() {
function stopStreamStatusPoll() {
if (_streamPollTimer) { clearInterval(_streamPollTimer); _streamPollTimer = null; }
if (_streamPollTimer) { clearInterval(_streamPollTimer); _streamPollTimer = null; }
+ _streamPollFast = false;
}
}
+/**
+ * Currently-desired poll cadence — fast while inside the burst window. The fast
+ * cadence carries per-client jitter so that when a popular channel drops, its
+ * waiting viewers don't all hit the endpoint on the same 2s beat.
+ */
+function _streamPollInterval() {
+ if (Date.now() >= _streamPollFastUntil) return STREAM_POLL_INTERVAL;
+ return STREAM_POLL_FAST_INTERVAL + Math.floor(Math.random() * 1500);
+}
+
+/**
+ * Open (or extend) the fast-poll burst window and re-arm the running poll at the
+ * fast cadence right away, so the next check lands in ~2s instead of ~15s.
+ */
+function _accelerateStreamStatusPoll() {
+ _streamPollFastUntil = Date.now() + STREAM_POLL_FAST_WINDOW;
+ if (_streamPollTimer && !_streamPollFast && _streamPollRearm) {
+ _streamPollRearm();
+ }
+}
+
+// Set by whichever poll (live/offline) is currently armed; lets
+// _accelerateStreamStatusPoll() rebuild the interval at the new cadence.
+let _streamPollRearm = null;
+
// Render/update the live stream's AI overview under the stream info. Only re-renders
// Render/update the live stream's AI overview under the stream info. Only re-renders
// when the text actually changes, so it never clobbers a viewer's expanded state.
// when the text actually changes, so it never clobbers a viewer's expanded state.
function _renderChStreamAi(overview, short) {
function _renderChStreamAi(overview, short) {
@@ -5397,10 +5435,17 @@ function startStreamStatusPoll(stream) {
@@ -5397,10 +5435,17 @@ function startStreamStatusPoll(stream) {
stopStreamStatusPoll();
stopStreamStatusPoll();
if (!currentChannelUsername) return;
if (!currentChannelUsername) return;
const username = currentChannelUsername;
const username = currentChannelUsername;
+ // We're on a healthy live player, so the viewer isn't stranded any more — close
+ // any open burst window. Keeping it open would have every viewer of a busy
+ // channel hitting the poll endpoint every 2s for no benefit; the WS
+ // 'stream-ended' push already tells us the moment this stream dies.
+ _streamPollFastUntil = 0;
- _streamPollTimer = setInterval(async () => {
+ const tick = async () => {
// Stop polling if user navigated away from the channel page
// Stop polling if user navigated away from the channel page
if (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }
if (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }
+ // Drop back to the lazy cadence once the burst window has expired.
+ if (_streamPollFast && Date.now() >= _streamPollFastUntil) arm();
try {
try {
// pollOnly=1 skips the heavy VOD/clip listing queries — the poll only needs
// pollOnly=1 skips the heavy VOD/clip listing queries — the poll only needs
// live status + viewer counts + restream info.
// live status + viewer counts + restream info.
@@ -5458,7 +5503,15 @@ function startStreamStatusPoll(stream) {
@@ -5458,7 +5503,15 @@ function startStreamStatusPoll(stream) {
// Update cumulative viewers
// Update cumulative viewers
updateCumulativeViewers(liveStreams, rsRestream, restreamLinks, extViewers);
updateCumulativeViewers(liveStreams, rsRestream, restreamLinks, extViewers);
} catch { /* silent — network error, retry next interval */ }
} catch { /* silent — network error, retry next interval */ }
- }, STREAM_POLL_INTERVAL);
+ };
+
+ const arm = () => {
+ if (_streamPollTimer) clearInterval(_streamPollTimer);
+ _streamPollFast = Date.now() < _streamPollFastUntil;
+ _streamPollTimer = setInterval(tick, _streamPollInterval());
+ };
+ _streamPollRearm = arm;
+ arm();
}
}
// Start offline poll — detects when a channel comes online
// Start offline poll — detects when a channel comes online
@@ -5477,9 +5530,15 @@ window.addEventListener('openvibe:stream-live', (e) => {
@@ -5477,9 +5530,15 @@ window.addEventListener('openvibe:stream-live', (e) => {
// navigating away, so the page check is essential.
// navigating away, so the page check is essential.
const page = document.getElementById('page-channel');
const page = document.getElementById('page-channel');
if (!page || !page.classList.contains('active')) return;
if (!page || !page.classList.contains('active')) return;
- // Already showing the live area? nothing to do.
+ // Already showing the live area with a healthy player? nothing to do.
+ // The player phase check matters: when a streamer bounces offline→online the
+ // live area is still on screen (the poll hasn't swapped in the offline card
+ // yet) but the player is parked on "Stream has ended". Bailing out here on
+ // visibility alone is exactly what used to strand viewers on that card.
const liveArea = document.getElementById('ch-live-area');
const liveArea = document.getElementById('ch-live-area');
- if (liveArea && liveArea.style.display !== 'none') return;
+ const playerDead = typeof playerLoadState !== 'undefined'
+ && (playerLoadState?.phase === 'ended' || playerLoadState?.severity === 'error');
+ if (liveArea && liveArea.style.display !== 'none' && !playerDead) return;
// Debounce against duplicate SSE + the poll firing together.
// Debounce against duplicate SSE + the poll firing together.
const now = Date.now();
const now = Date.now();
if (now - _lastFastLiveLoad < 4000) return;
if (now - _lastFastLiveLoad < 4000) return;
@@ -5490,10 +5549,41 @@ window.addEventListener('openvibe:stream-live', (e) => {
@@ -5490,10 +5549,41 @@ window.addEventListener('openvibe:stream-live', (e) => {
} catch { /* non-critical accelerator */ }
} catch { /* non-critical accelerator */ }
});
});
+// The player tells us the moment the stream dies (server 'stream-ended', or a
+// broadcaster that never came back). Without this the page only learns the stream
+// dropped on the next 15s poll tick, and only learns it returned on the tick after
+// that — so a 1-second offline blip could strand a viewer for ~30s. React now:
+// swap to the offline card immediately and burst-poll for the comeback.
+let _lastEndedReload = 0;
+window.addEventListener('openvibe:stream-ended', (e) => {
+ try {
+ const page = document.getElementById('page-channel');
+ if (!page || !page.classList.contains('active') || !currentChannelUsername) return;
+ // Only react to the stream the viewer is actually watching. Player teardown
+ // during navigation can surface an "ended" signal for a stream we already
+ // moved off of; reloading the channel then would be a spurious jump.
+ const endedId = e && e.detail && e.detail.streamId;
+ if (endedId && currentStreamId && endedId !== currentStreamId) return;
+ // One drop can surface as several "ended" signals (server push + a protocol
+ // teardown); collapse them so we reload the channel once.
+ const now = Date.now();
+ if (now - _lastEndedReload < 4000) return;
+ _lastEndedReload = now;
+ _accelerateStreamStatusPoll();
+ // Re-resolve channel state right away instead of waiting for a tick. If the
+ // streamer is already back this lands on the live player; if not, it renders
+ // the offline card, which then burst-polls via startOfflineStatusPoll().
+ loadChannelPage(currentChannelUsername);
+ } catch { /* non-critical accelerator */ }
+});
+
function startOfflineStatusPoll(username) {
function startOfflineStatusPoll(username) {
stopStreamStatusPoll();
stopStreamStatusPoll();
- _streamPollTimer = setInterval(async () => {
+
+ const tick = async () => {
if (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }
if (currentChannelUsername !== username) { stopStreamStatusPoll(); return; }
+ // Drop back to the lazy cadence once the burst window has expired.
+ if (_streamPollFast && Date.now() >= _streamPollFastUntil) arm();
try {
try {
// Lightweight live-only endpoint — offline viewers only need to detect go-live,
// Lightweight live-only endpoint — offline viewers only need to detect go-live,
// not refetch VODs/clips/counts every 15s (the heavy channel endpoint).
// not refetch VODs/clips/counts every 15s (the heavy channel endpoint).
@@ -5505,7 +5595,19 @@ function startOfflineStatusPoll(username) {
@@ -5505,7 +5595,19 @@ function startOfflineStatusPoll(username) {
toast(`${username} is now live!`, 'success');
toast(`${username} is now live!`, 'success');
}
}
} catch { /* silent */ }
} catch { /* silent */ }
- }, STREAM_POLL_INTERVAL);
+ };
+
+ const arm = () => {
+ if (_streamPollTimer) clearInterval(_streamPollTimer);
+ _streamPollFast = Date.now() < _streamPollFastUntil;
+ _streamPollTimer = setInterval(tick, _streamPollInterval());
+ };
+ _streamPollRearm = arm;
+ arm();
+ // A viewer landing on the offline card right after the stream dropped is the
+ // most likely person to be waiting on a quick restart — check once immediately
+ // rather than burning the first full interval.
+ if (Date.now() < _streamPollFastUntil) tick();
}
}
async function toggleChannelFollow(username) {
async function toggleChannelFollow(username) {
@@ -3755,6 +3755,14 @@ function showStreamEnded() {
@@ -3755,6 +3755,14 @@ function showStreamEnded() {
});
});
document.getElementById('video-canvas').style.display = 'none';
document.getElementById('video-canvas').style.display = 'none';
document.getElementById('video-element').style.display = 'none';
document.getElementById('video-element').style.display = 'none';
+ // Tell the channel page immediately. It owns the live/offline swap and the
+ // go-live polling, and would otherwise only notice on its next 15s tick —
+ // leaving viewers staring at this card long after a quick restart.
+ try {
+ window.dispatchEvent(new CustomEvent('openvibe:stream-ended', {
+ detail: { streamId: streamRef?.id || null },
+ }));
+ } catch { /* non-critical accelerator */ }
}
}
function showStreamError(msg) {
function showStreamError(msg) {
@@ -97,13 +97,23 @@
@@ -97,13 +97,23 @@
es.addEventListener('stream-live', function (e) {
es.addEventListener('stream-live', function (e) {
var d; try { d = JSON.parse(e.data); } catch (_) { return; }
var d; try { d = JSON.parse(e.data); } catch (_) { return; }
if (!d || !d.username) return;
if (!d || !d.username) return;
- var key = String(d.managed_id || d.stream_id);
+ // Dedupe on the session, not the slot: a managed slot keeps the same
+ // managed_id across restarts, so keying on it made every re-go-live
+ // within the hour look like a duplicate.
+ var key = String(d.stream_id || d.managed_id);
var now = Date.now();
var now = Date.now();
- if (seen[key] && now - seen[key] < DEDUPE_MS) return;
- seen[key] = now;
- show(d);
+ // d.repeat is the server's "already announced this slot recently" flag —
+ // it suppresses the toast only. The local `seen` map is a backstop for
+ // duplicate delivery (e.g. SSE reconnect replay).
+ var dupe = d.repeat || (seen[key] && now - seen[key] < DEDUPE_MS);
+ if (!dupe) {
+ seen[key] = now;
+ show(d);
+ }
// Let the app (if loaded) fast-load the stream when the viewer is already
// Let the app (if loaded) fast-load the stream when the viewer is already
// on this streamer's channel page — instead of waiting for the 15s poll.
// on this streamer's channel page — instead of waiting for the 15s poll.
+ // Dispatched even when the toast is deduped: suppressing a repeat toast is
+ // cosmetic, but suppressing this leaves the viewer on a dead player.
try { window.dispatchEvent(new CustomEvent('openvibe:stream-live', { detail: d })); } catch (_) {}
try { window.dispatchEvent(new CustomEvent('openvibe:stream-live', { detail: d })); } catch (_) {}
});
});
// EventSource auto-reconnects on error; nothing to do.
// EventSource auto-reconnects on error; nothing to do.
@@ -38,19 +38,28 @@ function broadcast(event, data) {
@@ -38,19 +38,28 @@ function broadcast(event, data) {
}
}
}
}
-// Announce a go-live to every subscribed page (once per slot per hour).
+// Announce a go-live to every subscribed page.
+//
+// The once-per-slot-per-hour rate limit exists so a flappy stream can't spam the
+// on-screen toast. It must NOT suppress the event itself: viewers already sitting
+// on the channel page rely on this to swap a dead player back to the live feed the
+// instant the streamer returns, and a streamer bouncing offline→online is by
+// definition inside the hour window. So we always broadcast, and mark repeats with
+// `repeat: true` — the client shows a toast only for non-repeats but always acts on
+// the event.
function announceGoLive(stream, streamer) {
function announceGoLive(stream, streamer) {
if (!stream) return;
if (!stream) return;
const slotKey = String(stream.managed_stream_id || `s${stream.id}`);
const slotKey = String(stream.managed_stream_id || `s${stream.id}`);
const now = Date.now();
const now = Date.now();
- if (now - (lastAnnounced.get(slotKey) || 0) < ONE_HOUR_MS) return;
- lastAnnounced.set(slotKey, now);
+ const repeat = now - (lastAnnounced.get(slotKey) || 0) < ONE_HOUR_MS;
+ if (!repeat) lastAnnounced.set(slotKey, now);
// occasional cleanup so the Map can't grow unbounded
// occasional cleanup so the Map can't grow unbounded
if (lastAnnounced.size > 500) {
if (lastAnnounced.size > 500) {
for (const [k, t] of lastAnnounced) if (now - t > ONE_HOUR_MS) lastAnnounced.delete(k);
for (const [k, t] of lastAnnounced) if (now - t > ONE_HOUR_MS) lastAnnounced.delete(k);
}
}
try {
try {
broadcast('stream-live', {
broadcast('stream-live', {
+ repeat,
username: (streamer && streamer.username) || stream.username || null,
username: (streamer && streamer.username) || stream.username || null,
display_name: (streamer && streamer.display_name) || stream.display_name || null,
display_name: (streamer && streamer.display_name) || stream.display_name || null,
avatar_url: (streamer && streamer.avatar_url) || stream.avatar_url || null,
avatar_url: (streamer && streamer.avatar_url) || stream.avatar_url || null,