Override default player controls (#10401)

* Override default player controls

* Improve mouse behavior
This commit is contained in:
Nicolas Mowen 2024-03-12 09:24:07 -06:00 committed by GitHub
parent a2b0ca07cc
commit 483a95b06b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 161 additions and 36 deletions

View File

@ -10,6 +10,16 @@ import { Recording } from "@/types/record";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { DynamicPlayback } from "@/types/playback"; import { DynamicPlayback } from "@/types/playback";
import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; import PreviewPlayer, { PreviewController } from "./PreviewPlayer";
import { isDesktop, isMobile } 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"; type PlayerMode = "playback" | "scrubbing";
@ -21,20 +31,16 @@ type DynamicVideoPlayerProps = {
camera: string; camera: string;
timeRange: { start: number; end: number }; timeRange: { start: number; end: number };
cameraPreviews: Preview[]; cameraPreviews: Preview[];
previewOnly?: boolean;
startTime?: number; startTime?: number;
onControllerReady: (controller: DynamicVideoController) => void; onControllerReady: (controller: DynamicVideoController) => void;
onClick?: () => void;
}; };
export default function DynamicVideoPlayer({ export default function DynamicVideoPlayer({
className, className,
camera, camera,
timeRange, timeRange,
cameraPreviews, cameraPreviews,
previewOnly = false,
startTime, startTime,
onControllerReady, onControllerReady,
onClick,
}: DynamicVideoPlayerProps) { }: DynamicVideoPlayerProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -57,7 +63,9 @@ export default function DynamicVideoPlayer({
const [playerRef, setPlayerRef] = useState<Player | null>(null); const [playerRef, setPlayerRef] = useState<Player | null>(null);
const [previewController, setPreviewController] = const [previewController, setPreviewController] =
useState<PreviewController | null>(null); useState<PreviewController | null>(null);
const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [controls, setControls] = useState(false);
const [controlsOpen, setControlsOpen] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>( const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined, undefined,
); );
@ -71,7 +79,7 @@ export default function DynamicVideoPlayer({
playerRef, playerRef,
previewController, previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
previewOnly ? "scrubbing" : "playback", "playback",
setIsScrubbing, setIsScrubbing,
setFocusedItem, setFocusedItem,
); );
@ -96,7 +104,7 @@ export default function DynamicVideoPlayer({
const onKeyboardShortcut = useCallback( const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => { (key: string, down: boolean, repeat: boolean) => {
if (!playerRef || previewOnly) { if (!playerRef) {
return; return;
} }
@ -137,7 +145,7 @@ export default function DynamicVideoPlayer({
}, },
// only update when preview only changes // only update when preview only changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[playerRef, previewOnly], [playerRef],
); );
useKeyboardListener( useKeyboardListener(
["ArrowLeft", "ArrowRight", "m", " "], ["ArrowLeft", "ArrowRight", "m", " "],
@ -164,13 +172,6 @@ export default function DynamicVideoPlayer({
return; return;
} }
if (previewOnly) {
player.autoplay(false);
return;
}
player.autoplay(true);
if (!startTime) { if (!startTime) {
return; return;
} }
@ -189,7 +190,7 @@ export default function DynamicVideoPlayer({
}; };
// we only want to calculate this once // we only want to calculate this once
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewOnly, startTime, controller]); }, [startTime, controller]);
// state of playback player // state of playback player
@ -200,14 +201,12 @@ export default function DynamicVideoPlayer({
}; };
}, [timeRange]); }, [timeRange]);
const { data: recordings } = useSWR<Recording[]>( const { data: recordings } = useSWR<Recording[]>(
previewOnly && onClick == undefined [`${camera}/recordings`, recordingParams],
? null
: [`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
useEffect(() => { useEffect(() => {
if (!controller || (!previewOnly && !recordings)) { if (!controller || !recordings) {
return; return;
} }
@ -224,26 +223,39 @@ export default function DynamicVideoPlayer({
return ( return (
<div <div
className={`relative ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`} className={`relative ${className ?? ""} cursor-pointer`}
onClick={onClick} onMouseOver={
> isDesktop
<div ? () => {
className={`w-full relative ${ setControls(true);
previewOnly || isScrubbing ? "hidden" : "visible" }
}`} : undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
onClick={
isMobile
? (e) => {
e.stopPropagation();
setControls(!controls);
}
: undefined
}
> >
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
<VideoPlayer <VideoPlayer
options={{ options={{
preload: "auto", preload: "auto",
autoplay: false, autoplay: true,
sources: [initialPlaybackSource], sources: [initialPlaybackSource],
aspectRatio: wideVideo ? undefined : "16:9", aspectRatio: wideVideo ? undefined : "16:9",
controlBar: { controls: false,
remainingTimeDisplay: false, nativeControlsForTouch: true,
progressControl: {
seekBar: false,
},
},
}} }}
seekOptions={{ forward: 10, backward: 5 }} seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => { onReady={(player) => {
@ -260,6 +272,12 @@ export default function DynamicVideoPlayer({
/> />
)} )}
</VideoPlayer> </VideoPlayer>
<PlayerControls
player={playerRef}
show={controls}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
/>
</div> </div>
<PreviewPlayer <PreviewPlayer
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`} className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
@ -269,7 +287,6 @@ export default function DynamicVideoPlayer({
onControllerReady={(previewController) => { onControllerReady={(previewController) => {
setPreviewController(previewController); setPreviewController(previewController);
}} }}
onClick={onClick}
/> />
</div> </div>
); );
@ -441,3 +458,111 @@ export class DynamicVideoController {
); );
} }
} }
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

@ -161,7 +161,7 @@ export default function PreviewThumbnailPlayer({
return ( return (
<div <div
className="relative size-full cursor-pointer" className="relative size-full cursor-pointer"
onMouseEnter={isMobile ? undefined : () => setIsHovered(true)} onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();