UI Improvements (#10972)

* Update web deps

* Fix tooltip on storage page

* Always show video controls even when zooming

* Get video controls working when video is paused

* Fix control hovering

* Add loading indicator to logs tab

* Show metrics correctly when hovering graph

* Show loading indicators for previews on recordings page

* Remove vitest update

* remove unused

* Make volume props optional
This commit is contained in:
Nicolas Mowen
2024-04-14 10:14:10 -06:00
committed by GitHub
parent a3e2171675
commit 7f424bb3f8
11 changed files with 234 additions and 205 deletions

View File

@@ -92,6 +92,9 @@ export function ThresholdBarGraph({
},
tooltip: {
theme: systemTheme || theme,
y: {
formatter: (val) => `${val}${unit}`,
},
},
markers: {
size: 0,
@@ -118,7 +121,7 @@ export function ThresholdBarGraph({
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, threshold, systemTheme, theme, formatTime]);
}, [graphId, threshold, unit, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
@@ -190,7 +193,7 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
},
},
tooltip: {
show: false,
enabled: false,
},
xaxis: {
axisBorder: {

View File

@@ -1,9 +1,9 @@
import { LuLoader2 } from "react-icons/lu";
export default function ActivityIndicator({ size = 30 }) {
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
return (
<div
className="w-full flex items-center justify-center"
className={`flex items-center justify-center ${className}`}
aria-label="Loading…"
>
<LuLoader2 className="animate-spin" size={size} />

View File

@@ -1,10 +1,4 @@
import {
MutableRefObject,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { MutableRefObject, useEffect, useRef, useState } from "react";
import Hls from "hls.js";
import { isAndroid, isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
@@ -19,7 +13,6 @@ const unsupportedErrorCodes = [
];
type HlsVideoPlayerProps = {
children?: ReactNode;
videoRef: MutableRefObject<HTMLVideoElement | null>;
visible: boolean;
currentSource: string;
@@ -30,7 +23,6 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void;
};
export default function HlsVideoPlayer({
children,
videoRef,
visible,
currentSource,
@@ -83,19 +75,88 @@ export default function HlsVideoPlayer({
// controls
const [isPlaying, setIsPlaying] = useState(true);
const [muted, setMuted] = useState(true);
const [volume, setVolume] = useState(1.0);
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false);
useEffect(() => {
if (!isDesktop) {
return;
}
const callback = (e: MouseEvent) => {
if (!videoRef.current) {
return;
}
const rect = videoRef.current.getBoundingClientRect();
if (
e.clientX > rect.left &&
e.clientX < rect.right &&
e.clientY > rect.top &&
e.clientY < rect.bottom
) {
setControls(true);
} else {
setControls(controlsOpen);
}
};
window.addEventListener("mousemove", callback);
return () => {
window.removeEventListener("mousemove", callback);
};
}, [videoRef, controlsOpen]);
return (
<TransformWrapper minScale={1.0}>
<VideoControls
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
video={videoRef.current}
isPlaying={isPlaying}
show={visible && controls}
muted={muted}
volume={volume}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
setMuted={setMuted}
playbackRate={videoRef.current?.playbackRate ?? 1}
hotKeys={hotKeys}
onPlayPause={(play) => {
if (!videoRef.current) {
return;
}
if (play) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}}
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
}
/>
<TransformComponent
wrapperStyle={{
position: "relative",
display: visible ? undefined : "none",
width: "100%",
height: "100%",
}}
wrapperProps={{
onClick: isDesktop ? undefined : () => setControls(!controls),
}}
contentStyle={{
width: "100%",
height: isMobile ? "100%" : undefined,
@@ -108,7 +169,8 @@ export default function HlsVideoPlayer({
autoPlay
controls={false}
playsInline
muted
muted={muted}
onVolumeChange={() => setVolume(videoRef.current?.volume ?? 1.0)}
onPlay={() => {
setIsPlaying(true);
@@ -145,61 +207,6 @@ export default function HlsVideoPlayer({
}
}}
/>
<div
className="absolute inset-0"
onMouseOver={
isDesktop
? () => {
setControls(true);
}
: undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
onClick={isDesktop ? undefined : () => setControls(!controls)}
>
<div className={`size-full relative ${visible ? "" : "hidden"}`}>
<VideoControls
className="absolute bottom-5 left-1/2 -translate-x-1/2"
video={videoRef.current}
isPlaying={isPlaying}
show={controls}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
playbackRate={videoRef.current?.playbackRate ?? 1}
hotKeys={hotKeys}
onPlayPause={(play) => {
if (!videoRef.current) {
return;
}
if (play) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}}
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
}
/>
{children}
</div>
</div>
</TransformComponent>
</TransformWrapper>
);

View File

@@ -14,6 +14,7 @@ import { isCurrentHour } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline";
import { Skeleton } from "../ui/skeleton";
type PreviewPlayerProps = {
className?: string;
@@ -143,6 +144,8 @@ function PreviewVideoPlayer({
// initial state
const [firstLoad, setFirstLoad] = useState(true);
const initialPreview = useMemo(() => {
return cameraPreviews.find(
(preview) =>
@@ -253,6 +256,10 @@ function PreviewVideoPlayer({
disableRemotePlayback
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
if (controller) {
controller.previewReady();
} else {
@@ -280,6 +287,7 @@ function PreviewVideoPlayer({
No Preview Found
</div>
)}
{firstLoad && <Skeleton className="absolute size-full aspect-video" />}
</div>
);
}
@@ -427,6 +435,8 @@ function PreviewFramesPlayer({
// initial state
const [firstLoad, setFirstLoad] = useState(true);
useEffect(() => {
if (!controller) {
return;
@@ -441,6 +451,8 @@ function PreviewFramesPlayer({
}, [controller]);
const onImageLoaded = useCallback(() => {
setFirstLoad(false);
if (!controller) {
return;
}
@@ -477,6 +489,7 @@ function PreviewFramesPlayer({
No Preview Found
</div>
)}
{firstLoad && <Skeleton className="absolute size-full aspect-video" />}
</div>
);
}

View File

@@ -38,11 +38,14 @@ type VideoControlsProps = {
features?: VideoControls;
isPlaying: boolean;
show: boolean;
muted?: boolean;
volume?: number;
controlsOpen?: boolean;
playbackRates?: number[];
playbackRate: number;
hotKeys?: boolean;
setControlsOpen?: (open: boolean) => void;
setMuted?: (muted: boolean) => void;
onPlayPause: (play: boolean) => void;
onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void;
@@ -53,11 +56,14 @@ export default function VideoControls({
features = CONTROLS_DEFAULT,
isPlaying,
show,
muted,
volume,
controlsOpen,
playbackRates = PLAYBACK_RATE_DEFAULT,
playbackRate,
hotKeys = true,
setControlsOpen,
setMuted,
onPlayPause,
onSeek,
onSetPlaybackRate,
@@ -89,18 +95,18 @@ export default function VideoControls({
// volume control
const VolumeIcon = useMemo(() => {
if (!video || video?.muted) {
if (!volume || volume == 0.0 || muted) {
return MdVolumeOff;
} else if (video.volume <= 0.33) {
} else if (volume <= 0.33) {
return MdVolumeMute;
} else if (video.volume <= 0.67) {
} else if (volume <= 0.67) {
return MdVolumeDown;
} else {
return MdVolumeUp;
}
// only update when specific fields change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.volume, video?.muted]);
}, [volume, muted]);
const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => {
@@ -116,8 +122,8 @@ export default function VideoControls({
}
break;
case "m":
if (down && !repeat && video) {
video.muted = !video.muted;
if (setMuted && down && !repeat && video) {
setMuted(!muted);
}
break;
case " ":
@@ -150,13 +156,16 @@ export default function VideoControls({
className="size-5"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
video.muted = !video.muted;
if (setMuted) {
setMuted(!muted);
}
}}
/>
{video.muted == false && (
{muted == false && (
<VolumeSlider
className="w-20"
value={[video.volume]}
value={[volume ?? 1.0]}
min={0}
max={1}
step={0.02}
@@ -193,7 +202,11 @@ export default function VideoControls({
onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))}
>
{playbackRates.map((rate) => (
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
<DropdownMenuRadioItem
key={rate}
className="cursor-pointer"
value={rate.toString()}
>
{rate}x
</DropdownMenuRadioItem>
))}

View File

@@ -1,5 +1,4 @@
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";
@@ -8,7 +7,7 @@ import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer";
import { TimeRange, Timeline } from "@/types/timeline";
import { TimeRange } from "@/types/timeline";
/**
* Dynamically switches between video playback and scrubbing preview player.
@@ -45,9 +44,6 @@ export default function DynamicVideoPlayer({
const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] =
useState<PreviewController | null>(null);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined,
);
const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) {
return undefined;
@@ -59,7 +55,7 @@ export default function DynamicVideoPlayer({
previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
isScrubbing ? "scrubbing" : "playback",
setFocusedItem,
() => {},
);
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -164,14 +160,7 @@ export default function DynamicVideoPlayer({
setIsLoading(false);
}}
>
{config && focusedItem && (
<TimelineEventOverlay
timeline={focusedItem}
cameraConfig={config.cameras[camera]}
/>
)}
</HlsVideoPlayer>
/>
<PreviewPlayer
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
camera={camera}

View File

@@ -11,6 +11,7 @@ import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator";
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
type LogType = (typeof logTypes)[number];
@@ -388,7 +389,7 @@ function Logs() {
</Button>
)}
<div className="size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
<div className="relative size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
<div className="p-1 flex items-center capitalize">Type</div>
<div className="col-span-2 sm:col-span-1 flex items-center">
@@ -443,6 +444,9 @@ function Logs() {
})}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</div>
{logLines.length == 0 && (
<ActivityIndicator className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2" />
)}
</div>
</div>
);

View File

@@ -364,11 +364,11 @@ export function RecordingView({
>
<div
key={mainCamera}
className={
className={`relative ${
isDesktop
? `${mainCameraAspect == "tall" ? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
}
}`}
style={{
aspectRatio: isDesktop
? mainCameraAspect == "tall"

View File

@@ -182,7 +182,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] };
}
series[key].data.push({ x: statsIdx + 1, y: stats.gpu });
series[key].data.push({ x: statsIdx + 1, y: stats.gpu.slice(0, -1) });
});
});
return Object.keys(series).length > 0 ? Object.values(series) : [];
@@ -215,7 +215,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] };
}
series[key].data.push({ x: statsIdx + 1, y: stats.mem });
series[key].data.push({ x: statsIdx + 1, y: stats.mem.slice(0, -1) });
});
});
return Object.values(series);
@@ -373,7 +373,7 @@ export default function GeneralMetrics({
key={series.name}
graphId={`${series.name}-gpu`}
name={series.name}
unit=""
unit="%"
threshold={GPUUsageThreshold}
updateTimes={updateTimes}
data={[series]}
@@ -392,7 +392,7 @@ export default function GeneralMetrics({
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-mem`}
unit=""
unit="%"
name={series.name}
threshold={GPUMemThreshold}
updateTimes={updateTimes}