From 0f0d6bd83af3f8f4846b4a97775af405d180ae55 Mon Sep 17 00:00:00 2001 From: Ran Mizrachi Date: Sun, 26 Oct 2025 10:35:58 +0200 Subject: [PATCH 1/4] Add event navigation functionality to video player components --- web/src/components/player/HlsVideoPlayer.tsx | 4 ++ web/src/components/player/VideoControls.tsx | 25 ++++++++ .../player/dynamic/DynamicVideoPlayer.tsx | 3 + web/src/views/recording/RecordingView.tsx | 64 +++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index fad88815b..c19ac501c 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -49,6 +49,7 @@ type HlsVideoPlayerProps = { onTimeUpdate?: (time: number) => void; onPlaying?: () => void; onSeekToTime?: (timestamp: number, play?: boolean) => void; + onJumpToEvent?: (direction: "next" | "previous") => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; @@ -73,6 +74,7 @@ export default function HlsVideoPlayer({ onTimeUpdate, onPlaying, onSeekToTime, + onJumpToEvent, setFullResolution, onUploadFrame, toggleFullscreen, @@ -257,6 +259,7 @@ export default function HlsVideoPlayer({ playbackRate: true, plusUpload: config?.plus?.enabled == true, fullscreen: supportsFullscreen, + eventNavigation: onJumpToEvent != undefined, }} setControlsOpen={setControlsOpen} setMuted={(muted) => setMuted(muted)} @@ -296,6 +299,7 @@ export default function HlsVideoPlayer({ } } }} + onJumpToEvent={onJumpToEvent} fullscreen={fullscreen} toggleFullscreen={toggleFullscreen} containerRef={containerRef} diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index d3bb1aa04..f3f29db0d 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -16,6 +16,7 @@ import { MdVolumeOff, MdVolumeUp, } from "react-icons/md"; +import { IoMdSkipBackward, IoMdSkipForward } from "react-icons/io"; import useKeyboardListener, { KeyModifiers, } from "@/hooks/use-keyboard-listener"; @@ -41,6 +42,7 @@ type VideoControls = { playbackRate?: boolean; plusUpload?: boolean; fullscreen?: boolean; + eventNavigation?: boolean; }; const CONTROLS_DEFAULT: VideoControls = { @@ -49,6 +51,7 @@ const CONTROLS_DEFAULT: VideoControls = { playbackRate: true, plusUpload: false, fullscreen: false, + eventNavigation: false, }; const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const MIN_ITEMS_WRAP = 6; @@ -71,6 +74,7 @@ type VideoControlsProps = { onSeek: (diff: number) => void; onSetPlaybackRate: (rate: number) => void; onUploadFrame?: () => void; + onJumpToEvent?: (direction: "next" | "previous") => void; toggleFullscreen?: () => void; containerRef?: React.MutableRefObject; }; @@ -92,6 +96,7 @@ export default function VideoControls({ onSeek, onSetPlaybackRate, onUploadFrame, + onJumpToEvent, toggleFullscreen, containerRef, }: VideoControlsProps) { @@ -291,6 +296,26 @@ export default function VideoControls({ containerRef={containerRef} /> )} + {features.eventNavigation && onJumpToEvent && ( + <> + { + e.stopPropagation(); + onJumpToEvent("previous"); + }} + /> + { + e.stopPropagation(); + onJumpToEvent("next"); + }} + /> + + )} {features.fullscreen && toggleFullscreen && (
{fullscreen ? : } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 2a6f3a1cf..14fcb4c37 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -34,6 +34,7 @@ type DynamicVideoPlayerProps = { onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; onSeekToTime?: (timestamp: number, play?: boolean) => void; + onJumpToEvent?: (direction: "next" | "previous") => void; setFullResolution: React.Dispatch>; toggleFullscreen: () => void; containerRef?: React.MutableRefObject; @@ -52,6 +53,7 @@ export default function DynamicVideoPlayer({ onTimestampUpdate, onClipEnded, onSeekToTime, + onJumpToEvent, setFullResolution, toggleFullscreen, containerRef, @@ -280,6 +282,7 @@ export default function DynamicVideoPlayer({ onSeekToTime(timestamp, play); } }} + onJumpToEvent={onJumpToEvent} onPlaying={() => { if (isScrubbing) { playerRef.current?.pause(); diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 44a3d0aab..776c9f893 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -300,6 +300,69 @@ export function RecordingView({ [currentTimeRange, updateSelectedSegment], ); + // event navigation + const onJumpToEvent = useCallback( + (direction: "next" | "previous") => { + if (!mainCameraReviewItems || mainCameraReviewItems.length === 0) { + return; + } + + // Sort all events by start time to ensure correct order + const sortedEvents = [...mainCameraReviewItems].sort((a, b) => a.start_time - b.start_time); + + // Find which event we're currently viewing + // Check if current time is between (event start - REVIEW_PADDING) and (event end or start + 60s) + const currentEventIndex = sortedEvents.findIndex((item) => { + const eventStart = item.start_time - REVIEW_PADDING; + const eventEnd = item.end_time || item.start_time + 60; // Assume max 60s if no end_time + return currentTime >= eventStart && currentTime <= eventEnd; + }); + + let targetEvent; + + if (currentEventIndex >= 0) { + // We identified the current event - use index-based navigation + if (direction === "next") { + if (currentEventIndex < sortedEvents.length - 1) { + targetEvent = sortedEvents[currentEventIndex + 1]; + } else { + // At the last event, loop to the first + targetEvent = sortedEvents[0]; + } + } else { + if (currentEventIndex > 0) { + targetEvent = sortedEvents[currentEventIndex - 1]; + } else { + // At the first event, loop to the last + targetEvent = sortedEvents[sortedEvents.length - 1]; + } + } + } else { + // Can't identify current event - fall back to time-based navigation + if (direction === "next") { + // Find the first event that starts after current time + targetEvent = sortedEvents.find( + (item) => item.start_time > currentTime, + ); + } else { + // Find the last event that starts before current time + const previousEvents = sortedEvents.filter( + (item) => item.start_time < currentTime, + ); + if (previousEvents.length > 0) { + targetEvent = previousEvents[previousEvents.length - 1]; + } + } + } + + // Only navigate if we found a target event + if (targetEvent) { + manuallySetCurrentTime(targetEvent.start_time - REVIEW_PADDING, true); + } + }, + [mainCameraReviewItems, currentTime, manuallySetCurrentTime], + ); + useEffect(() => { if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { @@ -746,6 +809,7 @@ export function RecordingView({ }} onClipEnded={onClipEnded} onSeekToTime={manuallySetCurrentTime} + onJumpToEvent={onJumpToEvent} onControllerReady={(controller) => { mainControllerRef.current = controller; }} From 2e9ca27acfacae370acee218bbbdfae48e9af9ce Mon Sep 17 00:00:00 2001 From: Ran Mizrachi Date: Sun, 26 Oct 2025 10:38:02 +0200 Subject: [PATCH 2/4] lint fix --- web/src/views/recording/RecordingView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 776c9f893..3662ae135 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -308,7 +308,9 @@ export function RecordingView({ } // Sort all events by start time to ensure correct order - const sortedEvents = [...mainCameraReviewItems].sort((a, b) => a.start_time - b.start_time); + const sortedEvents = [...mainCameraReviewItems].sort( + (a, b) => a.start_time - b.start_time, + ); // Find which event we're currently viewing // Check if current time is between (event start - REVIEW_PADDING) and (event end or start + 60s) @@ -319,7 +321,7 @@ export function RecordingView({ }); let targetEvent; - + if (currentEventIndex >= 0) { // We identified the current event - use index-based navigation if (direction === "next") { From 4a660262fcabaa9bfd262500eff107140bc45876 Mon Sep 17 00:00:00 2001 From: Ran Mizrachi Date: Sun, 26 Oct 2025 10:48:51 +0200 Subject: [PATCH 3/4] Add i18n support for event navigation buttons --- web/public/locales/en/components/player.json | 4 ++++ web/src/components/player/VideoControls.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 3b50ff5ed..411d9990b 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -6,6 +6,10 @@ "title": "Submit this frame to Frigate+?", "submit": "Submit" }, + "eventNavigation": { + "previous": "Previous Event", + "next": "Next Event" + }, "livePlayerRequiredIOSVersion": "iOS 17.1 or greater is required for this live stream type.", "streamOffline": { "title": "Stream Offline", diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index f3f29db0d..4d2cf49c1 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -100,6 +100,8 @@ export default function VideoControls({ toggleFullscreen, containerRef, }: VideoControlsProps) { + const { t } = useTranslation(["components/player"]); + // layout const controlsContainerRef = useRef(null); @@ -300,7 +302,7 @@ export default function VideoControls({ <> { e.stopPropagation(); onJumpToEvent("previous"); @@ -308,7 +310,7 @@ export default function VideoControls({ /> { e.stopPropagation(); onJumpToEvent("next"); From 997882ed31c9720220e7b26faa6abc69d2fbd1ad Mon Sep 17 00:00:00 2001 From: Ran Mizrachi Date: Sun, 26 Oct 2025 10:57:24 +0200 Subject: [PATCH 4/4] Remove event navigation labels from player controls and update localization file --- web/public/locales/en/components/player.json | 4 ---- web/src/components/player/VideoControls.tsx | 2 -- 2 files changed, 6 deletions(-) diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 411d9990b..3b50ff5ed 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -6,10 +6,6 @@ "title": "Submit this frame to Frigate+?", "submit": "Submit" }, - "eventNavigation": { - "previous": "Previous Event", - "next": "Next Event" - }, "livePlayerRequiredIOSVersion": "iOS 17.1 or greater is required for this live stream type.", "streamOffline": { "title": "Stream Offline", diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index 4d2cf49c1..68c5eb014 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -302,7 +302,6 @@ export default function VideoControls({ <> { e.stopPropagation(); onJumpToEvent("previous"); @@ -310,7 +309,6 @@ export default function VideoControls({ /> { e.stopPropagation(); onJumpToEvent("next");