diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 4443f4d0a..fea55c860 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -8,7 +8,7 @@ "masksAndZones": "Mask and Zone Editor - Frigate", "motionTuner": "Motion Tuner - Frigate", "object": "Debug - Frigate", - "general": "General Settings - Frigate", + "general": "UI Settings - Frigate", "frigatePlus": "Frigate+ Settings - Frigate", "notifications": "Notification Settings - Frigate" }, @@ -37,7 +37,7 @@ "noCamera": "No Camera" }, "general": { - "title": "General Settings", + "title": "UI Settings", "liveDashboard": { "title": "Live Dashboard", "automaticLiveView": { @@ -51,6 +51,10 @@ "displayCameraNames": { "label": "Always Show Camera Names", "desc": "Always show the camera names in a chip in the multi-camera live view dashboard." + }, + "liveFallbackTimeout": { + "label": "Live Player Fallback Timeout", + "desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3." } }, "storedLayouts": { diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 279b85e4c..33ddd0f00 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -343,6 +343,10 @@ export function TrackingDetails({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [displayedRecordTime]); + const onUploadFrameToPlus = useCallback(() => { + return axios.post(`/${event.camera}/plus/${currentTime}`); + }, [event.camera, currentTime]); + if (!config) { return ; } @@ -388,6 +392,7 @@ export function TrackingDetails({ frigateControls={true} onTimeUpdate={handleTimeUpdate} onSeekToTime={handleSeekToTime} + onUploadFrame={onUploadFrameToPlus} isDetailMode={true} camera={event.camera} currentTimeOverride={currentTime} diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index c4a30c7e6..4d2b8b365 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -1,4 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; +import { usePersistence } from "@/hooks/use-persistence"; import { LivePlayerError, PlayerStatsType, @@ -71,6 +72,8 @@ function MSEPlayer({ const [errorCount, setErrorCount] = useState(0); const totalBytesLoaded = useRef(0); + const [fallbackTimeout] = usePersistence("liveFallbackTimeout", 3); + const videoRef = useRef(null); const wsRef = useRef(null); const reconnectTIDRef = useRef(null); @@ -475,7 +478,10 @@ function MSEPlayer({ setBufferTimeout(undefined); } - const timeoutDuration = bufferTime == 0 ? 5000 : 3000; + const timeoutDuration = + bufferTime == 0 + ? (fallbackTimeout ?? 3) * 2 * 1000 + : (fallbackTimeout ?? 3) * 1000; setBufferTimeout( setTimeout(() => { if ( @@ -500,6 +506,7 @@ function MSEPlayer({ onError, onPlaying, playbackEnabled, + fallbackTimeout, ]); useEffect(() => { diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index 17dfaee91..2bdb1f866 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live"; export default function useCameraLiveMode( cameras: CameraConfig[], windowVisible: boolean, + activeStreams?: { [cameraName: string]: string }, ) { const { data: config } = useSWR("config"); @@ -20,16 +21,20 @@ export default function useCameraLiveMode( ); if (isRestreamed) { - Object.values(camera.live.streams).forEach((streamName) => { - streamNames.add(streamName); - }); + if (activeStreams && activeStreams[camera.name]) { + streamNames.add(activeStreams[camera.name]); + } else { + Object.values(camera.live.streams).forEach((streamName) => { + streamNames.add(streamName); + }); + } } }); return streamNames.size > 0 ? Array.from(streamNames).sort().join(",") : null; - }, [cameras, config]); + }, [cameras, config, activeStreams]); const streamsFetcher = useCallback(async (key: string) => { const streamNames = key.split(","); @@ -68,7 +73,9 @@ export default function useCameraLiveMode( [key: string]: LiveStreamMetadata; }>(restreamedStreamsKey, streamsFetcher, { revalidateOnFocus: false, - dedupingInterval: 10000, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 60000, }); const [preferredLiveModes, setPreferredLiveModes] = useState<{ diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 5239fe388..259e4093f 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -86,14 +86,6 @@ export default function DraggableGridLayout({ // preferred live modes per camera - const { - preferredLiveModes, - setPreferredLiveModes, - resetPreferredLiveMode, - isRestreamedStates, - supportsAudioOutputStates, - } = useCameraLiveMode(cameras, windowVisible); - const [globalAutoLive] = usePersistence("autoLiveView", true); const [displayCameraNames] = usePersistence("displayCameraNames", false); @@ -106,6 +98,33 @@ export default function DraggableGridLayout({ } }, [allGroupsStreamingSettings, cameraGroup]); + const activeStreams = useMemo(() => { + const streams: { [cameraName: string]: string } = {}; + cameras.forEach((camera) => { + const availableStreams = camera.live.streams || {}; + const streamNameFromSettings = + currentGroupStreamingSettings?.[camera.name]?.streamName || ""; + const streamExists = + streamNameFromSettings && + Object.values(availableStreams).includes(streamNameFromSettings); + + const streamName = streamExists + ? streamNameFromSettings + : Object.values(availableStreams)[0] || ""; + + streams[camera.name] = streamName; + }); + return streams; + }, [cameras, currentGroupStreamingSettings]); + + const { + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + } = useCameraLiveMode(cameras, windowVisible, activeStreams); + // grid layout const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 2d61d40f5..f667afd29 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -162,6 +162,9 @@ export default function LiveCameraView({ isRestreamed ? `go2rtc/streams/${streamName}` : null, { revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 60000, }, ); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 8b7dd1336..c4104576c 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -202,14 +202,6 @@ export default function LiveDashboardView({ }; }, []); - const { - preferredLiveModes, - setPreferredLiveModes, - resetPreferredLiveMode, - isRestreamedStates, - supportsAudioOutputStates, - } = useCameraLiveMode(cameras, windowVisible); - const [globalAutoLive] = usePersistence("autoLiveView", true); const [displayCameraNames] = usePersistence("displayCameraNames", false); @@ -239,6 +231,33 @@ export default function LiveDashboardView({ [visibleCameraObserver.current], ); + const activeStreams = useMemo(() => { + const streams: { [cameraName: string]: string } = {}; + cameras.forEach((camera) => { + const availableStreams = camera.live.streams || {}; + const streamNameFromSettings = + currentGroupStreamingSettings?.[camera.name]?.streamName || ""; + const streamExists = + streamNameFromSettings && + Object.values(availableStreams).includes(streamNameFromSettings); + + const streamName = streamExists + ? streamNameFromSettings + : Object.values(availableStreams)[0] || ""; + + streams[camera.name] = streamName; + }); + return streams; + }, [cameras, currentGroupStreamingSettings]); + + const { + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + } = useCameraLiveMode(cameras, windowVisible, activeStreams); + const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const handleError = useCallback( diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 092e3713d..d09ab7c75 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -99,6 +99,10 @@ export default function UiSettingsView() { const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1); const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0); const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true); + const [fallbackTimeout, setFallbackTimeout] = usePersistence( + "liveFallbackTimeout", + 3, + ); return ( <> @@ -161,6 +165,48 @@ export default function UiSettingsView() { {t("general.liveDashboard.displayCameraNames.desc")} + + + + {t("general.liveDashboard.liveFallbackTimeout.label")} + + + + {t("general.liveDashboard.liveFallbackTimeout.desc")} + + setFallbackTimeout(parseInt(value))} + > + + {t("time.second", { + ns: "common", + time: fallbackTimeout, + count: fallbackTimeout, + })} + + + + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => ( + + {t("time.second", { + ns: "common", + time: timeout, + count: timeout, + })} + + ))} + + + +
{t("general.liveDashboard.displayCameraNames.desc")}
{t("general.liveDashboard.liveFallbackTimeout.desc")}