UI tweaks and fixes (#22448)

* fix double scrollbar in debug replay

* always hide ffmpeg cpu warnings for replay cameras

* add slovenian

* fix motion previews on safari and ios

match the logic used in ScrubbablePreview for manually stepping currentTime at the correct rate

* prevent motion recalibration when opening motion tuner
This commit is contained in:
Josh Hawkins
2026-03-15 07:26:23 -05:00
committed by GitHub
parent 687fefb343
commit 310b5dfe05
6 changed files with 74 additions and 56 deletions
+45 -44
View File
@@ -9,6 +9,7 @@ import {
useState,
} from "react";
import { isCurrentHour } from "@/utils/dateUtil";
import { isFirefox, isMobile, isSafari } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { CameraConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@@ -305,31 +306,46 @@ function MotionPreviewClip({
);
}, [clipStart, preview, range.end_time]);
const compatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
if (compatIntervalRef.current) {
clearInterval(compatIntervalRef.current);
}
};
}, []);
const resetPlayback = useCallback(() => {
if (!videoRef.current || !preview) {
return;
}
if (compatIntervalRef.current) {
clearInterval(compatIntervalRef.current);
compatIntervalRef.current = null;
}
videoRef.current.currentTime = clipStart;
videoRef.current.playbackRate = playbackRate;
}, [clipStart, playbackRate, preview]);
useEffect(() => {
if (!videoRef.current || !preview) {
return;
}
if (!isVisible) {
if (isSafari || (isFirefox && isMobile)) {
// Safari / iOS can't play at speeds > 2x, so manually step through frames
videoRef.current.pause();
videoRef.current.currentTime = clipStart;
return;
}
compatIntervalRef.current = setInterval(() => {
if (!videoRef.current) {
return;
}
if (videoRef.current.readyState >= 2) {
resetPlayback();
void videoRef.current.play().catch(() => undefined);
videoRef.current.currentTime += 1;
if (videoRef.current.currentTime >= clipEnd) {
videoRef.current.currentTime = clipStart;
}
}, 1000 / playbackRate);
} else {
videoRef.current.playbackRate = playbackRate;
}
}, [clipStart, isVisible, preview, resetPlayback]);
}, [clipStart, clipEnd, playbackRate, preview]);
const drawDimOverlay = useCallback(() => {
if (!dimOverlayCanvasRef.current) {
@@ -463,15 +479,17 @@ function MotionPreviewClip({
{showLoadingIndicator && (
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
)}
{preview ? (
{preview && isVisible ? (
<>
<video
ref={videoRef}
className="size-full bg-black object-contain"
preload="auto"
autoPlay
playsInline
preload={isVisible ? "metadata" : "none"}
muted
autoPlay={isVisible}
disableRemotePlayback
loop
onLoadedMetadata={() => {
setVideoLoaded(true);
@@ -481,36 +499,21 @@ function MotionPreviewClip({
height: videoRef.current.videoHeight,
});
}
if (!isVisible) {
return;
}
resetPlayback();
if (videoRef.current) {
void videoRef.current.play().catch(() => undefined);
}
}}
onCanPlay={() => {
setVideoLoaded(true);
if (!isVisible) {
return;
}
if (videoRef.current) {
void videoRef.current.play().catch(() => undefined);
}
}}
onPlay={() => setVideoPlaying(true)}
onPlay={() => {
setVideoPlaying(true);
resetPlayback();
}}
onLoadedData={() => setVideoLoaded(true)}
onError={() => {
setVideoLoaded(true);
setVideoPlaying(true);
}}
onTimeUpdate={() => {
if (!videoRef.current || !preview || !isVisible) {
if (!videoRef.current || !preview) {
return;
}
@@ -519,12 +522,10 @@ function MotionPreviewClip({
}
}}
>
{isVisible && (
<source
src={`${baseUrl}${preview.src.substring(1)}`}
type={preview.type}
/>
)}
<source
src={`${baseUrl}${preview.src.substring(1)}`}
type={preview.type}
/>
</video>
{motionHeatmap && (
<canvas
+12 -4
View File
@@ -4,7 +4,7 @@ import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import {
@@ -63,6 +63,8 @@ export default function MotionTunerView({
improve_contrast: undefined,
});
const userInteractedRef = useRef(false);
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
@@ -70,6 +72,7 @@ export default function MotionTunerView({
}, [config, selectedCamera]);
useEffect(() => {
userInteractedRef.current = false;
if (cameraConfig) {
setMotionSettings({
threshold: cameraConfig.motion.threshold,
@@ -87,24 +90,29 @@ export default function MotionTunerView({
}, [selectedCamera]);
useEffect(() => {
if (!motionSettings.threshold) return;
if (!motionSettings.threshold || !userInteractedRef.current) return;
sendMotionThreshold(motionSettings.threshold);
}, [motionSettings.threshold, sendMotionThreshold]);
useEffect(() => {
if (!motionSettings.contour_area) return;
if (!motionSettings.contour_area || !userInteractedRef.current) return;
sendMotionContourArea(motionSettings.contour_area);
}, [motionSettings.contour_area, sendMotionContourArea]);
useEffect(() => {
if (motionSettings.improve_contrast === undefined) return;
if (
motionSettings.improve_contrast === undefined ||
!userInteractedRef.current
)
return;
sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF");
}, [motionSettings.improve_contrast, sendImproveContrast]);
const handleMotionConfigChange = (newConfig: Partial<MotionSettings>) => {
userInteractedRef.current = true;
setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
setUnsavedChanges(true);
setChangedValue(true);