mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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:
		
							parent
							
								
									fe4fb645d3
								
							
						
					
					
						commit
						bfefed4d6e
					
				@ -593,30 +593,17 @@ export function GeneralFilterContent({
 | 
			
		||||
        <DropdownMenuSeparator />
 | 
			
		||||
        <div className="my-2.5 flex flex-col gap-2.5">
 | 
			
		||||
          {allLabels.map((item) => (
 | 
			
		||||
            <div className="flex justify-between items-center">
 | 
			
		||||
              <Label
 | 
			
		||||
                className="w-full mx-2 text-primary capitalize cursor-pointer"
 | 
			
		||||
                htmlFor={item}
 | 
			
		||||
              >
 | 
			
		||||
                {item.replaceAll("_", " ")}
 | 
			
		||||
              </Label>
 | 
			
		||||
              <Switch
 | 
			
		||||
                key={item}
 | 
			
		||||
                className="ml-1"
 | 
			
		||||
                id={item}
 | 
			
		||||
                checked={currentLabels?.includes(item) ?? false}
 | 
			
		||||
            <FilterSwitch
 | 
			
		||||
              label={item.replaceAll("_", " ")}
 | 
			
		||||
              isChecked={currentLabels?.includes(item) ?? false}
 | 
			
		||||
              onCheckedChange={(isChecked) => {
 | 
			
		||||
                if (isChecked) {
 | 
			
		||||
                    const updatedLabels = currentLabels
 | 
			
		||||
                      ? [...currentLabels]
 | 
			
		||||
                      : [];
 | 
			
		||||
                  const updatedLabels = currentLabels ? [...currentLabels] : [];
 | 
			
		||||
 | 
			
		||||
                  updatedLabels.push(item);
 | 
			
		||||
                  setCurrentLabels(updatedLabels);
 | 
			
		||||
                } else {
 | 
			
		||||
                    const updatedLabels = currentLabels
 | 
			
		||||
                      ? [...currentLabels]
 | 
			
		||||
                      : [];
 | 
			
		||||
                  const updatedLabels = currentLabels ? [...currentLabels] : [];
 | 
			
		||||
 | 
			
		||||
                  // can not deselect the last item
 | 
			
		||||
                  if (updatedLabels.length > 1) {
 | 
			
		||||
@ -626,7 +613,6 @@ export function GeneralFilterContent({
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
            </div>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -520,6 +520,7 @@ export function VideoPreview({
 | 
			
		||||
  const onStopManualSeek = useCallback(() => {
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      setIgnoreClick(false);
 | 
			
		||||
      setHoverTimeout(undefined);
 | 
			
		||||
 | 
			
		||||
      if (isSafari || (isFirefox && isMobile)) {
 | 
			
		||||
        setManualPlayback(true);
 | 
			
		||||
@ -565,7 +566,7 @@ export function VideoPreview({
 | 
			
		||||
      {showProgress && (
 | 
			
		||||
        <NoThumbSlider
 | 
			
		||||
          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]}
 | 
			
		||||
          onValueChange={onManualSeek}
 | 
			
		||||
          onValueCommit={onStopManualSeek}
 | 
			
		||||
@ -740,7 +741,7 @@ export function InProgressPreview({
 | 
			
		||||
      {showProgress && (
 | 
			
		||||
        <NoThumbSlider
 | 
			
		||||
          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]}
 | 
			
		||||
          onValueChange={onManualSeek}
 | 
			
		||||
          onValueCommit={onStopManualSeek}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { Recording } from "@/types/record";
 | 
			
		||||
import { DynamicPlayback } from "@/types/playback";
 | 
			
		||||
import { PreviewController } from "../PreviewPlayer";
 | 
			
		||||
import { Timeline } from "@/types/timeline";
 | 
			
		||||
import { TimeRange, Timeline } from "@/types/timeline";
 | 
			
		||||
 | 
			
		||||
type PlayerMode = "playback" | "scrubbing";
 | 
			
		||||
 | 
			
		||||
@ -10,11 +10,13 @@ export class DynamicVideoController {
 | 
			
		||||
  public camera = "";
 | 
			
		||||
  private playerController: HTMLVideoElement;
 | 
			
		||||
  private previewController: PreviewController;
 | 
			
		||||
  private setNoRecording: (noRecs: boolean) => void;
 | 
			
		||||
  private setFocusedItem: (timeline: Timeline) => void;
 | 
			
		||||
  private playerMode: PlayerMode = "playback";
 | 
			
		||||
 | 
			
		||||
  // playback
 | 
			
		||||
  private recordings: Recording[] = [];
 | 
			
		||||
  private timeRange: TimeRange = { after: 0, before: 0 };
 | 
			
		||||
  private annotationOffset: number;
 | 
			
		||||
  private timeToStart: number | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
@ -24,6 +26,7 @@ export class DynamicVideoController {
 | 
			
		||||
    previewController: PreviewController,
 | 
			
		||||
    annotationOffset: number,
 | 
			
		||||
    defaultMode: PlayerMode,
 | 
			
		||||
    setNoRecording: (noRecs: boolean) => void,
 | 
			
		||||
    setFocusedItem: (timeline: Timeline) => void,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.camera = camera;
 | 
			
		||||
@ -31,11 +34,13 @@ export class DynamicVideoController {
 | 
			
		||||
    this.previewController = previewController;
 | 
			
		||||
    this.annotationOffset = annotationOffset;
 | 
			
		||||
    this.playerMode = defaultMode;
 | 
			
		||||
    this.setNoRecording = setNoRecording;
 | 
			
		||||
    this.setFocusedItem = setFocusedItem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  newPlayback(newPlayback: DynamicPlayback) {
 | 
			
		||||
    this.recordings = newPlayback.recordings;
 | 
			
		||||
    this.timeRange = newPlayback.timeRange;
 | 
			
		||||
 | 
			
		||||
    if (this.timeToStart) {
 | 
			
		||||
      this.seekToTimestamp(this.timeToStart);
 | 
			
		||||
@ -52,12 +57,17 @@ export class DynamicVideoController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  seekToTimestamp(time: number, play: boolean = false) {
 | 
			
		||||
    if (time < this.timeRange.after || time > this.timeRange.before) {
 | 
			
		||||
      this.timeToStart = time;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      this.recordings.length == 0 ||
 | 
			
		||||
      time < this.recordings[0].start_time ||
 | 
			
		||||
      time > this.recordings[this.recordings.length - 1].end_time
 | 
			
		||||
    ) {
 | 
			
		||||
      this.timeToStart = time;
 | 
			
		||||
      this.setNoRecording(true);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -90,6 +100,8 @@ export class DynamicVideoController {
 | 
			
		||||
      } else {
 | 
			
		||||
        this.playerController.pause();
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      console.log(`seek time is 0`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -45,6 +45,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
  const playerRef = useRef<HTMLVideoElement | null>(null);
 | 
			
		||||
  const [previewController, setPreviewController] =
 | 
			
		||||
    useState<PreviewController | null>(null);
 | 
			
		||||
  const [noRecording, setNoRecording] = useState(false);
 | 
			
		||||
  const controller = useMemo(() => {
 | 
			
		||||
    if (!config || !playerRef.current || !previewController) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
@ -56,6 +57,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
      previewController,
 | 
			
		||||
      (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
 | 
			
		||||
      isScrubbing ? "scrubbing" : "playback",
 | 
			
		||||
      setNoRecording,
 | 
			
		||||
      () => {},
 | 
			
		||||
    );
 | 
			
		||||
    // we only want to fire once when players are ready
 | 
			
		||||
@ -92,9 +94,11 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (loadingTimeout) {
 | 
			
		||||
        clearTimeout(loadingTimeout)
 | 
			
		||||
      }
 | 
			
		||||
        clearTimeout(loadingTimeout);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    // we only want trigger when scrubbing state changes
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [camera, isScrubbing]);
 | 
			
		||||
 | 
			
		||||
  const onPlayerLoaded = useCallback(() => {
 | 
			
		||||
@ -149,6 +153,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
 | 
			
		||||
    controller.newPlayback({
 | 
			
		||||
      recordings: recordings ?? [],
 | 
			
		||||
      timeRange,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // we only want this to change when recordings update
 | 
			
		||||
@ -175,6 +180,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          setIsLoading(false);
 | 
			
		||||
          setNoRecording(false);
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <PreviewPlayer
 | 
			
		||||
@ -188,9 +194,14 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
          setPreviewController(previewController);
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {isLoading && (
 | 
			
		||||
      {isLoading && !noRecording && (
 | 
			
		||||
        <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>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -55,7 +55,7 @@ const NoThumbSlider = React.forwardRef<
 | 
			
		||||
    )}
 | 
			
		||||
    {...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.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" />
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ import { TimeRange } from "./timeline";
 | 
			
		||||
 | 
			
		||||
export type DynamicPlayback = {
 | 
			
		||||
  recordings: Recording[];
 | 
			
		||||
  timeRange: TimeRange;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type PreviewPlayback = {
 | 
			
		||||
 | 
			
		||||
@ -591,7 +591,9 @@ function DetectionReview({
 | 
			
		||||
              })
 | 
			
		||||
            : Array(itemsToReview)
 | 
			
		||||
                .fill(0)
 | 
			
		||||
                .map(() => <Skeleton className="size-full aspect-video" />)}
 | 
			
		||||
                .map((_, idx) => (
 | 
			
		||||
                  <Skeleton key={idx} className="size-full aspect-video" />
 | 
			
		||||
                ))}
 | 
			
		||||
          {!loading &&
 | 
			
		||||
            (currentItems?.length ?? 0) > 0 &&
 | 
			
		||||
            (itemsToReview ?? 0) > 0 && (
 | 
			
		||||
@ -953,7 +955,7 @@ function MotionReview({
 | 
			
		||||
 | 
			
		||||
      {!scrubbing && (
 | 
			
		||||
        <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={{
 | 
			
		||||
            volume: false,
 | 
			
		||||
            seek: true,
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import {
 | 
			
		||||
  useSnapshotsState,
 | 
			
		||||
} from "@/api/ws";
 | 
			
		||||
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
 | 
			
		||||
import FilterSwitch from "@/components/filter/FilterSwitch";
 | 
			
		||||
import LivePlayer from "@/components/player/LivePlayer";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
 | 
			
		||||
@ -15,8 +16,6 @@ import {
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
} from "@/components/ui/dropdown-menu";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { Switch } from "@/components/ui/switch";
 | 
			
		||||
import { TooltipProvider } from "@/components/ui/tooltip";
 | 
			
		||||
import { useResizeObserver } from "@/hooks/resize-observer";
 | 
			
		||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
 | 
			
		||||
@ -623,67 +622,29 @@ function FrigateCameraFeatures({
 | 
			
		||||
        />
 | 
			
		||||
      </DrawerTrigger>
 | 
			
		||||
      <DrawerContent className="px-2 py-4 flex flex-col gap-3 rounded-2xl">
 | 
			
		||||
        <div className="flex justify-between items-center gap-1">
 | 
			
		||||
          <Label
 | 
			
		||||
            className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
 | 
			
		||||
            htmlFor={"camera-detect"}
 | 
			
		||||
          >
 | 
			
		||||
            Object Detection
 | 
			
		||||
          </Label>
 | 
			
		||||
          <Switch
 | 
			
		||||
            id={"camera-detect"}
 | 
			
		||||
            checked={detectState == "ON"}
 | 
			
		||||
            onCheckedChange={() =>
 | 
			
		||||
              sendDetect(detectState == "ON" ? "OFF" : "ON")
 | 
			
		||||
            }
 | 
			
		||||
        <FilterSwitch
 | 
			
		||||
          label="Object Detection"
 | 
			
		||||
          isChecked={detectState == "ON"}
 | 
			
		||||
          onCheckedChange={() => sendDetect(detectState == "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-record"}
 | 
			
		||||
          >
 | 
			
		||||
            Recording
 | 
			
		||||
          </Label>
 | 
			
		||||
          <Switch
 | 
			
		||||
            id={"camera-record"}
 | 
			
		||||
            checked={recordState == "ON"}
 | 
			
		||||
            onCheckedChange={() =>
 | 
			
		||||
              sendRecord(recordState == "ON" ? "OFF" : "ON")
 | 
			
		||||
            }
 | 
			
		||||
        <FilterSwitch
 | 
			
		||||
          label="Recording"
 | 
			
		||||
          isChecked={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"}
 | 
			
		||||
        <FilterSwitch
 | 
			
		||||
          label="Snapshots"
 | 
			
		||||
          isChecked={snapshotState == "ON"}
 | 
			
		||||
          onCheckedChange={() =>
 | 
			
		||||
            sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        </div>
 | 
			
		||||
        {audioDetectEnabled && (
 | 
			
		||||
          <div className="flex justify-between items-center gap-1">
 | 
			
		||||
            <Label
 | 
			
		||||
              className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
 | 
			
		||||
              htmlFor={"camera-audio-detect"}
 | 
			
		||||
            >
 | 
			
		||||
              Audio Detection
 | 
			
		||||
            </Label>
 | 
			
		||||
            <Switch
 | 
			
		||||
              id={"camera-audio-detect"}
 | 
			
		||||
              checked={audioState == "ON"}
 | 
			
		||||
              onCheckedChange={() =>
 | 
			
		||||
                sendAudio(audioState == "ON" ? "OFF" : "ON")
 | 
			
		||||
              }
 | 
			
		||||
          <FilterSwitch
 | 
			
		||||
            label="Audio Detection"
 | 
			
		||||
            isChecked={audioState == "ON"}
 | 
			
		||||
            onCheckedChange={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
 | 
			
		||||
          />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </DrawerContent>
 | 
			
		||||
    </Drawer>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user