HLS Playback Startup Time Optimization (#19503)

* Include preferred startTime in source so that the playlist does not need to seek

* Compatibility

* Cleanup

* Adjust based on inpoint

* Don't set start position if it is not valid

* Handle firefox buggy behavior
This commit is contained in:
Nicolas Mowen 2025-08-16 07:09:15 -06:00 committed by GitHub
parent 89db960c05
commit fb290c411b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 84 additions and 17 deletions

View File

@ -107,7 +107,9 @@ export function GenericVideoPlayer({
> >
<HlsVideoPlayer <HlsVideoPlayer
videoRef={videoRef} videoRef={videoRef}
currentSource={source} currentSource={{
playlist: source,
}}
hotKeys hotKeys
visible visible
frigateControls={false} frigateControls={false}

View File

@ -28,17 +28,22 @@ const unsupportedErrorCodes = [
MediaError.MEDIA_ERR_DECODE, MediaError.MEDIA_ERR_DECODE,
]; ];
export interface HlsSource {
playlist: string;
startPosition?: number;
}
type HlsVideoPlayerProps = { type HlsVideoPlayerProps = {
videoRef: MutableRefObject<HTMLVideoElement | null>; videoRef: MutableRefObject<HTMLVideoElement | null>;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
visible: boolean; visible: boolean;
currentSource: string; currentSource: HlsSource;
hotKeys: boolean; hotKeys: boolean;
supportsFullscreen: boolean; supportsFullscreen: boolean;
fullscreen: boolean; fullscreen: boolean;
frigateControls?: boolean; frigateControls?: boolean;
inpointOffset?: number; inpointOffset?: number;
onClipEnded?: () => void; onClipEnded?: (currentTime: number) => void;
onPlayerLoaded?: () => void; onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
onPlaying?: () => void; onPlaying?: () => void;
@ -113,17 +118,25 @@ export default function HlsVideoPlayer({
const currentPlaybackRate = videoRef.current.playbackRate; const currentPlaybackRate = videoRef.current.playbackRate;
if (!useHlsCompat) { if (!useHlsCompat) {
videoRef.current.src = currentSource; videoRef.current.src = currentSource.playlist;
videoRef.current.load(); videoRef.current.load();
return; return;
} }
if (!hlsRef.current) { // we must destroy the hlsRef every time the source changes
hlsRef.current = new Hls(); // so that we can create a new HLS instance with startPosition
hlsRef.current.attachMedia(videoRef.current); // 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.current.playbackRate = currentPlaybackRate;
}, [videoRef, hlsRef, useHlsCompat, currentSource]); }, [videoRef, hlsRef, useHlsCompat, currentSource]);
@ -374,7 +387,11 @@ export default function HlsVideoPlayer({
} }
} }
}} }}
onEnded={onClipEnded} onEnded={() => {
if (onClipEnded) {
onClipEnded(getVideoTime() ?? 0);
}
}}
onError={(e) => { onError={(e) => {
if ( if (
!hlsRef.current && !hlsRef.current &&

View File

@ -6,7 +6,7 @@ import { Recording } from "@/types/record";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController"; import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer"; import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
@ -14,6 +14,7 @@ import axios from "axios";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { calculateInpointOffset } from "@/utils/videoUtil"; import { calculateInpointOffset } from "@/utils/videoUtil";
import { isFirefox } from "react-device-detect";
/** /**
* Dynamically switches between video playback and scrubbing preview player. * Dynamically switches between video playback and scrubbing preview player.
@ -98,9 +99,10 @@ export default function DynamicVideoPlayer({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isBuffering, setIsBuffering] = useState(false); const [isBuffering, setIsBuffering] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>(); const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
const [source, setSource] = useState( const [source, setSource] = useState<HlsSource>({
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
); startPosition: startTimestamp ? timeRange.after - startTimestamp : 0,
});
// start at correct time // start at correct time
@ -184,9 +186,28 @@ export default function DynamicVideoPlayer({
playerRef.current.autoplay = !isScrubbing; playerRef.current.autoplay = !isScrubbing;
} }
setSource( let startPosition = undefined;
`${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
); 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)); setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
controller.newPlayback({ controller.newPlayback({
@ -203,6 +224,33 @@ export default function DynamicVideoPlayer({
[recordingParams, recordings], [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 ( return (
<> <>
<HlsVideoPlayer <HlsVideoPlayer
@ -216,7 +264,7 @@ export default function DynamicVideoPlayer({
inpointOffset={inpointOffset} inpointOffset={inpointOffset}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded} onPlayerLoaded={onPlayerLoaded}
onClipEnded={onClipEnded} onClipEnded={onValidateClipEnd}
onPlaying={() => { onPlaying={() => {
if (isScrubbing) { if (isScrubbing) {
playerRef.current?.pause(); playerRef.current?.pause();