mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-08 20:25:26 +03:00
Convert to using hls.js instead of videojs
This commit is contained in:
parent
664d88d25d
commit
503997c066
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -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",
|
||||
|
||||
285
web/src/components/player/HlsVideoPlayer.tsx
Normal file
285
web/src/components/player/HlsVideoPlayer.tsx
Normal file
@ -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<HTMLVideoElement | null>;
|
||||
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<Hls>();
|
||||
|
||||
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 (
|
||||
<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"
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={false}
|
||||
playsInline
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={() =>
|
||||
onTimeUpdate && videoRef.current
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
: undefined
|
||||
}
|
||||
onLoadedData={onPlayerLoaded}
|
||||
onEnded={onClipEnded}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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<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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<FrigateConfig>("config");
|
||||
@ -58,23 +50,21 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
// controlling playback
|
||||
|
||||
const [playerRef, setPlayerRef] = useState<Player | null>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | 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) {
|
||||
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 (
|
||||
<div
|
||||
className={`relative ${className ?? ""} cursor-pointer`}
|
||||
onMouseOver={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseOut={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(controlsOpen);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={`relative ${className ?? ""} cursor-pointer`}>
|
||||
<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);
|
||||
}}
|
||||
<HlsVideoPlayer
|
||||
className={` ${wideVideo ? "" : "aspect-video"}`}
|
||||
videoRef={playerRef}
|
||||
currentSource={source}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlayerLoaded={onPlayerLoaded}
|
||||
onClipEnded={onClipEnded}
|
||||
>
|
||||
{config && focusedItem && (
|
||||
<TimelineEventOverlay
|
||||
@ -273,13 +162,7 @@ export default function DynamicVideoPlayer({
|
||||
cameraConfig={config.cameras[camera]}
|
||||
/>
|
||||
)}
|
||||
</VideoPlayer>
|
||||
<PlayerControls
|
||||
player={playerRef}
|
||||
show={controls}
|
||||
controlsOpen={controlsOpen}
|
||||
setControlsOpen={setControlsOpen}
|
||||
/>
|
||||
</HlsVideoPlayer>
|
||||
</div>
|
||||
<PreviewPlayer
|
||||
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
||||
@ -293,111 +176,3 @@ export default function DynamicVideoPlayer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,7 +3,6 @@ import { Recording } from "./record";
|
||||
|
||||
export type DynamicPlayback = {
|
||||
recordings: Recording[];
|
||||
playbackUri: string;
|
||||
};
|
||||
|
||||
export type PreviewPlayback = {
|
||||
|
||||
@ -2,6 +2,7 @@ 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";
|
||||
@ -12,27 +13,15 @@ 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 React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { 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 = {
|
||||
@ -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"
|
||||
>
|
||||
<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>
|
||||
<DynamicVideoPlayer
|
||||
className={`w-full ${grow}`}
|
||||
camera={mainCamera}
|
||||
timeRange={currentTimeRange}
|
||||
cameraPreviews={allPreviews ?? []}
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex justify-center gap-2 overflow-x-auto">
|
||||
{allCameras.map((cam) => {
|
||||
@ -330,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);
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user