From ae8bc6b0c7959e520faa0fc2d1d3c72bed193d6e Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 24 Jun 2026 16:24:25 -0500 Subject: [PATCH] fix(liveness): use lastSeenAt as authoritative offline signal, 5m threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check flipped to 'offline' after 15s of no local segments. Way too aggressive — a brief stall would send the user to the offline page. The 15s was also tied to the local segment, which isn't the right signal: navigating away and back resets the local segment even if the streamer's been gone for hours. Two changes: 1. Bump OFFLINE_AFTER_SECONDS from 15 to 300 (5m). 5 minutes gives transient stalls a chance to recover without falsely flipping. 2. Tie the offline check to livestream.record.lastSeenAt instead of the local segment. The director (server) updates lastSeenAt every ~30s whenever it ingests a segment, so this is the server's view of 'when did we last see a segment from this streamer'. A 5-minute gap is the authoritative offline signal. 3. Add a hard offline trigger for livestream.record.endedAt — if the streamer explicitly ended, immediately go offline regardless of timestamps. The check needed a re-render driver to actually progress over time when no segments are arriving, so the hook now runs a 1s tick while a livestream is being tracked. Stale threshold unchanged at 10s (per request). Stale is still based on local segment age — that's about in-flight playback quality, which is a different concern from 'is the stream over'. --- js/web/src/hooks/use-liveness-state.ts | 44 ++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/js/web/src/hooks/use-liveness-state.ts b/js/web/src/hooks/use-liveness-state.ts index 6f97e3dd..909ed361 100644 --- a/js/web/src/hooks/use-liveness-state.ts +++ b/js/web/src/hooks/use-liveness-state.ts @@ -3,8 +3,20 @@ import { useEffect, useState } from "react"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; +// "stale" fires when local playback hasn't received a segment in a +// while — typically a network blip. Short by design so the user sees +// a "reconnecting" overlay quickly when the stream is hiccuping. const STALE_AFTER_SECONDS = 10; -const OFFLINE_AFTER_SECONDS = 15; + +// "offline" is the authoritative "the stream is done" signal, derived +// from the server's lastSeenAt on the livestream record (updated by +// the director every ~30s whenever a segment is ingested). 5 minutes +// gives transient stalls a chance to recover without falsely flipping +// to the offline page. As a fallback, if the livestream record's +// lastSeenAt isn't available, we use the local segment age — covers +// the case where the WebSocket hasn't delivered the record yet but +// segments stopped flowing long ago. +const OFFLINE_AFTER_SECONDS = 300; export type Liveness = "live" | "stale" | "offline" | "never-live"; @@ -29,10 +41,38 @@ export function useLivenessState(store: LivestreamStore): Liveness { return () => window.clearInterval(id); }, [state.segment]); + // The lastSeenAt-based offline check needs a re-render driver so it + // progresses over time when no segments are arriving. Run a 1s tick + // while a livestream is being tracked; clean up when it goes away. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!state.livestream?.record.lastSeenAt) return; + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [state.livestream?.record.lastSeenAt]); + + // Hard offline: the streamer explicitly ended the stream. + if (state.livestream?.record.endedAt) { + return "offline"; + } + + // Authoritative offline: server hasn't seen a segment in 5m. + const lastSeenAt = state.livestream?.record.lastSeenAt; + if (lastSeenAt) { + const secondsSinceLastSeen = (now - new Date(lastSeenAt).getTime()) / 1000; + if (secondsSinceLastSeen >= OFFLINE_AFTER_SECONDS) { + return "offline"; + } + } + if (!state.hasReceivedSegment && !state.livestream) { return "never-live"; } - if (secondsSinceSegment >= OFFLINE_AFTER_SECONDS) return "offline"; + + // Stale is purely about local playback freshness — the server + // thinks the stream is fine but this client hasn't seen a segment + // in a while. Reset to live as soon as a new segment arrives. if (secondsSinceSegment >= STALE_AFTER_SECONDS) return "stale"; return "live"; } -- 2.51.2