From 0f0d6bd83af3f8f4846b4a97775af405d180ae55 Mon Sep 17 00:00:00 2001 From: Ran Mizrachi Date: Sun, 26 Oct 2025 10:35:58 +0200 Subject: [PATCH] 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; }}