Use dynamic imports to reduce initial load times

Remove videojs
This commit is contained in:
Nicolas Mowen 2024-03-13 10:15:59 -06:00
parent 0e8350ea7f
commit 664d88d25d
7 changed files with 256 additions and 248 deletions

15
web/package-lock.json generated
View File

@ -60,8 +60,7 @@
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.8.0",
"video.js": "^8.6.1",
"videojs-playlist": "^5.1.0",
"video.js": "^8.10.0",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.4"
},
@ -8187,18 +8186,6 @@
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.1.0.tgz",
"integrity": "sha512-X1LuPfLZPisPLrANIAKCknZbZu5obVM/ylfd1CN+SsCmPZQ3UMDPcvLTpPBJxcBuTpHQq2MO1QCFt7p8spnZ/w=="
},
"node_modules/videojs-playlist": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-5.1.0.tgz",
"integrity": "sha512-p5ohld6Kom9meYCcEVYj0JVS2MBL2XxMiU+IDB/xKpDOspFAHrERHrZEBoiJZc/mCfHixZBNgj1vWRgYsVVsrw==",
"dependencies": {
"global": "^4.3.2",
"video.js": "^6 || ^7 || ^8"
},
"engines": {
"node": ">=4.4.0"
}
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",

View File

@ -39,6 +39,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",
@ -65,8 +66,6 @@
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.8.0",
"video.js": "^8.6.1",
"videojs-playlist": "^5.1.0",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.4"
},

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,6 +1,5 @@
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";

View File

@ -0,0 +1,187 @@
import Player from "video.js/dist/types/player";
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: 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
);
}
}
export default typeof DynamicVideoController;

View File

@ -1,15 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import VideoPlayer from "./VideoPlayer";
import VideoPlayer from "../VideoPlayer";
import Player from "video.js/dist/types/player";
import TimelineEventOverlay from "../overlay/TimelineDataOverlay";
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 PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { isDesktop } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu";
import {
@ -18,10 +17,9 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
} from "../../ui/dropdown-menu";
import { MdForward10, MdReplay10 } from "react-icons/md";
type PlayerMode = "playback" | "scrubbing";
import { DynamicVideoController } from "./DynamicVideoController";
/**
* Dynamically switches between video playback and scrubbing preview player.
@ -296,185 +294,6 @@ export default function DynamicVideoPlayer({
);
}
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;

View File

@ -1,9 +1,7 @@
import DynamicVideoPlayer, {
DynamicVideoController,
} from "@/components/player/DynamicVideoPlayer";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
@ -14,15 +12,27 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
const DynamicVideoPlayer = React.lazy(
() => import("@/components/player/dynamic/DynamicVideoPlayer"),
);
const SEGMENT_DURATION = 30;
type DesktopRecordingViewProps = {
@ -216,23 +226,25 @@ export function DesktopRecordingView({
key={mainCamera}
className="w-[82%] flex justify-center items mb-5"
>
<DynamicVideoPlayer
className={`w-full ${grow}`}
camera={mainCamera}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={playbackStart}
onControllerReady={(controller) => {
mainControllerRef.current = controller;
controller.onPlayerTimeUpdate((timestamp: number) => {
setPlayerTime(timestamp);
setCurrentTime(timestamp);
Object.values(previewRefs.current ?? {}).forEach((prev) =>
prev.scrubToTimestamp(Math.floor(timestamp)),
);
});
}}
/>
<Suspense fallback={<Skeleton className={`w-full ${grow}`} />}>
<DynamicVideoPlayer
className={`w-full ${grow}`}
camera={mainCamera}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={playbackStart}
onControllerReady={(controller) => {
mainControllerRef.current = controller;
controller.onPlayerTimeUpdate((timestamp: number) => {
setPlayerTime(timestamp);
setCurrentTime(timestamp);
Object.values(previewRefs.current ?? {}).forEach((prev) =>
prev.scrubToTimestamp(Math.floor(timestamp)),
);
});
}}
/>
</Suspense>
</div>
<div className="w-full flex justify-center gap-2 overflow-x-auto">
{allCameras.map((cam) => {