diff --git a/web/package-lock.json b/web/package-lock.json index c6611c0d5..8b54122c4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -34,6 +34,7 @@ "clsx": "^2.1.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^2.30.0", + "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.3", "lucide-react": "^0.294.0", @@ -4972,6 +4973,11 @@ "integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==", "dev": true }, + "node_modules/hls.js": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", + "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx new file mode 100644 index 000000000..a3e04be67 --- /dev/null +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -0,0 +1,285 @@ +import { + MutableRefObject, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import Hls from "hls.js"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import { LuPause, LuPlay } from "react-icons/lu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { MdForward10, MdReplay10 } from "react-icons/md"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; + +const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; + +type HlsVideovideoProps = { + className: string; + children?: ReactNode; + videoRef: MutableRefObject; + currentSource: string; + onClipEnded?: () => void; + onPlayerLoaded?: () => void; + onTimeUpdate?: (time: number) => void; +}; +export default function HlsVideovideo({ + className, + children, + videoRef, + currentSource, + onClipEnded, + onPlayerLoaded, + onTimeUpdate, +}: HlsVideovideoProps) { + // playback + + const hlsRef = useRef(); + + useEffect(() => { + if (!videoRef.current) { + return; + } + + if (videoRef.current.canPlayType(HLS_MIME_TYPE)) { + return; + } else if (Hls.isSupported()) { + hlsRef.current = new Hls(); + hlsRef.current.attachMedia(videoRef.current); + } + }, [videoRef]); + + useEffect(() => { + if (!videoRef.current) { + return; + } + + if (!hlsRef.current) { + videoRef.current.src = currentSource; + videoRef.current.load(); + return; + } + + hlsRef.current.loadSource(currentSource); + }, [videoRef, hlsRef, currentSource]); + + // controls + + const [isPlaying, setIsPlaying] = useState(true); + const [controls, setControls] = useState(isMobile); + const [controlsOpen, setControlsOpen] = useState(false); + + const onKeyboardShortcut = useCallback( + (key: string, down: boolean, repeat: boolean) => { + if (!videoRef.current) { + return; + } + + switch (key) { + case "ArrowLeft": + if (down) { + const currentTime = videoRef.current.currentTime; + + if (currentTime) { + videoRef.current.currentTime = Math.max(0, currentTime - 5); + } + } + break; + case "ArrowRight": + if (down) { + const currentTime = videoRef.current.currentTime; + + if (currentTime) { + videoRef.current.currentTime = currentTime + 5; + } + } + break; + case "m": + if (down && !repeat && videoRef.current) { + videoRef.current.muted = !videoRef.current.muted; + } + break; + case " ": + if (down && videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + } + break; + } + }, + // only update when preview only changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [videoRef.current], + ); + useKeyboardListener( + ["ArrowLeft", "ArrowRight", "m", " "], + onKeyboardShortcut, + ); + + return ( +
{ + setControls(true); + } + : undefined + } + onMouseOut={ + isDesktop + ? () => { + setControls(controlsOpen); + } + : undefined + } + onClick={isDesktop ? undefined : () => setControls(!controls)} + > +
+ ); +} + +type VideoControlsProps = { + video: HTMLVideoElement | null; + isPlaying: boolean; + show: boolean; + controlsOpen: boolean; + setControlsOpen: (open: boolean) => void; +}; +function VideoControls({ + video, + isPlaying, + show, + controlsOpen, + setControlsOpen, +}: VideoControlsProps) { + const playbackRates = useMemo(() => { + if (isSafari) { + return [0.5, 1, 2]; + } else { + return [0.5, 1, 2, 4, 8, 16]; + } + }, []); + + const onReplay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = video?.currentTime; + + if (!video || !currentTime) { + return; + } + + video.currentTime = Math.max(0, currentTime - 10); + }, + [video], + ); + + const onSkip = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = video?.currentTime; + + if (!video || !currentTime) { + return; + } + + video.currentTime = currentTime + 10; + }, + [video], + ); + + const onTogglePlay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!video) { + return; + } + + if (isPlaying) { + video.pause(); + } else { + video.play(); + } + }, + [isPlaying, video], + ); + + if (!video || !show) { + return; + } + + return ( +
+ +
+ {isPlaying ? ( + + ) : ( + + )} +
+ + { + setControlsOpen(open); + }} + > + {`${video.playbackRate}x`} + + (video.playbackRate = parseInt(rate))} + > + {playbackRates.map((rate) => ( + + {rate}x + + ))} + + + +
+ ); +} diff --git a/web/src/components/player/VideoPlayer.tsx b/web/src/components/player/VideoPlayer.tsx deleted file mode 100644 index ab04562f0..000000000 --- a/web/src/components/player/VideoPlayer.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useRef, ReactElement } from "react"; -import videojs from "video.js"; -import "video.js/dist/video-js.css"; -import Player from "video.js/dist/types/player"; - -type VideoPlayerProps = { - children?: ReactElement | ReactElement[]; - options?: { - [key: string]: unknown; - }; - seekOptions?: { - forward?: number; - backward?: number; - }; - remotePlayback?: boolean; - onReady?: (player: Player) => void; - onDispose?: () => void; -}; - -export default function VideoPlayer({ - children, - options, - seekOptions = { forward: 30, backward: 10 }, - remotePlayback = false, - onReady = () => {}, - onDispose = () => {}, -}: VideoPlayerProps) { - const videoRef = useRef(null); - const playerRef = useRef(null); - - useEffect(() => { - const defaultOptions = { - controls: true, - controlBar: { - skipButtons: seekOptions, - }, - playbackRates: [0.5, 1, 2, 4, 8], - fluid: true, - }; - - if (!videojs.browser.IS_FIREFOX) { - defaultOptions.playbackRates.push(16); - } - - // Make sure Video.js player is only initialized once - if (!playerRef.current) { - // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. - const videoElement = document.createElement( - "video-js", - ) as HTMLVideoElement; - videoElement.controls = true; - videoElement.playsInline = true; - videoElement.disableRemotePlayback = !remotePlayback; - videoElement.classList.add("small-player"); - videoElement.classList.add("video-js"); - videoElement.classList.add("vjs-default-skin"); - videoRef.current?.appendChild(videoElement); - - const player = (playerRef.current = videojs( - videoElement, - { ...defaultOptions, ...options }, - () => { - onReady && onReady(player); - }, - )); - } - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, videoRef]); - - // Dispose the Video.js player when the functional component unmounts - useEffect(() => { - const player = playerRef.current; - - return () => { - if (player && !player.isDisposed()) { - player.dispose(); - playerRef.current = null; - onDispose(); - } - }; - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playerRef]); - - return ( -
-
- {children} -
- ); -} diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index 1b56a8062..45101c27f 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -1,4 +1,3 @@ -import Player from "video.js/dist/types/player"; import { Recording } from "@/types/record"; import { DynamicPlayback } from "@/types/playback"; import { PreviewController } from "../PreviewPlayer"; @@ -8,7 +7,7 @@ type PlayerMode = "playback" | "scrubbing"; export class DynamicVideoController { // main state public camera = ""; - private playerController: Player; + private playerController: HTMLVideoElement; private previewController: PreviewController; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; @@ -19,13 +18,9 @@ export class DynamicVideoController { private annotationOffset: number; private timeToStart: number | undefined = undefined; - // listeners - private playerProgressListener: (() => void) | null = null; - private playerEndedListener: (() => void) | null = null; - constructor( camera: string, - playerController: Player, + playerController: HTMLVideoElement, previewController: PreviewController, annotationOffset: number, defaultMode: PlayerMode, @@ -43,10 +38,6 @@ export class DynamicVideoController { newPlayback(newPlayback: DynamicPlayback) { this.recordings = newPlayback.recordings; - this.playerController.src({ - src: newPlayback.playbackUri, - type: "application/vnd.apple.mpegurl", - }); if (this.timeToStart) { this.seekToTimestamp(this.timeToStart); @@ -91,7 +82,7 @@ export class DynamicVideoController { }); if (seekSeconds != 0) { - this.playerController.currentTime(seekSeconds); + this.playerController.currentTime = seekSeconds; if (play) { this.playerController.play(); @@ -125,38 +116,6 @@ export class DynamicVideoController { return timestamp; } - onPlayerTimeUpdate(listener: ((timestamp: number) => void) | null) { - if (this.playerProgressListener) { - this.playerController.off("timeupdate", this.playerProgressListener); - this.playerProgressListener = null; - } - - if (listener) { - this.playerProgressListener = () => { - const progress = this.playerController.currentTime() || 0; - - if (progress == 0) { - return; - } - - listener(this.getProgress(progress)); - }; - this.playerController.on("timeupdate", this.playerProgressListener); - } - } - - onClipChangedEvent(listener: ((dir: "forward") => void) | null) { - if (this.playerEndedListener) { - this.playerController.off("ended", this.playerEndedListener); - this.playerEndedListener = null; - } - - if (listener) { - this.playerEndedListener = () => listener("forward"); - this.playerController.on("ended", this.playerEndedListener); - } - } - scrubToTimestamp(time: number, saveIfNotReady: boolean = false) { const scrubResult = this.previewController.scrubToTimestamp(time); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 88ed323cc..96fbc80fa 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -1,25 +1,13 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import VideoPlayer from "../VideoPlayer"; -import Player from "video.js/dist/types/player"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import TimelineEventOverlay from "../../overlay/TimelineDataOverlay"; import { useApiHost } from "@/api"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; -import { isDesktop } from "react-device-detect"; -import { LuPause, LuPlay } from "react-icons/lu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "../../ui/dropdown-menu"; -import { MdForward10, MdReplay10 } from "react-icons/md"; import { DynamicVideoController } from "./DynamicVideoController"; +import HlsVideoPlayer from "../HlsVideoPlayer"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -29,16 +17,20 @@ type DynamicVideoPlayerProps = { camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; - startTime?: number; + startTimestamp?: number; onControllerReady: (controller: DynamicVideoController) => void; + onTimestampUpdate?: (timestamp: number) => void; + onClipEnded?: () => void; }; export default function DynamicVideoPlayer({ className, camera, timeRange, cameraPreviews, - startTime, + startTimestamp, onControllerReady, + onTimestampUpdate, + onClipEnded, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -58,23 +50,21 @@ export default function DynamicVideoPlayer({ // controlling playback - const [playerRef, setPlayerRef] = useState(null); + const playerRef = useRef(null); const [previewController, setPreviewController] = useState(null); - const [controls, setControls] = useState(false); - const [controlsOpen, setControlsOpen] = useState(false); const [isScrubbing, setIsScrubbing] = useState(false); const [focusedItem, setFocusedItem] = useState( undefined, ); const controller = useMemo(() => { - if (!config || !playerRef || !previewController) { + if (!config || !playerRef.current || !previewController) { return undefined; } return new DynamicVideoController( camera, - playerRef, + playerRef.current, previewController, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, "playback", @@ -83,7 +73,7 @@ export default function DynamicVideoPlayer({ ); // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, config, playerRef, previewController]); + }, [camera, config, playerRef.current, previewController]); useEffect(() => { if (!controller) { @@ -98,110 +88,32 @@ export default function DynamicVideoPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller]); - // keyboard control - - const onKeyboardShortcut = useCallback( - (key: string, down: boolean, repeat: boolean) => { - if (!playerRef) { - return; - } - - switch (key) { - case "ArrowLeft": - if (down) { - const currentTime = playerRef.currentTime(); - - if (currentTime) { - playerRef.currentTime(Math.max(0, currentTime - 5)); - } - } - break; - case "ArrowRight": - if (down) { - const currentTime = playerRef.currentTime(); - - if (currentTime) { - playerRef.currentTime(currentTime + 5); - } - } - break; - case "m": - if (down && !repeat && playerRef) { - playerRef.muted(!playerRef.muted()); - } - break; - case " ": - if (down && playerRef) { - if (playerRef.paused()) { - playerRef.play(); - } else { - playerRef.pause(); - } - } - break; - } - }, - // only update when preview only changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [playerRef], - ); - useKeyboardListener( - ["ArrowLeft", "ArrowRight", "m", " "], - onKeyboardShortcut, - ); - - // mobile tap controls - - useEffect(() => { - if (isDesktop || !playerRef) { - return; - } - - const callback = () => setControls(!controls); - playerRef.on("touchstart", callback); - - return () => playerRef.off("touchstart", callback); - }, [controls, playerRef]); - // initial state - const initialPlaybackSource = useMemo(() => { - return { - src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, - type: "application/vnd.apple.mpegurl", - }; - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const [source, setSource] = useState( + `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + ); // start at correct time - useEffect(() => { - const player = playerRef; - - if (!player) { + const onPlayerLoaded = useCallback(() => { + if (!controller || !startTimestamp) { return; } - if (!startTime) { - return; - } + controller.seekToTimestamp(startTimestamp, true); + }, [startTimestamp, controller]); - if (player.isReady_) { - controller?.seekToTimestamp(startTime, true); - return; - } + const onTimeUpdate = useCallback( + (time: number) => { + if (!controller || !onTimestampUpdate || time == 0) { + return; + } - const callback = () => { - controller?.seekToTimestamp(startTime, true); - }; - player.on("loadeddata", callback); - return () => { - player.off("loadeddata", callback); - }; - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startTime, controller]); + onTimestampUpdate(controller.getProgress(time)); + }, + [controller, onTimestampUpdate], + ); // state of playback player @@ -221,11 +133,12 @@ export default function DynamicVideoPlayer({ return; } - const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`; + setSource( + `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + ); controller.newPlayback({ recordings: recordings ?? [], - playbackUri, }); // we only want this to change when recordings update @@ -233,39 +146,15 @@ export default function DynamicVideoPlayer({ }, [controller, recordings]); return ( -
{ - setControls(true); - } - : undefined - } - onMouseOut={ - isDesktop - ? () => { - setControls(controlsOpen); - } - : undefined - } - > +
- { - setPlayerRef(player); - }} - onDispose={() => { - setPlayerRef(null); - }} + {config && focusedItem && ( )} - - +
); } - -type PlayerControlsProps = { - player: Player | null; - show: boolean; - controlsOpen: boolean; - setControlsOpen: (open: boolean) => void; -}; -function PlayerControls({ - player, - show, - controlsOpen, - setControlsOpen, -}: PlayerControlsProps) { - const playbackRates = useMemo(() => { - if (!player) { - return []; - } - - // @ts-expect-error player getter requires undefined - return player.playbackRates(undefined); - }, [player]); - - const onReplay = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - const currentTime = player?.currentTime(); - - if (!player || !currentTime) { - return; - } - - player.currentTime(Math.max(0, currentTime - 10)); - }, - [player], - ); - - const onSkip = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - const currentTime = player?.currentTime(); - - if (!player || !currentTime) { - return; - } - - player.currentTime(currentTime + 10); - }, - [player], - ); - - const onTogglePlay = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - if (!player) { - return; - } - - if (player.paused()) { - player.play(); - } else { - player.pause(); - } - }, - [player], - ); - - if (!player || !show) { - return; - } - - return ( -
- -
- {player.paused() ? ( - - ) : ( - - )} -
- - { - setControlsOpen(open); - }} - > - {`${player.playbackRate()}x`} - - player.playbackRate(parseInt(rate))} - > - {playbackRates.map((rate) => ( - - {rate}x - - ))} - - - -
- ); -} diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 76ac6fe95..395378219 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from "@/components/ui/select"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; +import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; // Color data const colors = [ @@ -157,6 +158,8 @@ function UIPlayground() { timestampSpread: 15, }); + const videoRef = useRef(null); + const possibleZoomLevels = [ { segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 30, timestampSpread: 5 }, @@ -290,6 +293,14 @@ function UIPlayground() {
+
+ +
+
{!isEventsReviewTimeline && ( import("@/components/player/dynamic/DynamicVideoPlayer"), -); - const SEGMENT_DURATION = 30; type DesktopRecordingViewProps = { @@ -82,21 +71,16 @@ export function DesktopRecordingView({ ); // move to next clip - useEffect(() => { + + const onClipEnded = useCallback(() => { if (!mainControllerRef.current) { return; } - mainControllerRef.current.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - // we only want to fire once when players are ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]); + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } + }, [selectedRangeIdx, timeRange]); // scrubbing and timeline state @@ -111,8 +95,8 @@ export function DesktopRecordingView({ ); if (index != -1) { - setSelectedRangeIdx(index); setPlaybackStart(currentTime); + setSelectedRangeIdx(index); } }, [timeRange], @@ -144,7 +128,14 @@ export function DesktopRecordingView({ useEffect(() => { if (!scrubbing) { - mainControllerRef.current?.seekToTimestamp(currentTime, true); + if ( + currentTimeRange.start <= currentTime && + currentTimeRange.end >= currentTime + ) { + mainControllerRef.current?.seekToTimestamp(currentTime, true); + } else { + updateSelectedSegment(currentTime); + } } // we only want to seek when user stops scrubbing @@ -226,25 +217,24 @@ export function DesktopRecordingView({ key={mainCamera} className="w-[82%] flex justify-center items mb-5" > - }> - { - mainControllerRef.current = controller; - controller.onPlayerTimeUpdate((timestamp: number) => { - setPlayerTime(timestamp); - setCurrentTime(timestamp); - Object.values(previewRefs.current ?? {}).forEach((prev) => - prev.scrubToTimestamp(Math.floor(timestamp)), - ); - }); - }} - /> - + { + setPlayerTime(timestamp); + setCurrentTime(timestamp); + Object.values(previewRefs.current ?? {}).forEach((prev) => + prev.scrubToTimestamp(Math.floor(timestamp)), + ); + }} + onClipEnded={onClipEnded} + onControllerReady={(controller) => { + mainControllerRef.current = controller; + }} + />
{allCameras.map((cam) => { @@ -330,7 +320,6 @@ export function MobileRecordingView({ // controller state - const [playerReady, setPlayerReady] = useState(false); const controllerRef = useRef(undefined); const [playbackCamera, setPlaybackCamera] = useState(startCamera); const [playbackStart, setPlaybackStart] = useState(startTime); @@ -353,20 +342,17 @@ export function MobileRecordingView({ [reviewItems, playbackCamera], ); - // move to next clip - useEffect(() => { + // handle clip change + + const onClipEnded = useCallback(() => { if (!controllerRef.current) { return; } - controllerRef.current.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - }, [playerReady, selectedRangeIdx, timeRange]); + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } + }, [selectedRangeIdx, timeRange]); // scrubbing and timeline state @@ -463,13 +449,9 @@ export function MobileRecordingView({ startTime={playbackStart} onControllerReady={(controller) => { controllerRef.current = controller; - setPlayerReady(true); - controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { - setCurrentTime(timestamp); - }); - - controllerRef.current?.seekToTimestamp(startTime, true); }} + onTimeUpdate={setCurrentTime} + onClipEnded={onClipEnded} />