Improve HLS handling for startPosition

The startPosition was incorrectly calculated assuming continuous recordings, when it needs to consider only some segments exist. This extracts that logic to a utility so all can use it.
This commit is contained in:
Nicolas Mowen 2025-11-21 14:22:28 -07:00
parent 739de1a4e1
commit c01443e555
3 changed files with 134 additions and 84 deletions

View File

@ -2,7 +2,10 @@ import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback"; import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewPlayer"; import { PreviewController } from "../PreviewPlayer";
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline"; import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
import { calculateInpointOffset } from "@/utils/videoUtil"; import {
calculateInpointOffset,
calculateSeekPosition,
} from "@/utils/videoUtil";
type PlayerMode = "playback" | "scrubbing"; type PlayerMode = "playback" | "scrubbing";
@ -72,38 +75,20 @@ export class DynamicVideoController {
return; return;
} }
if (
this.recordings.length == 0 ||
time < this.recordings[0].start_time ||
time > this.recordings[this.recordings.length - 1].end_time
) {
this.setNoRecording(true);
return;
}
if (this.playerMode != "playback") { if (this.playerMode != "playback") {
this.playerMode = "playback"; this.playerMode = "playback";
} }
let seekSeconds = 0; const seekSeconds = calculateSeekPosition(
(this.recordings || []).every((segment) => { time,
// if the next segment is past the desired time, stop calculating this.recordings,
if (segment.start_time > time) { this.inpointOffset,
return false; );
}
if (segment.end_time < time) { if (seekSeconds === undefined) {
seekSeconds += segment.end_time - segment.start_time; this.setNoRecording(true);
return true; return;
} }
seekSeconds +=
segment.end_time - segment.start_time - (segment.end_time - time);
return true;
});
// adjust for HLS inpoint offset
seekSeconds -= this.inpointOffset;
if (seekSeconds != 0) { if (seekSeconds != 0) {
this.playerController.currentTime = seekSeconds; this.playerController.currentTime = seekSeconds;

View File

@ -14,7 +14,10 @@ import { VideoResolutionType } from "@/types/live";
import axios from "axios"; 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,
calculateSeekPosition,
} from "@/utils/videoUtil";
import { isFirefox } from "react-device-detect"; import { isFirefox } from "react-device-detect";
/** /**
@ -109,10 +112,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<HlsSource>({
playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, // Don't set source until recordings load - we need accurate startPosition
startPosition: startTimestamp ? startTimestamp - timeRange.after : 0, // to avoid hls.js clamping to video end when startPosition exceeds duration
}); const [source, setSource] = useState<HlsSource | undefined>(undefined);
// start at correct time // start at correct time
@ -184,7 +187,7 @@ export default function DynamicVideoPlayer({
); );
useEffect(() => { useEffect(() => {
if (!controller || !recordings?.length) { if (!recordings?.length) {
if (recordings?.length == 0) { if (recordings?.length == 0) {
setNoRecording(true); setNoRecording(true);
} }
@ -192,10 +195,6 @@ export default function DynamicVideoPlayer({
return; return;
} }
if (playerRef.current) {
playerRef.current.autoplay = !isScrubbing;
}
let startPosition = undefined; let startPosition = undefined;
if (startTimestamp) { if (startTimestamp) {
@ -203,14 +202,12 @@ export default function DynamicVideoPlayer({
recordingParams.after, recordingParams.after,
(recordings || [])[0], (recordings || [])[0],
); );
const idealStartPosition = Math.max(
0,
startTimestamp - timeRange.after - inpointOffset,
);
if (idealStartPosition >= recordings[0].start_time - timeRange.after) { startPosition = calculateSeekPosition(
startPosition = idealStartPosition; startTimestamp,
} recordings,
inpointOffset,
);
} }
setSource({ setSource({
@ -218,6 +215,18 @@ export default function DynamicVideoPlayer({
startPosition, startPosition,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordings]);
useEffect(() => {
if (!controller || !recordings?.length) {
return;
}
if (playerRef.current) {
playerRef.current.autoplay = !isScrubbing;
}
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
controller.newPlayback({ controller.newPlayback({
@ -225,7 +234,7 @@ export default function DynamicVideoPlayer({
timeRange, timeRange,
}); });
// we only want this to change when recordings update // we only want this to change when controller or recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, recordings]); }, [controller, recordings]);
@ -263,46 +272,48 @@ export default function DynamicVideoPlayer({
return ( return (
<> <>
<HlsVideoPlayer {source && (
videoRef={playerRef} <HlsVideoPlayer
containerRef={containerRef} videoRef={playerRef}
visible={!(isScrubbing || isLoading)} containerRef={containerRef}
currentSource={source} visible={!(isScrubbing || isLoading)}
hotKeys={hotKeys} currentSource={source}
supportsFullscreen={supportsFullscreen} hotKeys={hotKeys}
fullscreen={fullscreen} supportsFullscreen={supportsFullscreen}
inpointOffset={inpointOffset} fullscreen={fullscreen}
onTimeUpdate={onTimeUpdate} inpointOffset={inpointOffset}
onPlayerLoaded={onPlayerLoaded} onTimeUpdate={onTimeUpdate}
onClipEnded={onValidateClipEnd} onPlayerLoaded={onPlayerLoaded}
onSeekToTime={(timestamp, play) => { onClipEnded={onValidateClipEnd}
if (onSeekToTime) { onSeekToTime={(timestamp, play) => {
onSeekToTime(timestamp, play); if (onSeekToTime) {
} onSeekToTime(timestamp, play);
}} }
onPlaying={() => { }}
if (isScrubbing) { onPlaying={() => {
playerRef.current?.pause(); if (isScrubbing) {
} playerRef.current?.pause();
}
if (loadingTimeout) { if (loadingTimeout) {
clearTimeout(loadingTimeout); clearTimeout(loadingTimeout);
} }
setNoRecording(false); setNoRecording(false);
}} }}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus} onUploadFrame={onUploadFrameToPlus}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
onError={(error) => { onError={(error) => {
if (error == "stalled" && !isScrubbing) { if (error == "stalled" && !isScrubbing) {
setIsBuffering(true); setIsBuffering(true);
} }
}} }}
isDetailMode={isDetailMode} isDetailMode={isDetailMode}
camera={contextCamera || camera} camera={contextCamera || camera}
currentTimeOverride={currentTime} currentTimeOverride={currentTime}
/> />
)}
<PreviewPlayer <PreviewPlayer
className={cn( className={cn(
className, className,

View File

@ -24,3 +24,57 @@ export function calculateInpointOffset(
return 0; return 0;
} }
/**
* Calculates the video player time (in seconds) for a given timestamp
* by iterating through recording segments and summing their durations.
* This accounts for the fact that the video is a concatenation of segments,
* not a single continuous stream.
*
* @param timestamp - The target timestamp to seek to
* @param recordings - Array of recording segments
* @param inpointOffset - HLS inpoint offset to subtract from the result
* @returns The calculated seek position in seconds, or undefined if timestamp is out of range
*/
export function calculateSeekPosition(
timestamp: number,
recordings: Recording[],
inpointOffset: number = 0,
): number | undefined {
if (!recordings || recordings.length === 0) {
return undefined;
}
// Check if timestamp is within the recordings range
if (
timestamp < recordings[0].start_time ||
timestamp > recordings[recordings.length - 1].end_time
) {
return undefined;
}
let seekSeconds = 0;
(recordings || []).every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > timestamp) {
return false;
}
if (segment.end_time < timestamp) {
// Add the full duration of this segment
seekSeconds += segment.end_time - segment.start_time;
return true;
}
// We're in this segment - calculate position within it
seekSeconds +=
segment.end_time - segment.start_time - (segment.end_time - timestamp);
return true;
});
// Adjust for HLS inpoint offset
seekSeconds -= inpointOffset;
return seekSeconds >= 0 ? seekSeconds : undefined;
}