From d93adff1b11a5b25c810cf5e137bb64385f99492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Wed, 26 Aug 2026 23:07:43 +0200 Subject: [PATCH] VI-5068 Offer Open for a live source, not only a closed one The Open button was gated on durationMs > 0, standing in for "the flow has at least one segment" (530b49a). That proxy only holds for a closed flow: a growing flow reports an open-ended timerange, so both bounds are never known and durationMs stays null however many segments have landed. A live source therefore showed the LIVE badge but had nothing to click -- even though handleOpen already sends status "growing" for exactly that case. Gate on the segments themselves instead. timerangeHasMedia reads the bounds from the timerange string, as isInitSegmentEntry does, because parseTimerange collapses empty, inverted and unparsable onto the same 0n..0n result. An open-ended range counts as media; an empty, zero-length or unparsable one does not, so a flow with no segments yet stays unopenable as intended. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/timerange.js | 28 ++++++++++++++++++++++++++++ src/views/Embed/index.jsx | 15 +++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/utils/timerange.js b/src/utils/timerange.js index 48dfd07..9e374c1 100644 --- a/src/utils/timerange.js +++ b/src/utils/timerange.js @@ -226,3 +226,31 @@ export const parseTimerangeDateTime = (timerange) => { }; } }; + +/** + * Whether a timerange covers any media at all. + * + * A growing flow reports an open-ended range (`[start_`), so it has segments + * but no computable duration -- duration is therefore not a usable proxy for + * "has media". The bounds are read from the string rather than from + * parseTimerange, which collapses "empty", "inverted" and "did not parse" onto + * the same 0n..0n result. + */ +export const timerangeHasMedia = (timerange) => { + if (typeof timerange !== "string" || !timerange) return false; + if (EMPTY_PATTERNS.has(timerange)) return false; + + const match = TIMERANGE_REGEX.exec(timerange); + if (!match) return false; + + const { startSeconds, startNanos, endSeconds, endNanos } = match.groups; + const hasStart = startSeconds != null || startNanos != null; + const hasEnd = endSeconds != null || endNanos != null; + if (!hasStart && !hasEnd) return false; + + // A bare timestamp with no range separator is a single point, so zero-length. + if (!timerange.includes("_")) return false; + if (!hasStart || !hasEnd) return true; + + return !(startSeconds === endSeconds && startNanos === endNanos); +}; diff --git a/src/views/Embed/index.jsx b/src/views/Embed/index.jsx index 6d95fc6..8aa0284 100644 --- a/src/views/Embed/index.jsx +++ b/src/views/Embed/index.jsx @@ -5,7 +5,7 @@ import { useIngestingFlows } from "@/hooks/useFlows"; import { useCapabilities } from "@/hooks/useService"; import useStoreManager from "@/stores/useStoreManager"; import StoreManager from "@/views/StoreManager"; -import { parseTimerange } from "@/utils/timerange"; +import { parseTimerange, timerangeHasMedia } from "@/utils/timerange"; import { isFlowGrowing } from "@/utils/flowStatus"; import { findVideoMember } from "@/utils/editorialPurpose"; import { TAMS_PAGE_LIMIT, VIEW_MODE } from "@/constants"; @@ -113,8 +113,8 @@ const resolveVideoSourceId = async (source, api, canQueryCollections) => { }; /** - * For each displayed source, fetch its video flow to get fps, duration and - * growing status. Growing status comes from the flow's `status` (8.2) or the + * For each displayed source, fetch its video flow to get fps, duration, + * growing status, and whether any segments have landed yet. Growing status comes from the flow's `status` (8.2) or the * deprecated flow_status tag, guarded by recent segment activity. * * On initial load, all sources are fetched once. Sources that are growing are @@ -166,6 +166,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { let durationMs = null; const detailRes = await api.get(`/flows/${flow.id}?include_timerange=true`); const tr = detailRes.data?.timerange; + const hasSegments = timerangeHasMedia(tr); if (tr) { const parsed = parseTimerange(tr); if (parsed.start !== null && parsed.end !== null) { @@ -183,7 +184,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { if (!cancelled) { setFlowDetails((prev) => { const next = new Map(prev); - next.set(source.id, { fps, isGrowing, durationMs }); + next.set(source.id, { fps, isGrowing, durationMs, hasSegments }); return next; }); } @@ -216,6 +217,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { let durationMs = null; const tr = flow.timerange; + const hasSegments = timerangeHasMedia(tr); if (tr) { const parsed = parseTimerange(tr); if (parsed.start !== null && parsed.end !== null) { @@ -231,7 +233,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { setFlowDetails((prev) => { const next = new Map(prev); const existing = next.get(sourceId); - next.set(sourceId, { ...existing, isGrowing, durationMs }); + next.set(sourceId, { ...existing, isGrowing, durationMs, hasSegments }); return next; }); } catch { /* skip */ } @@ -315,6 +317,7 @@ const Embed = () => { ...source, isGrowing: details?.isGrowing ?? false, durationMs: details?.durationMs ?? null, + hasSegments: details?.hasSegments ?? false, fps: details?.fps ?? null, detailsLoaded: !!details, }; @@ -426,7 +429,7 @@ const Embed = () => { ) : source.durationMs > 0 ? ( {formatDuration(source.durationMs)} ) : null} - {source.durationMs > 0 && ( + {source.hasSegments && (