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 && (