From 664d88d25dbf01b62ecaeabdabf05c6ba5b88fa4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 13 Mar 2024 10:15:59 -0600 Subject: [PATCH] Use dynamic imports to reduce initial load times Remove videojs --- web/package-lock.json | 15 +- web/package.json | 3 +- web/src/App.tsx | 53 ++--- web/src/components/player/VideoPlayer.tsx | 1 - .../player/dynamic/DynamicVideoController.ts | 187 +++++++++++++++++ .../{ => dynamic}/DynamicVideoPlayer.tsx | 191 +----------------- web/src/views/events/RecordingView.tsx | 54 +++-- 7 files changed, 256 insertions(+), 248 deletions(-) create mode 100644 web/src/components/player/dynamic/DynamicVideoController.ts rename web/src/components/player/{ => dynamic}/DynamicVideoPlayer.tsx (66%) diff --git a/web/package-lock.json b/web/package-lock.json index dc218efd7..c6611c0d5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 6c8a698c6..a9ea40891 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 21e579fc2..dc82bbc07 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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"}`} > - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/web/src/components/player/VideoPlayer.tsx b/web/src/components/player/VideoPlayer.tsx index 881238bec..ab04562f0 100644 --- a/web/src/components/player/VideoPlayer.tsx +++ b/web/src/components/player/VideoPlayer.tsx @@ -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"; diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts new file mode 100644 index 000000000..1b56a8062 --- /dev/null +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -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; diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx similarity index 66% rename from web/src/components/player/DynamicVideoPlayer.tsx rename to web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 045fd7d63..88ed323cc 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -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; diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index dc9097e2c..4bf9cfd2d 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -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" > - { - mainControllerRef.current = controller; - controller.onPlayerTimeUpdate((timestamp: number) => { - setPlayerTime(timestamp); - setCurrentTime(timestamp); - Object.values(previewRefs.current ?? {}).forEach((prev) => - prev.scrubToTimestamp(Math.floor(timestamp)), - ); - }); - }} - /> + }> + { + mainControllerRef.current = controller; + controller.onPlayerTimeUpdate((timestamp: number) => { + setPlayerTime(timestamp); + setCurrentTime(timestamp); + Object.values(previewRefs.current ?? {}).forEach((prev) => + prev.scrubToTimestamp(Math.floor(timestamp)), + ); + }); + }} + /> +
{allCameras.map((cam) => {