From 0e3dca5915ca22d0d9b7b2e8effbc0c96ad8e5a3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:51:16 -0600 Subject: [PATCH] Live view improvements (#24967) * hold the last frame on screen while switching live streams Switching streams in the single camera view blanked the player until the new stream played, because the old player unmounts in the same render and the hidden new player gave the card no height. The outgoing video's last frame is now drawn to a canvas before the remount and covers the gap until the new stream plays, and the card takes the camera's aspect ratio so it keeps its size while the video is hidden. * show live streams as a one-tap grid in the mobile drawer Switching streams on mobile meant scrolling past the camera toggles, opening a dropdown, and picking a stream. The drawer now opens with the stream section, and streams are a wrapping grid of toggles sized to their names, so a switch is two taps. A single stream shows as plain text. The audio and two-way talk lines show a spinner while the stream's go2rtc metadata loads instead of reporting audio as unavailable, which showed on the first switch to a transcoded stream. * don't capture the switch frame on tiles that show the camera still --- web/public/locales/en/views/live.json | 3 + web/src/components/player/LivePlayer.tsx | 34 ++++ .../components/player/LiveStreamSelect.tsx | 112 +++++++---- web/src/views/live/LiveCameraView.tsx | 185 ++++++++++-------- 4 files changed, 215 insertions(+), 119 deletions(-) diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index c4f3f91b0b..3ea4689fe5 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -173,6 +173,9 @@ "label": "Play in background", "tips": "Enable this option to continue streaming when the player is hidden." }, + "metadata": { + "loading": "Fetching stream metadata…" + }, "mode": "Streaming Technology", "technology": { "description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.", diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index ff73e047fa..6f9c94583d 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -248,10 +248,21 @@ export default function LivePlayer({ // remounts them in the same render and the new player is hidden until it // plays. jsmpeg plays the camera, not the stream, and keeps playing const [renderedStream, setRenderedStream] = useState(streamName); + const [switchFrame, setSwitchFrame] = useState(); if (renderedStream !== streamName) { setRenderedStream(streamName); if (preferredLiveMode !== "jsmpeg") { setLiveReady(false); + + // the old video is still in the DOM until this render commits, so its + // last frame can cover the gap until the new stream plays. Tiles that + // show the camera still already cover it + if (!showStillWithoutActivity) { + const frame = captureVideoFrame(overlayRef.current); + if (frame) { + setSwitchFrame(frame); + } + } } } @@ -263,6 +274,7 @@ export default function LivePlayer({ const playerIsPlaying = useCallback(() => { setLiveReady(true); + setSwitchFrame(undefined); }, []); // enabled states @@ -416,6 +428,12 @@ export default function LivePlayer({ )} {player} + {switchFrame && !liveReady && ( +
node?.replaceChildren(switchFrame)} + /> + )} {cameraEnabled && !offline && (!showStillWithoutActivity || isReEnabling) && @@ -582,3 +600,19 @@ export default function LivePlayer({
); } + +function captureVideoFrame( + container: HTMLElement | null, +): HTMLCanvasElement | undefined { + const video = container?.querySelector("video"); + if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return undefined; + } + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.className = "size-full object-contain"; + canvas.getContext("2d")?.drawImage(video, 0, 0); + return canvas; +} diff --git a/web/src/components/player/LiveStreamSelect.tsx b/web/src/components/player/LiveStreamSelect.tsx index fee9351c00..97d5cc56cf 100644 --- a/web/src/components/player/LiveStreamSelect.tsx +++ b/web/src/components/player/LiveStreamSelect.tsx @@ -12,11 +12,15 @@ import { useTranslation } from "react-i18next"; import { LuInfo } from "react-icons/lu"; import { MdOutlineRestartAlt } from "react-icons/md"; import { Button } from "@/components/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; // Radix Select needs a non-empty value. Auto itself is stored as the // absence of a pinned stream const AUTO_VALUE = "__frigate_auto__"; +const GRID_ITEM = + "h-auto min-h-10 min-w-[calc((100%-1rem)/3)] grow whitespace-normal bg-secondary [overflow-wrap:anywhere] px-2 py-2 text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:hover:bg-selected data-[state=on]:hover:text-selected-foreground"; + type LiveStreamSelectProps = { // friendly name to go2rtc stream name, in ladder order streams: Record; @@ -29,6 +33,7 @@ type LiveStreamSelectProps = { // sends auto back to the top of the ladder onRetry: () => void; disabled: boolean; + variant?: "select" | "grid"; }; export default function LiveStreamSelect({ @@ -40,6 +45,7 @@ export default function LiveStreamSelect({ onSelect, onRetry, disabled, + variant = "select", }: LiveStreamSelectProps) { const { t } = useTranslation(["views/live"]); @@ -52,48 +58,78 @@ export default function LiveStreamSelect({ const auto = autoAvailable && pinnedStream == undefined; const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream); + const autoLabel = auto + ? t("stream.auto.selected", { + stream: + autoReason === "floor" + ? t("stream.auto.floorLabel") + : labelFor(playingStream), + }) + : t("stream.auto.label"); + return ( <> - + onSelect(next === AUTO_VALUE ? undefined : next) + } + > + + {auto ? autoLabel : labelFor(value)} + - - - {autoAvailable && ( - - - {t("stream.auto.label")} - - {t("stream.auto.tips")} + + + {autoAvailable && ( + + + {t("stream.auto.label")} + + {t("stream.auto.tips")} + - - - )} - {Object.entries(streams).map(([label, name]) => ( - - {label} - - ))} - - - + + )} + {Object.entries(streams).map(([label, name]) => ( + + {label} + + ))} + + + + )} {auto && autoReason && !disabled && (
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 8252a6692e..71c8e7a7ee 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -230,15 +230,16 @@ export default function LiveCameraView({ } }, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]); - const { data: cameraMetadata } = useSWR( - isRestreamed ? `go2rtc/streams/${streamName}` : null, - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - dedupingInterval: 60000, - }, - ); + const { data: cameraMetadata, isLoading: cameraMetadataLoading } = + useSWR( + isRestreamed ? `go2rtc/streams/${streamName}` : null, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 60000, + }, + ); const webRTCAvailability = useWebRTCAvailableForStream( cameraMetadata, @@ -847,6 +848,7 @@ export default function LiveCameraView({ onResetStream={resetStream} supportsAudioOutput={supportsAudioOutput} supports2WayTalk={supports2WayTalk} + streamMetadataLoading={cameraMetadataLoading} cameraEnabled={cameraEnabled} debug={debug} setDebug={setDebug} @@ -873,9 +875,12 @@ export default function LiveCameraView({ growClassName, )} ref={clickOverlayRef} - style={{ - aspectRatio: constrainedAspectRatio, - }} + style={ + { + aspectRatio: constrainedAspectRatio, + "--camera-aspect": cameraAspectRatio, + } as React.CSSProperties + } > {clickOverlay && overlaySize.width > 0 && (
void; supportsAudioOutput: boolean; supports2WayTalk: boolean; + streamMetadataLoading: boolean; cameraEnabled: boolean; debug: boolean; setDebug: (debug: boolean) => void; @@ -1038,6 +1047,7 @@ function FrigateCameraFeatures({ onResetStream, supportsAudioOutput, supports2WayTalk, + streamMetadataLoading, cameraEnabled, debug, setDebug, @@ -1434,7 +1444,12 @@ function FrigateCameraFeatures({ !debug && isRestreamed && (
- {supportsAudioOutput ? ( + {streamMetadataLoading ? ( + <> + +
{t("stream.metadata.loading")}
+ + ) : supportsAudioOutput ? ( <>
{t("stream.audio.available")}
@@ -1693,72 +1708,7 @@ function FrigateCameraFeatures({
<> - {isAdmin && ( - <> - - sendEnabled(enabledState == "ON" ? "OFF" : "ON") - } - /> - - sendDetect(detectState == "ON" ? "OFF" : "ON") - } - /> - {recordingEnabled && ( - - sendRecord(recordState == "ON" ? "OFF" : "ON") - } - /> - )} - - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") - } - /> - {audioDetectEnabled && ( - - sendAudio(audioState == "ON" ? "OFF" : "ON") - } - /> - )} - {audioDetectEnabled && transcriptionEnabled && ( - - sendTranscription( - transcriptionState == "ON" ? "OFF" : "ON", - ) - } - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - /> - )} - - )} - -
+
{!isRestreamed && (
@@ -1811,6 +1761,7 @@ function FrigateCameraFeatures({ onSelect={onSelectStream} onRetry={onResetStream} disabled={debug || forceLowBandwidth} + variant="grid" /> {debug && ( @@ -1826,7 +1777,12 @@ function FrigateCameraFeatures({ !debug && isRestreamed && (
- {supportsAudioOutput ? ( + {streamMetadataLoading ? ( + <> + +
{t("stream.metadata.loading")}
+ + ) : supportsAudioOutput ? ( <>
{t("stream.audio.available")}
@@ -1955,6 +1911,73 @@ function FrigateCameraFeatures({

)} +
+ {isAdmin && ( + <> + + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> + + sendDetect(detectState == "ON" ? "OFF" : "ON") + } + /> + {recordingEnabled && ( + + sendRecord(recordState == "ON" ? "OFF" : "ON") + } + /> + )} + + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") + } + /> + {audioDetectEnabled && ( + + sendAudio(audioState == "ON" ? "OFF" : "ON") + } + /> + )} + {audioDetectEnabled && transcriptionEnabled && ( + + sendTranscription( + transcriptionState == "ON" ? "OFF" : "ON", + ) + } + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + /> + )} + + )} + +
{isRestreamed && Object.values(camera.live.streams).length > 0 && (