Dynamically scale the slider height when hovering + other UI tweaks (#11042)

* Make no thumb slider height dynamic

* Use existing switch component

* Use existing switch component for general filter content

* Show message when no reordings found for time

* Don't show while scrubbing

* Fix key error

* Fix background color for controls on motion page
This commit is contained in:
Nicolas Mowen 2024-04-19 16:12:03 -06:00 committed by GitHub
parent fe4fb645d3
commit bfefed4d6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 77 additions and 103 deletions

View File

@ -593,40 +593,26 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => ( {allLabels.map((item) => (
<div className="flex justify-between items-center"> <FilterSwitch
<Label label={item.replaceAll("_", " ")}
className="w-full mx-2 text-primary capitalize cursor-pointer" isChecked={currentLabels?.includes(item) ?? false}
htmlFor={item} onCheckedChange={(isChecked) => {
> if (isChecked) {
{item.replaceAll("_", " ")} const updatedLabels = currentLabels ? [...currentLabels] : [];
</Label>
<Switch
key={item}
className="ml-1"
id={item}
checked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
updatedLabels.push(item); updatedLabels.push(item);
setCurrentLabels(updatedLabels);
} else {
const updatedLabels = currentLabels ? [...currentLabels] : [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentLabels(updatedLabels); setCurrentLabels(updatedLabels);
} else {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentLabels(updatedLabels);
}
} }
}} }
/> }}
</div> />
))} ))}
</div> </div>
</div> </div>

View File

@ -520,6 +520,7 @@ export function VideoPreview({
const onStopManualSeek = useCallback(() => { const onStopManualSeek = useCallback(() => {
setTimeout(() => { setTimeout(() => {
setIgnoreClick(false); setIgnoreClick(false);
setHoverTimeout(undefined);
if (isSafari || (isFirefox && isMobile)) { if (isSafari || (isFirefox && isMobile)) {
setManualPlayback(true); setManualPlayback(true);
@ -565,7 +566,7 @@ export function VideoPreview({
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30" className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${hoverTimeout != undefined ? "h-4" : "h-2"}`}
value={[progress]} value={[progress]}
onValueChange={onManualSeek} onValueChange={onManualSeek}
onValueCommit={onStopManualSeek} onValueCommit={onStopManualSeek}
@ -740,7 +741,7 @@ export function InProgressPreview({
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30" className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${manualFrame ? "h-4" : "h-2"}`}
value={[key]} value={[key]}
onValueChange={onManualSeek} onValueChange={onManualSeek}
onValueCommit={onStopManualSeek} onValueCommit={onStopManualSeek}

View File

@ -1,7 +1,7 @@
import { Recording } from "@/types/record"; import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback"; import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewPlayer"; import { PreviewController } from "../PreviewPlayer";
import { Timeline } from "@/types/timeline"; import { TimeRange, Timeline } from "@/types/timeline";
type PlayerMode = "playback" | "scrubbing"; type PlayerMode = "playback" | "scrubbing";
@ -10,11 +10,13 @@ export class DynamicVideoController {
public camera = ""; public camera = "";
private playerController: HTMLVideoElement; private playerController: HTMLVideoElement;
private previewController: PreviewController; private previewController: PreviewController;
private setNoRecording: (noRecs: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void; private setFocusedItem: (timeline: Timeline) => void;
private playerMode: PlayerMode = "playback"; private playerMode: PlayerMode = "playback";
// playback // playback
private recordings: Recording[] = []; private recordings: Recording[] = [];
private timeRange: TimeRange = { after: 0, before: 0 };
private annotationOffset: number; private annotationOffset: number;
private timeToStart: number | undefined = undefined; private timeToStart: number | undefined = undefined;
@ -24,6 +26,7 @@ export class DynamicVideoController {
previewController: PreviewController, previewController: PreviewController,
annotationOffset: number, annotationOffset: number,
defaultMode: PlayerMode, defaultMode: PlayerMode,
setNoRecording: (noRecs: boolean) => void,
setFocusedItem: (timeline: Timeline) => void, setFocusedItem: (timeline: Timeline) => void,
) { ) {
this.camera = camera; this.camera = camera;
@ -31,11 +34,13 @@ export class DynamicVideoController {
this.previewController = previewController; this.previewController = previewController;
this.annotationOffset = annotationOffset; this.annotationOffset = annotationOffset;
this.playerMode = defaultMode; this.playerMode = defaultMode;
this.setNoRecording = setNoRecording;
this.setFocusedItem = setFocusedItem; this.setFocusedItem = setFocusedItem;
} }
newPlayback(newPlayback: DynamicPlayback) { newPlayback(newPlayback: DynamicPlayback) {
this.recordings = newPlayback.recordings; this.recordings = newPlayback.recordings;
this.timeRange = newPlayback.timeRange;
if (this.timeToStart) { if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart); this.seekToTimestamp(this.timeToStart);
@ -52,12 +57,17 @@ export class DynamicVideoController {
} }
seekToTimestamp(time: number, play: boolean = false) { seekToTimestamp(time: number, play: boolean = false) {
if (time < this.timeRange.after || time > this.timeRange.before) {
this.timeToStart = time;
return;
}
if ( if (
this.recordings.length == 0 || this.recordings.length == 0 ||
time < this.recordings[0].start_time || time < this.recordings[0].start_time ||
time > this.recordings[this.recordings.length - 1].end_time time > this.recordings[this.recordings.length - 1].end_time
) { ) {
this.timeToStart = time; this.setNoRecording(true);
return; return;
} }
@ -90,6 +100,8 @@ export class DynamicVideoController {
} else { } else {
this.playerController.pause(); this.playerController.pause();
} }
} else {
console.log(`seek time is 0`);
} }
} }

View File

@ -45,6 +45,7 @@ export default function DynamicVideoPlayer({
const playerRef = useRef<HTMLVideoElement | null>(null); const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] = const [previewController, setPreviewController] =
useState<PreviewController | null>(null); useState<PreviewController | null>(null);
const [noRecording, setNoRecording] = useState(false);
const controller = useMemo(() => { const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) { if (!config || !playerRef.current || !previewController) {
return undefined; return undefined;
@ -56,6 +57,7 @@ export default function DynamicVideoPlayer({
previewController, previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
isScrubbing ? "scrubbing" : "playback", isScrubbing ? "scrubbing" : "playback",
setNoRecording,
() => {}, () => {},
); );
// we only want to fire once when players are ready // we only want to fire once when players are ready
@ -92,9 +94,11 @@ export default function DynamicVideoPlayer({
return () => { return () => {
if (loadingTimeout) { if (loadingTimeout) {
clearTimeout(loadingTimeout) clearTimeout(loadingTimeout);
} }
} };
// we only want trigger when scrubbing state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, isScrubbing]); }, [camera, isScrubbing]);
const onPlayerLoaded = useCallback(() => { const onPlayerLoaded = useCallback(() => {
@ -149,6 +153,7 @@ export default function DynamicVideoPlayer({
controller.newPlayback({ controller.newPlayback({
recordings: recordings ?? [], recordings: recordings ?? [],
timeRange,
}); });
// we only want this to change when recordings update // we only want this to change when recordings update
@ -175,6 +180,7 @@ export default function DynamicVideoPlayer({
} }
setIsLoading(false); setIsLoading(false);
setNoRecording(false);
}} }}
/> />
<PreviewPlayer <PreviewPlayer
@ -188,9 +194,14 @@ export default function DynamicVideoPlayer({
setPreviewController(previewController); setPreviewController(previewController);
}} }}
/> />
{isLoading && ( {isLoading && !noRecording && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
{!isScrubbing && noRecording && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No recordings found for this time
</div>
)}
</> </>
); );
} }

View File

@ -55,7 +55,7 @@ const NoThumbSlider = React.forwardRef<
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full"> <SliderPrimitive.Track className="relative h-full w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-selected" /> <SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" /> <SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />

View File

@ -4,6 +4,7 @@ import { TimeRange } from "./timeline";
export type DynamicPlayback = { export type DynamicPlayback = {
recordings: Recording[]; recordings: Recording[];
timeRange: TimeRange;
}; };
export type PreviewPlayback = { export type PreviewPlayback = {

View File

@ -591,7 +591,9 @@ function DetectionReview({
}) })
: Array(itemsToReview) : Array(itemsToReview)
.fill(0) .fill(0)
.map(() => <Skeleton className="size-full aspect-video" />)} .map((_, idx) => (
<Skeleton key={idx} className="size-full aspect-video" />
))}
{!loading && {!loading &&
(currentItems?.length ?? 0) > 0 && (currentItems?.length ?? 0) > 0 &&
(itemsToReview ?? 0) > 0 && ( (itemsToReview ?? 0) > 0 && (
@ -953,7 +955,7 @@ function MotionReview({
{!scrubbing && ( {!scrubbing && (
<VideoControls <VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2" className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{ features={{
volume: false, volume: false,
seek: true, seek: true,

View File

@ -6,6 +6,7 @@ import {
useSnapshotsState, useSnapshotsState,
} from "@/api/ws"; } from "@/api/ws";
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle"; import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
@ -15,8 +16,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
@ -623,67 +622,29 @@ function FrigateCameraFeatures({
/> />
</DrawerTrigger> </DrawerTrigger>
<DrawerContent className="px-2 py-4 flex flex-col gap-3 rounded-2xl"> <DrawerContent className="px-2 py-4 flex flex-col gap-3 rounded-2xl">
<div className="flex justify-between items-center gap-1"> <FilterSwitch
<Label label="Object Detection"
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer" isChecked={detectState == "ON"}
htmlFor={"camera-detect"} onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
> />
Object Detection <FilterSwitch
</Label> label="Recording"
<Switch isChecked={recordState == "ON"}
id={"camera-detect"} onCheckedChange={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
checked={detectState == "ON"} />
onCheckedChange={() => <FilterSwitch
sendDetect(detectState == "ON" ? "OFF" : "ON") label="Snapshots"
} isChecked={snapshotState == "ON"}
/> onCheckedChange={() =>
</div> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
<div className="flex justify-between items-center gap-1"> }
<Label />
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-record"}
>
Recording
</Label>
<Switch
id={"camera-record"}
checked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
</div>
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-snapshot"}
>
Snapshots
</Label>
<Switch
id={"camera-snapshot"}
checked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
</div>
{audioDetectEnabled && ( {audioDetectEnabled && (
<div className="flex justify-between items-center gap-1"> <FilterSwitch
<Label label="Audio Detection"
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer" isChecked={audioState == "ON"}
htmlFor={"camera-audio-detect"} onCheckedChange={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
> />
Audio Detection
</Label>
<Switch
id={"camera-audio-detect"}
checked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
</div>
)} )}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>