Add event navigation functionality to video player components

This commit is contained in:
Ran Mizrachi 2025-10-26 10:35:58 +02:00
parent 2c480b9a89
commit 0f0d6bd83a
4 changed files with 96 additions and 0 deletions

View File

@ -49,6 +49,7 @@ type HlsVideoPlayerProps = {
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
onPlaying?: () => void; onPlaying?: () => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void; onSeekToTime?: (timestamp: number, play?: boolean) => void;
onJumpToEvent?: (direction: "next" | "previous") => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
toggleFullscreen?: () => void; toggleFullscreen?: () => void;
@ -73,6 +74,7 @@ export default function HlsVideoPlayer({
onTimeUpdate, onTimeUpdate,
onPlaying, onPlaying,
onSeekToTime, onSeekToTime,
onJumpToEvent,
setFullResolution, setFullResolution,
onUploadFrame, onUploadFrame,
toggleFullscreen, toggleFullscreen,
@ -257,6 +259,7 @@ export default function HlsVideoPlayer({
playbackRate: true, playbackRate: true,
plusUpload: config?.plus?.enabled == true, plusUpload: config?.plus?.enabled == true,
fullscreen: supportsFullscreen, fullscreen: supportsFullscreen,
eventNavigation: onJumpToEvent != undefined,
}} }}
setControlsOpen={setControlsOpen} setControlsOpen={setControlsOpen}
setMuted={(muted) => setMuted(muted)} setMuted={(muted) => setMuted(muted)}
@ -296,6 +299,7 @@ export default function HlsVideoPlayer({
} }
} }
}} }}
onJumpToEvent={onJumpToEvent}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
containerRef={containerRef} containerRef={containerRef}

View File

@ -16,6 +16,7 @@ import {
MdVolumeOff, MdVolumeOff,
MdVolumeUp, MdVolumeUp,
} from "react-icons/md"; } from "react-icons/md";
import { IoMdSkipBackward, IoMdSkipForward } from "react-icons/io";
import useKeyboardListener, { import useKeyboardListener, {
KeyModifiers, KeyModifiers,
} from "@/hooks/use-keyboard-listener"; } from "@/hooks/use-keyboard-listener";
@ -41,6 +42,7 @@ type VideoControls = {
playbackRate?: boolean; playbackRate?: boolean;
plusUpload?: boolean; plusUpload?: boolean;
fullscreen?: boolean; fullscreen?: boolean;
eventNavigation?: boolean;
}; };
const CONTROLS_DEFAULT: VideoControls = { const CONTROLS_DEFAULT: VideoControls = {
@ -49,6 +51,7 @@ const CONTROLS_DEFAULT: VideoControls = {
playbackRate: true, playbackRate: true,
plusUpload: false, plusUpload: false,
fullscreen: false, fullscreen: false,
eventNavigation: false,
}; };
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const MIN_ITEMS_WRAP = 6; const MIN_ITEMS_WRAP = 6;
@ -71,6 +74,7 @@ type VideoControlsProps = {
onSeek: (diff: number) => void; onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void; onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void; onUploadFrame?: () => void;
onJumpToEvent?: (direction: "next" | "previous") => void;
toggleFullscreen?: () => void; toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
}; };
@ -92,6 +96,7 @@ export default function VideoControls({
onSeek, onSeek,
onSetPlaybackRate, onSetPlaybackRate,
onUploadFrame, onUploadFrame,
onJumpToEvent,
toggleFullscreen, toggleFullscreen,
containerRef, containerRef,
}: VideoControlsProps) { }: VideoControlsProps) {
@ -291,6 +296,26 @@ export default function VideoControls({
containerRef={containerRef} containerRef={containerRef}
/> />
)} )}
{features.eventNavigation && onJumpToEvent && (
<>
<IoMdSkipBackward
className="size-5 cursor-pointer"
title="Previous Event"
onClick={(e) => {
e.stopPropagation();
onJumpToEvent("previous");
}}
/>
<IoMdSkipForward
className="size-5 cursor-pointer"
title="Next Event"
onClick={(e) => {
e.stopPropagation();
onJumpToEvent("next");
}}
/>
</>
)}
{features.fullscreen && toggleFullscreen && ( {features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}> <div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />} {fullscreen ? <FaCompress /> : <FaExpand />}

View File

@ -34,6 +34,7 @@ type DynamicVideoPlayerProps = {
onTimestampUpdate?: (timestamp: number) => void; onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void; onClipEnded?: () => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void; onSeekToTime?: (timestamp: number, play?: boolean) => void;
onJumpToEvent?: (direction: "next" | "previous") => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
toggleFullscreen: () => void; toggleFullscreen: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
@ -52,6 +53,7 @@ export default function DynamicVideoPlayer({
onTimestampUpdate, onTimestampUpdate,
onClipEnded, onClipEnded,
onSeekToTime, onSeekToTime,
onJumpToEvent,
setFullResolution, setFullResolution,
toggleFullscreen, toggleFullscreen,
containerRef, containerRef,
@ -280,6 +282,7 @@ export default function DynamicVideoPlayer({
onSeekToTime(timestamp, play); onSeekToTime(timestamp, play);
} }
}} }}
onJumpToEvent={onJumpToEvent}
onPlaying={() => { onPlaying={() => {
if (isScrubbing) { if (isScrubbing) {
playerRef.current?.pause(); playerRef.current?.pause();

View File

@ -300,6 +300,69 @@ export function RecordingView({
[currentTimeRange, updateSelectedSegment], [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(() => { useEffect(() => {
if (!scrubbing) { if (!scrubbing) {
if (Math.abs(currentTime - playerTime) > 10) { if (Math.abs(currentTime - playerTime) > 10) {
@ -746,6 +809,7 @@ export function RecordingView({
}} }}
onClipEnded={onClipEnded} onClipEnded={onClipEnded}
onSeekToTime={manuallySetCurrentTime} onSeekToTime={manuallySetCurrentTime}
onJumpToEvent={onJumpToEvent}
onControllerReady={(controller) => { onControllerReady={(controller) => {
mainControllerRef.current = controller; mainControllerRef.current = controller;
}} }}