diff --git a/web/src/components/player/GenericVideoPlayer.tsx b/web/src/components/player/GenericVideoPlayer.tsx index ed4427740..d64d9a736 100644 --- a/web/src/components/player/GenericVideoPlayer.tsx +++ b/web/src/components/player/GenericVideoPlayer.tsx @@ -107,7 +107,9 @@ export function GenericVideoPlayer({ > ; containerRef?: React.MutableRefObject; visible: boolean; - currentSource: string; + currentSource: HlsSource; hotKeys: boolean; supportsFullscreen: boolean; fullscreen: boolean; frigateControls?: boolean; inpointOffset?: number; - onClipEnded?: () => void; + onClipEnded?: (currentTime: number) => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; @@ -113,17 +118,25 @@ export default function HlsVideoPlayer({ const currentPlaybackRate = videoRef.current.playbackRate; if (!useHlsCompat) { - videoRef.current.src = currentSource; + videoRef.current.src = currentSource.playlist; videoRef.current.load(); return; } - if (!hlsRef.current) { - hlsRef.current = new Hls(); - hlsRef.current.attachMedia(videoRef.current); + // we must destroy the hlsRef every time the source changes + // so that we can create a new HLS instance with startPosition + // set at the optimal point in time + if (hlsRef.current) { + hlsRef.current.destroy(); } - hlsRef.current.loadSource(currentSource); + hlsRef.current = new Hls({ + maxBufferLength: 10, + maxBufferSize: 20 * 1000 * 1000, + startPosition: currentSource.startPosition, + }); + hlsRef.current.attachMedia(videoRef.current); + hlsRef.current.loadSource(currentSource.playlist); videoRef.current.playbackRate = currentPlaybackRate; }, [videoRef, hlsRef, useHlsCompat, currentSource]); @@ -374,7 +387,11 @@ export default function HlsVideoPlayer({ } } }} - onEnded={onClipEnded} + onEnded={() => { + if (onClipEnded) { + onClipEnded(getVideoTime() ?? 0); + } + }} onError={(e) => { if ( !hlsRef.current && diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 450dd1a17..836203ca7 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -6,7 +6,7 @@ import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; -import HlsVideoPlayer from "../HlsVideoPlayer"; +import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer"; import { TimeRange } from "@/types/timeline"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { VideoResolutionType } from "@/types/live"; @@ -14,6 +14,7 @@ import axios from "axios"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { calculateInpointOffset } from "@/utils/videoUtil"; +import { isFirefox } from "react-device-detect"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -98,9 +99,10 @@ export default function DynamicVideoPlayer({ const [isLoading, setIsLoading] = useState(false); const [isBuffering, setIsBuffering] = useState(false); const [loadingTimeout, setLoadingTimeout] = useState(); - const [source, setSource] = useState( - `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, - ); + const [source, setSource] = useState({ + playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, + startPosition: startTimestamp ? timeRange.after - startTimestamp : 0, + }); // start at correct time @@ -184,9 +186,28 @@ export default function DynamicVideoPlayer({ playerRef.current.autoplay = !isScrubbing; } - setSource( - `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, - ); + let startPosition = undefined; + + if (startTimestamp) { + const inpointOffset = calculateInpointOffset( + recordingParams.after, + (recordings || [])[0], + ); + const idealStartPosition = Math.max( + 0, + startTimestamp - timeRange.after - inpointOffset, + ); + + if (idealStartPosition >= recordings[0].start_time - timeRange.after) { + startPosition = idealStartPosition; + } + } + + setSource({ + playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, + startPosition, + }); + setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); controller.newPlayback({ @@ -203,6 +224,33 @@ export default function DynamicVideoPlayer({ [recordingParams, recordings], ); + const onValidateClipEnd = useCallback( + (currentTime: number) => { + if (!onClipEnded || !controller || !recordings) { + return; + } + + if (!isFirefox) { + onClipEnded(); + } + + // Firefox has a bug where clipEnded can be called prematurely due to buffering + // we need to validate if the current play-point is truly at the end of available recordings + + const lastRecordingTime = recordings.at(-1)?.start_time; + + if ( + !lastRecordingTime || + controller.getProgress(currentTime) < lastRecordingTime + ) { + return; + } + + onClipEnded(); + }, + [onClipEnded, controller, recordings], + ); + return ( <> { if (isScrubbing) { playerRef.current?.pause();