Use react lazy to break js files into smaller chunks & remove videojs in favor of hls.js (#10431)

* Use dynamic imports to reduce initial load times

Remove videojs

* Convert to using hls.js instead of videojs

* Improve mobile controls experience

* Cleanup

* Ensure playback rate stays teh same when source changes

* Use webp for latest camera image

* Switch to hls.js on error

* Don't rerun error if hls already tried

* Fix error checking

* also check for media decode error to fallback to HLS

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen
2024-03-13 14:24:24 -06:00
committed by GitHub
parent 0e8350ea7f
commit f9ed082e35
12 changed files with 737 additions and 1029 deletions

View File

@@ -2,20 +2,23 @@ import Providers from "@/context/providers";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Wrapper from "@/components/Wrapper";
import Sidebar from "@/components/navigation/Sidebar";
import Live from "@/pages/Live";
import Export from "@/pages/Export";
import Storage from "@/pages/Storage";
import System from "@/pages/System";
import ConfigEditor from "@/pages/ConfigEditor";
import Logs from "@/pages/Logs";
import NoMatch from "@/pages/NoMatch";
import Settings from "@/pages/Settings";
import UIPlayground from "./pages/UIPlayground";
import Events from "./pages/Events";
import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar";
import Bottombar from "./components/navigation/Bottombar";
import SubmitPlus from "./pages/SubmitPlus";
import { Suspense, lazy } from "react";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
const Export = lazy(() => import("@/pages/Export"));
const Storage = lazy(() => import("@/pages/Storage"));
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
const System = lazy(() => import("@/pages/System"));
const Settings = lazy(() => import("@/pages/Settings"));
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
const Logs = lazy(() => import("@/pages/Logs"));
const NoMatch = lazy(() => import("@/pages/NoMatch"));
function App() {
return (
@@ -30,19 +33,21 @@ function App() {
id="pageRoot"
className={`absolute top-2 right-0 overflow-hidden ${isMobile ? "left-0 bottom-16" : "left-16 bottom-8"}`}
>
<Routes>
<Route path="/" element={<Live />} />
<Route path="/events" element={<Events />} />
<Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} />
<Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} />
<Route path="/settings" element={<Settings />} />
<Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} />
<Route path="/playground" element={<UIPlayground />} />
<Route path="*" element={<NoMatch />} />
</Routes>
<Suspense>
<Routes>
<Route path="/" element={<Live />} />
<Route path="/events" element={<Events />} />
<Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} />
<Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} />
<Route path="/settings" element={<Settings />} />
<Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} />
<Route path="/playground" element={<UIPlayground />} />
<Route path="*" element={<NoMatch />} />
</Routes>
</Suspense>
</div>
</div>
</Wrapper>

View File

@@ -1,584 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import VideoPlayer from "./VideoPlayer";
import Player from "video.js/dist/types/player";
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 { DynamicPlayback } from "@/types/playback";
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";
type PlayerMode = "playback" | "scrubbing";
/**
* Dynamically switches between video playback and scrubbing preview player.
*/
type DynamicVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
cameraPreviews: Preview[];
startTime?: number;
onControllerReady: (controller: DynamicVideoController) => void;
};
export default function DynamicVideoPlayer({
className,
camera,
timeRange,
cameraPreviews,
startTime,
onControllerReady,
}: DynamicVideoPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
// playback behavior
const wideVideo = useMemo(() => {
if (!config) {
return false;
}
return (
config.cameras[camera].detect.width /
config.cameras[camera].detect.height >
1.7
);
}, [camera, config]);
// controlling playback
const [playerRef, setPlayerRef] = useState<Player | null>(null);
const [previewController, setPreviewController] =
useState<PreviewController | null>(null);
const [controls, setControls] = useState(false);
const [controlsOpen, setControlsOpen] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined,
);
const controller = useMemo(() => {
if (!config || !playerRef || !previewController) {
return undefined;
}
return new DynamicVideoController(
camera,
playerRef,
previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
"playback",
setIsScrubbing,
setFocusedItem,
);
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, config, playerRef, previewController]);
useEffect(() => {
if (!controller) {
return;
}
if (controller) {
onControllerReady(controller);
}
// we only want to fire once when players are ready
// 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
}, []);
// start at correct time
useEffect(() => {
const player = playerRef;
if (!player) {
return;
}
if (!startTime) {
return;
}
if (player.isReady_) {
controller?.seekToTimestamp(startTime, true);
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]);
// state of playback player
const recordingParams = useMemo(() => {
return {
before: timeRange.end,
after: timeRange.start,
};
}, [timeRange]);
const { data: recordings } = useSWR<Recording[]>(
[`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false },
);
useEffect(() => {
if (!controller || !recordings) {
return;
}
const playbackUri = `${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
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, recordings]);
return (
<div
className={`relative ${className ?? ""} cursor-pointer`}
onMouseOver={
isDesktop
? () => {
setControls(true);
}
: undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
>
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
<VideoPlayer
options={{
preload: "auto",
autoplay: true,
sources: [initialPlaybackSource],
aspectRatio: wideVideo ? undefined : "16:9",
controls: false,
nativeControlsForTouch: false,
}}
onReady={(player) => {
setPlayerRef(player);
}}
onDispose={() => {
setPlayerRef(null);
}}
>
{config && focusedItem && (
<TimelineEventOverlay
timeline={focusedItem}
cameraConfig={config.cameras[camera]}
/>
)}
</VideoPlayer>
<PlayerControls
player={playerRef}
show={controls}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
/>
</div>
<PreviewPlayer
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
camera={camera}
timeRange={timeRange}
cameraPreviews={cameraPreviews}
onControllerReady={(previewController) => {
setPreviewController(previewController);
}}
/>
</div>
);
}
export class DynamicVideoController {
// main state
public camera = "";
private playerController: Player;
private previewController: PreviewController;
private setScrubbing: (isScrubbing: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void;
private playerMode: PlayerMode = "playback";
// playback
private recordings: Recording[] = [];
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
// listeners
private playerProgressListener: (() => void) | null = null;
private playerEndedListener: (() => void) | null = null;
constructor(
camera: string,
playerController: Player,
previewController: PreviewController,
annotationOffset: number,
defaultMode: PlayerMode,
setScrubbing: (isScrubbing: boolean) => void,
setFocusedItem: (timeline: Timeline) => void,
) {
this.camera = camera;
this.playerController = playerController;
this.previewController = previewController;
this.annotationOffset = annotationOffset;
this.playerMode = defaultMode;
this.setScrubbing = setScrubbing;
this.setFocusedItem = setFocusedItem;
}
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);
this.timeToStart = undefined;
}
}
pause() {
this.playerController.pause();
}
seekToTimestamp(time: number, play: boolean = false) {
if (this.playerMode != "playback") {
this.playerMode = "playback";
this.setScrubbing(false);
}
if (
this.recordings.length == 0 ||
time < this.recordings[0].start_time ||
time > this.recordings[this.recordings.length - 1].end_time
) {
this.timeToStart = time;
return;
}
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;
}
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;
});
if (seekSeconds != 0) {
this.playerController.currentTime(seekSeconds);
if (play) {
this.playerController.play();
} else {
this.playerController.pause();
}
}
}
seekToTimelineItem(timeline: Timeline) {
this.playerController.pause();
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
this.setFocusedItem(timeline);
}
getProgress(playerTime: number): number {
// take a player time in seconds and convert to timestamp in timeline
let timestamp = 0;
let totalTime = 0;
(this.recordings || []).every((segment) => {
if (totalTime + segment.duration > playerTime) {
// segment is here
timestamp = segment.start_time + (playerTime - totalTime);
return false;
} else {
totalTime += segment.duration;
return true;
}
});
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);
if (!scrubResult && saveIfNotReady) {
this.previewController.setNewPreviewStartTime(time);
}
if (scrubResult && this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";
this.playerController.pause();
this.setScrubbing(true);
}
}
hasRecordingAtTime(time: number): boolean {
if (!this.recordings || this.recordings.length == 0) {
return false;
}
return (
this.recordings.find(
(segment) => segment.start_time <= time && segment.end_time >= time,
) != undefined
);
}
}
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<HTMLDivElement>) => {
e.stopPropagation();
const currentTime = player?.currentTime();
if (!player || !currentTime) {
return;
}
player.currentTime(Math.max(0, currentTime - 10));
},
[player],
);
const onSkip = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const currentTime = player?.currentTime();
if (!player || !currentTime) {
return;
}
player.currentTime(currentTime + 10);
},
[player],
);
const onTogglePlay = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!player) {
return;
}
if (player.paused()) {
player.play();
} else {
player.pause();
}
},
[player],
);
if (!player || !show) {
return;
}
return (
<div
className={`absolute bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 flex justify-between items-center gap-8 text-white z-10 bg-black bg-opacity-60 rounded-lg`}
>
<MdReplay10 className="size-5 cursor-pointer" onClick={onReplay} />
<div className="cursor-pointer" onClick={onTogglePlay}>
{player.paused() ? (
<LuPlay className="size-5 fill-white" />
) : (
<LuPause className="size-5 fill-white" />
)}
</div>
<MdForward10 className="size-5 cursor-pointer" onClick={onSkip} />
<DropdownMenu
open={controlsOpen}
onOpenChange={(open) => {
setControlsOpen(open);
}}
>
<DropdownMenuTrigger>{`${player.playbackRate()}x`}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup
onValueChange={(rate) => player.playbackRate(parseInt(rate))}
>
{playbackRates.map((rate) => (
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
{rate}x
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,321 @@
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;
const unsupportedErrorCodes = [
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
MediaError.MEDIA_ERR_DECODE,
];
type HlsVideoPlayerProps = {
className: string;
children?: ReactNode;
videoRef: MutableRefObject<HTMLVideoElement | null>;
currentSource: string;
onClipEnded?: () => void;
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
};
export default function HlsVideoPlayer({
className,
children,
videoRef,
currentSource,
onClipEnded,
onPlayerLoaded,
onTimeUpdate,
}: HlsVideoPlayerProps) {
// playback
const hlsRef = useRef<Hls>();
const [useHlsCompat, setUseHlsCompat] = useState(false);
useEffect(() => {
if (!videoRef.current) {
return;
}
if (videoRef.current.canPlayType(HLS_MIME_TYPE)) {
return;
} else if (Hls.isSupported()) {
setUseHlsCompat(true);
}
}, [videoRef]);
useEffect(() => {
if (!videoRef.current) {
return;
}
const currentPlaybackRate = videoRef.current.playbackRate;
if (!useHlsCompat) {
videoRef.current.src = currentSource;
videoRef.current.load();
return;
}
if (!hlsRef.current) {
hlsRef.current = new Hls();
hlsRef.current.attachMedia(videoRef.current);
}
hlsRef.current.loadSource(currentSource);
videoRef.current.playbackRate = currentPlaybackRate;
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
// controls
const [isPlaying, setIsPlaying] = useState(true);
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
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 (
<div
className={`relative ${className ?? ""}`}
onMouseOver={
isDesktop
? () => {
setControls(true);
}
: undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
onClick={isDesktop ? undefined : () => setControls(!controls)}
>
<video
ref={videoRef}
className="size-full rounded-2xl"
preload="auto"
autoPlay
controls={false}
playsInline
onPlay={() => {
setIsPlaying(true);
if (isMobile) {
setControls(true);
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
}
}}
onPause={() => {
setIsPlaying(false);
if (isMobile && mobileCtrlTimeout) {
clearTimeout(mobileCtrlTimeout);
}
}}
onTimeUpdate={() =>
onTimeUpdate && videoRef.current
? onTimeUpdate(videoRef.current.currentTime)
: undefined
}
onLoadedData={onPlayerLoaded}
onEnded={onClipEnded}
onError={(e) => {
if (
!hlsRef.current &&
// @ts-expect-error code does exist
unsupportedErrorCodes.includes(e.target.error.code) &&
videoRef.current
) {
setUseHlsCompat(true);
}
}}
/>
<VideoControls
video={videoRef.current}
isPlaying={isPlaying}
show={controls}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
/>
{children}
</div>
);
}
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<HTMLDivElement>) => {
e.stopPropagation();
const currentTime = video?.currentTime;
if (!video || !currentTime) {
return;
}
video.currentTime = Math.max(0, currentTime - 10);
},
[video],
);
const onSkip = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const currentTime = video?.currentTime;
if (!video || !currentTime) {
return;
}
video.currentTime = currentTime + 10;
},
[video],
);
const onTogglePlay = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!video) {
return;
}
if (isPlaying) {
video.pause();
} else {
video.play();
}
},
[isPlaying, video],
);
if (!video || !show) {
return;
}
return (
<div
className={`absolute bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-black bg-opacity-60 rounded-lg`}
>
<MdReplay10 className="size-5 cursor-pointer" onClick={onReplay} />
<div className="cursor-pointer" onClick={onTogglePlay}>
{isPlaying ? (
<LuPause className="size-5 fill-white" />
) : (
<LuPlay className="size-5 fill-white" />
)}
</div>
<MdForward10 className="size-5 cursor-pointer" onClick={onSkip} />
<DropdownMenu
open={controlsOpen}
onOpenChange={(open) => {
setControlsOpen(open);
}}
>
<DropdownMenuTrigger>{`${video.playbackRate}x`}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup
onValueChange={(rate) => (video.playbackRate = parseInt(rate))}
>
{playbackRates.map((rate) => (
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
{rate}x
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -1,95 +0,0 @@
import { useEffect, useRef, ReactElement } from "react";
import videojs from "video.js";
import "videojs-playlist";
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<HTMLDivElement | null>(null);
const playerRef = useRef<Player | null>(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 (
<div data-vjs-player>
<div className="rounded-2xl overflow-hidden" ref={videoRef} />
{children}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewPlayer";
type PlayerMode = "playback" | "scrubbing";
export class DynamicVideoController {
// main state
public camera = "";
private playerController: HTMLVideoElement;
private previewController: PreviewController;
private setScrubbing: (isScrubbing: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void;
private playerMode: PlayerMode = "playback";
// playback
private recordings: Recording[] = [];
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
constructor(
camera: string,
playerController: HTMLVideoElement,
previewController: PreviewController,
annotationOffset: number,
defaultMode: PlayerMode,
setScrubbing: (isScrubbing: boolean) => void,
setFocusedItem: (timeline: Timeline) => void,
) {
this.camera = camera;
this.playerController = playerController;
this.previewController = previewController;
this.annotationOffset = annotationOffset;
this.playerMode = defaultMode;
this.setScrubbing = setScrubbing;
this.setFocusedItem = setFocusedItem;
}
newPlayback(newPlayback: DynamicPlayback) {
this.recordings = newPlayback.recordings;
if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart);
this.timeToStart = undefined;
}
}
pause() {
this.playerController.pause();
}
seekToTimestamp(time: number, play: boolean = false) {
if (this.playerMode != "playback") {
this.playerMode = "playback";
this.setScrubbing(false);
}
if (
this.recordings.length == 0 ||
time < this.recordings[0].start_time ||
time > this.recordings[this.recordings.length - 1].end_time
) {
this.timeToStart = time;
return;
}
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;
}
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;
});
if (seekSeconds != 0) {
this.playerController.currentTime = seekSeconds;
if (play) {
this.playerController.play();
} else {
this.playerController.pause();
}
}
}
seekToTimelineItem(timeline: Timeline) {
this.playerController.pause();
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
this.setFocusedItem(timeline);
}
getProgress(playerTime: number): number {
// take a player time in seconds and convert to timestamp in timeline
let timestamp = 0;
let totalTime = 0;
(this.recordings || []).every((segment) => {
if (totalTime + segment.duration > playerTime) {
// segment is here
timestamp = segment.start_time + (playerTime - totalTime);
return false;
} else {
totalTime += segment.duration;
return true;
}
});
return timestamp;
}
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
const scrubResult = this.previewController.scrubToTimestamp(time);
if (!scrubResult && saveIfNotReady) {
this.previewController.setNewPreviewStartTime(time);
}
if (scrubResult && this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";
this.playerController.pause();
this.setScrubbing(true);
}
}
hasRecordingAtTime(time: number): boolean {
if (!this.recordings || this.recordings.length == 0) {
return false;
}
return (
this.recordings.find(
(segment) => segment.start_time <= time && segment.end_time >= time,
) != undefined
);
}
}
export default typeof DynamicVideoController;

View File

@@ -0,0 +1,178 @@
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 { Recording } from "@/types/record";
import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer";
/**
* Dynamically switches between video playback and scrubbing preview player.
*/
type DynamicVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
cameraPreviews: Preview[];
startTimestamp?: number;
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
};
export default function DynamicVideoPlayer({
className,
camera,
timeRange,
cameraPreviews,
startTimestamp,
onControllerReady,
onTimestampUpdate,
onClipEnded,
}: DynamicVideoPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
// playback behavior
const wideVideo = useMemo(() => {
if (!config) {
return false;
}
return (
config.cameras[camera].detect.width /
config.cameras[camera].detect.height >
1.7
);
}, [camera, config]);
// controlling playback
const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] =
useState<PreviewController | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined,
);
const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) {
return undefined;
}
return new DynamicVideoController(
camera,
playerRef.current,
previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
"playback",
setIsScrubbing,
setFocusedItem,
);
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, config, playerRef.current, previewController]);
useEffect(() => {
if (!controller) {
return;
}
if (controller) {
onControllerReady(controller);
}
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller]);
// initial state
const [source, setSource] = useState(
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
);
// start at correct time
const onPlayerLoaded = useCallback(() => {
if (!controller || !startTimestamp) {
return;
}
controller.seekToTimestamp(startTimestamp, true);
}, [startTimestamp, controller]);
const onTimeUpdate = useCallback(
(time: number) => {
if (!controller || !onTimestampUpdate || time == 0) {
return;
}
onTimestampUpdate(controller.getProgress(time));
},
[controller, onTimestampUpdate],
);
// state of playback player
const recordingParams = useMemo(() => {
return {
before: timeRange.end,
after: timeRange.start,
};
}, [timeRange]);
const { data: recordings } = useSWR<Recording[]>(
[`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false },
);
useEffect(() => {
if (!controller || !recordings) {
return;
}
setSource(
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
);
controller.newPlayback({
recordings: recordings ?? [],
});
// we only want this to change when recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, recordings]);
return (
<div className={`relative ${className ?? ""} cursor-pointer`}>
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
<HlsVideoPlayer
className={` ${wideVideo ? "" : "aspect-video"}`}
videoRef={playerRef}
currentSource={source}
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onClipEnded}
>
{config && focusedItem && (
<TimelineEventOverlay
timeline={focusedItem}
cameraConfig={config.cameras[camera]}
/>
)}
</HlsVideoPlayer>
</div>
<PreviewPlayer
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
camera={camera}
timeRange={timeRange}
cameraPreviews={cameraPreviews}
onControllerReady={(previewController) => {
setPreviewController(previewController);
}}
/>
</div>
);
}

View File

@@ -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<HTMLVideoElement | null>(null);
const possibleZoomLevels = [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
@@ -290,6 +293,14 @@ function UIPlayground() {
</div>
</div>
<div className="absolute left-96 top-96 bottom-96 right-96">
<HlsVideoPlayer
className="size-full"
videoRef={videoRef}
currentSource="http://localhost:5173/vod/side_cam/start/1710345600/end/1710349200/master.m3u8"
/>
</div>
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
{!isEventsReviewTimeline && (
<MotionReviewTimeline

View File

@@ -3,7 +3,6 @@ import { Recording } from "./record";
export type DynamicPlayback = {
recordings: Recording[];
playbackUri: string;
};
export type PreviewPlayback = {

View File

@@ -1,9 +1,8 @@
import DynamicVideoPlayer, {
DynamicVideoController,
} from "@/components/player/DynamicVideoPlayer";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
@@ -72,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
@@ -101,8 +95,8 @@ export function DesktopRecordingView({
);
if (index != -1) {
setSelectedRangeIdx(index);
setPlaybackStart(currentTime);
setSelectedRangeIdx(index);
}
},
[timeRange],
@@ -134,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
@@ -221,16 +222,17 @@ export function DesktopRecordingView({
camera={mainCamera}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={playbackStart}
startTimestamp={playbackStart}
onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp);
setCurrentTime(timestamp);
Object.values(previewRefs.current ?? {}).forEach((prev) =>
prev.scrubToTimestamp(Math.floor(timestamp)),
);
}}
onClipEnded={onClipEnded}
onControllerReady={(controller) => {
mainControllerRef.current = controller;
controller.onPlayerTimeUpdate((timestamp: number) => {
setPlayerTime(timestamp);
setCurrentTime(timestamp);
Object.values(previewRefs.current ?? {}).forEach((prev) =>
prev.scrubToTimestamp(Math.floor(timestamp)),
);
});
}}
/>
</div>
@@ -318,7 +320,6 @@ export function MobileRecordingView({
// controller state
const [playerReady, setPlayerReady] = useState(false);
const controllerRef = useRef<DynamicVideoController | undefined>(undefined);
const [playbackCamera, setPlaybackCamera] = useState(startCamera);
const [playbackStart, setPlaybackStart] = useState(startTime);
@@ -341,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
@@ -448,16 +446,12 @@ export function MobileRecordingView({
camera={playbackCamera}
timeRange={currentTimeRange}
cameraPreviews={relevantPreviews || []}
startTime={playbackStart}
startTimestamp={playbackStart}
onControllerReady={(controller) => {
controllerRef.current = controller;
setPlayerReady(true);
controllerRef.current.onPlayerTimeUpdate((timestamp: number) => {
setCurrentTime(timestamp);
});
controllerRef.current?.seekToTimestamp(startTime, true);
}}
onTimestampUpdate={setCurrentTime}
onClipEnded={onClipEnded}
/>
</div>