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
videoRef={videoRef}
currentSource={source}
currentSource={{
playlist: source,
}}
hotKeys
visible
frigateControls={false}

View File

@ -28,17 +28,22 @@ const unsupportedErrorCodes = [
MediaError.MEDIA_ERR_DECODE,
];
export interface HlsSource {
playlist: string;
startPosition?: number;
}
type HlsVideoPlayerProps = {
videoRef: MutableRefObject<HTMLVideoElement | null>;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
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 &&

View File

@ -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<NodeJS.Timeout>();
const [source, setSource] = useState(
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
);
const [source, setSource] = useState<HlsSource>({
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 (
<>
<HlsVideoPlayer
@ -216,7 +264,7 @@ export default function DynamicVideoPlayer({
inpointOffset={inpointOffset}
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onClipEnded}
onClipEnded={onValidateClipEnd}
onPlaying={() => {
if (isScrubbing) {
playerRef.current?.pause();