diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 4b1bfe5ef..38896f842 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -6,7 +6,7 @@ import { useState, } from "react"; import Hls from "hls.js"; -import { isAndroid, isDesktop, isMobile } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import VideoControls from "./VideoControls"; import { VideoResolutionType } from "@/types/live"; @@ -21,7 +21,7 @@ import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; // Android native hls does not seek correctly -const USE_NATIVE_HLS = !isAndroid; +const USE_NATIVE_HLS = false; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index 0683481c6..b68191e16 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -2,7 +2,10 @@ import { Recording } from "@/types/record"; import { DynamicPlayback } from "@/types/playback"; import { PreviewController } from "../PreviewPlayer"; import { TimeRange, ObjectLifecycleSequence } from "@/types/timeline"; -import { calculateInpointOffset } from "@/utils/videoUtil"; +import { + calculateInpointOffset, + calculateSeekPosition, +} from "@/utils/videoUtil"; type PlayerMode = "playback" | "scrubbing"; @@ -68,38 +71,20 @@ export class DynamicVideoController { 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") { this.playerMode = "playback"; } - let seekSeconds = 0; - (this.recordings || []).every((segment) => { - // if the next segment is past the desired time, stop calculating - if (segment.start_time > time) { - return false; - } + const seekSeconds = calculateSeekPosition( + time, + this.recordings, + this.inpointOffset, + ); - if (segment.end_time < time) { - seekSeconds += segment.end_time - segment.start_time; - return true; - } - - seekSeconds += - segment.end_time - segment.start_time - (segment.end_time - time); - return true; - }); - - // adjust for HLS inpoint offset - seekSeconds -= this.inpointOffset; + if (seekSeconds === undefined) { + this.setNoRecording(true); + return; + } if (seekSeconds != 0) { this.playerController.currentTime = seekSeconds; diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 836203ca7..5967fc6dc 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -13,7 +13,10 @@ import { VideoResolutionType } from "@/types/live"; import axios from "axios"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; -import { calculateInpointOffset } from "@/utils/videoUtil"; +import { + calculateInpointOffset, + calculateSeekPosition, +} from "@/utils/videoUtil"; import { isFirefox } from "react-device-detect"; /** @@ -99,10 +102,10 @@ export default function DynamicVideoPlayer({ const [isLoading, setIsLoading] = useState(false); const [isBuffering, setIsBuffering] = useState(false); const [loadingTimeout, setLoadingTimeout] = useState(); - const [source, setSource] = useState({ - playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, - startPosition: startTimestamp ? timeRange.after - startTimestamp : 0, - }); + + // Don't set source until recordings load - we need accurate startPosition + // to avoid hls.js clamping to video end when startPosition exceeds duration + const [source, setSource] = useState(undefined); // start at correct time @@ -174,7 +177,7 @@ export default function DynamicVideoPlayer({ ); useEffect(() => { - if (!controller || !recordings?.length) { + if (!recordings?.length) { if (recordings?.length == 0) { setNoRecording(true); } @@ -182,10 +185,6 @@ export default function DynamicVideoPlayer({ return; } - if (playerRef.current) { - playerRef.current.autoplay = !isScrubbing; - } - let startPosition = undefined; if (startTimestamp) { @@ -193,14 +192,12 @@ export default function DynamicVideoPlayer({ recordingParams.after, (recordings || [])[0], ); - const idealStartPosition = Math.max( - 0, - startTimestamp - timeRange.after - inpointOffset, - ); - if (idealStartPosition >= recordings[0].start_time - timeRange.after) { - startPosition = idealStartPosition; - } + startPosition = calculateSeekPosition( + startTimestamp, + recordings, + inpointOffset, + ); } setSource({ @@ -208,6 +205,18 @@ export default function DynamicVideoPlayer({ 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)); controller.newPlayback({ @@ -215,7 +224,7 @@ export default function DynamicVideoPlayer({ 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 }, [controller, recordings]); @@ -253,38 +262,40 @@ export default function DynamicVideoPlayer({ return ( <> - { - if (isScrubbing) { - playerRef.current?.pause(); - } + {source && ( + { + if (isScrubbing) { + playerRef.current?.pause(); + } - if (loadingTimeout) { - clearTimeout(loadingTimeout); - } + if (loadingTimeout) { + clearTimeout(loadingTimeout); + } - setNoRecording(false); - }} - setFullResolution={setFullResolution} - onUploadFrame={onUploadFrameToPlus} - toggleFullscreen={toggleFullscreen} - onError={(error) => { - if (error == "stalled" && !isScrubbing) { - setIsBuffering(true); - } - }} - /> + setNoRecording(false); + }} + setFullResolution={setFullResolution} + onUploadFrame={onUploadFrameToPlus} + toggleFullscreen={toggleFullscreen} + onError={(error) => { + if (error == "stalled" && !isScrubbing) { + setIsBuffering(true); + } + }} + /> + )} 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; +}