Improve live streaming (#16447)

* config file changes

* config migrator

* stream selection on single camera live view

* camera streaming settings dialog

* manage persistent group streaming settings

* apply streaming settings in camera groups

* add ability to clear all streaming settings from settings

* docs

* update reference config

* fixes

* clarify docs

* use first stream as default in dialog

* ensure still image is visible after switching stream type to none

* docs

* clarify docs

* add ability to continue playing stream in background

* fix props

* put stream selection inside dropdown on desktop

* add capabilities to live mode hook

* live context menu component

* resize observer: only return new dimensions if they've actually changed

* pass volume prop to players

* fix slider bug, https://github.com/shadcn-ui/ui/issues/1448

* update react-grid-layout

* prevent animated transitions on draggable grid layout

* add context menu to dashboards

* use provider

* streaming dialog from context menu

* docs

* add jsmpeg warning to context menu

* audio and two way talk indicators in single camera view

* add link to debug view

* don't use hook

* create manual events from live camera view

* maintain grow classes on grid items

* fix initial volume state on default dashboard

* fix pointer events causing context menu to end up underneath image on iOS

* mobile drawer tweaks

* stream stats

* show settings menu for non-restreamed cameras

* consistent settings icon

* tweaks

* optional stats to fix birdseye player

* add toaster to live camera view

* fix crash on initial save in streaming dialog

* don't require restreaming for context menu streaming settings

* add debug view to context menu

* stats fixes

* update docs

* always show stream info when restreamed

* update camera streaming dialog

* make note of no h265 support for webrtc

* docs clarity

* ensure docs show streams as a dict

* docs clarity

* fix css file

* tweaks
This commit is contained in:
Josh Hawkins
2025-02-10 10:42:35 -06:00
committed by GitHub
parent 2a28964e63
commit dd7820e4ee
31 changed files with 2681 additions and 219 deletions

View File

@@ -40,9 +40,9 @@ export default function CameraFeatureToggle({
<div
onClick={onClick}
className={cn(
className,
"flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"],
className,
)}
>
<Icon

View File

@@ -1,4 +1,9 @@
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
import {
AllGroupsStreamingSettings,
CameraGroupConfig,
FrigateConfig,
GroupStreamingSettings,
} from "@/types/frigateConfig";
import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
@@ -43,7 +48,6 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import axios from "axios";
import FilterSwitch from "./FilterSwitch";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import IconWrapper from "../ui/icon-wrapper";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -66,6 +70,11 @@ import {
MobilePageHeader,
MobilePageTitle,
} from "../mobile/MobilePage";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type CameraGroupSelectorProps = {
className?: string;
@@ -607,6 +616,16 @@ export function CameraGroupEdit({
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const [groupStreamingSettings, setGroupStreamingSettings] =
useState<GroupStreamingSettings>(
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const formSchema = z.object({
@@ -656,6 +675,16 @@ export function CameraGroupEdit({
setIsLoading(true);
// update streaming settings
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== editingGroup?.[0],
),
),
[values.name]: groupStreamingSettings,
};
let renamingQuery = "";
if (editingGroup && editingGroup[0] !== values.name) {
renamingQuery = `camera_groups.${editingGroup[0]}&`;
@@ -679,7 +708,7 @@ export function CameraGroupEdit({
requires_restart: 0,
},
)
.then((res) => {
.then(async (res) => {
if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, {
position: "top-center",
@@ -688,6 +717,7 @@ export function CameraGroupEdit({
if (onSave) {
onSave();
}
setAllGroupsStreamingSettings(updatedSettings);
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
@@ -704,7 +734,16 @@ export function CameraGroupEdit({
setIsLoading(false);
});
},
[currentGroups, setIsLoading, onSave, updateConfig, editingGroup],
[
currentGroups,
setIsLoading,
onSave,
updateConfig,
editingGroup,
groupStreamingSettings,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
],
);
const form = useForm<z.infer<typeof formSchema>>({
@@ -762,16 +801,66 @@ export function CameraGroupEdit({
),
].map((camera) => (
<FormControl key={camera}>
<FilterSwitch
isChecked={field.value && field.value.includes(camera)}
label={camera.replaceAll("_", " ")}
onCheckedChange={(checked) => {
const updatedCameras = checked
? [...(field.value || []), camera]
: (field.value || []).filter((c) => c !== camera);
form.setValue("cameras", updatedCameras);
}}
/>
<div className="flex items-center justify-between gap-1">
<Label
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={camera.replaceAll("_", " ")}
>
{camera.replaceAll("_", " ")}
</Label>
<div className="flex items-center gap-x-2">
{camera !== "birdseye" && (
<Dialog
open={openCamera === camera}
onOpenChange={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
>
<DialogTrigger asChild>
<Button
className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings"
size="icon"
variant="ghost"
disabled={
!(field.value && field.value.includes(camera))
}
>
<LuIcons.LuSettings
className={cn(
field.value && field.value.includes(camera)
? "text-primary"
: "text-muted-foreground",
"size-5",
)}
/>
</Button>
</DialogTrigger>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={
setGroupStreamingSettings
}
setIsDialogOpen={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
/>
</Dialog>
)}
<Switch
id={camera.replaceAll("_", " ")}
checked={field.value && field.value.includes(camera)}
onCheckedChange={(checked) => {
const updatedCameras = checked
? [...(field.value || []), camera]
: (field.value || []).filter((c) => c !== camera);
form.setValue("cameras", updatedCameras);
}}
/>
</div>
</div>
</FormControl>
))}
</FormItem>

View File

@@ -0,0 +1,302 @@
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
MdVolumeDown,
MdVolumeMute,
MdVolumeOff,
MdVolumeUp,
} from "react-icons/md";
import { Dialog } from "@/components/ui/dialog";
import { VolumeSlider } from "@/components/ui/slider";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import {
AllGroupsStreamingSettings,
GroupStreamingSettings,
} from "@/types/frigateConfig";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { IoIosWarning } from "react-icons/io";
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
type LiveContextMenuProps = {
className?: string;
camera: string;
streamName: string;
cameraGroup?: string;
preferredLiveMode: string;
isRestreamed: boolean;
supportsAudio: boolean;
audioState: boolean;
toggleAudio: () => void;
volumeState?: number;
setVolumeState: (volumeState: number) => void;
muteAll: () => void;
unmuteAll: () => void;
statsState: boolean;
toggleStats: () => void;
resetPreferredLiveMode: () => void;
children?: ReactNode;
};
export default function LiveContextMenu({
className,
camera,
streamName,
cameraGroup,
preferredLiveMode,
isRestreamed,
supportsAudio,
audioState,
toggleAudio,
volumeState,
setVolumeState,
muteAll,
unmuteAll,
statsState,
toggleStats,
resetPreferredLiveMode,
children,
}: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false);
// streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const [groupStreamingSettings, setGroupStreamingSettings] =
useState<GroupStreamingSettings>(
allGroupsStreamingSettings[cameraGroup ?? ""],
);
useEffect(() => {
if (cameraGroup) {
setGroupStreamingSettings(allGroupsStreamingSettings[cameraGroup]);
}
// set individual group when all groups changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allGroupsStreamingSettings]);
const onSave = useCallback(
(settings: GroupStreamingSettings) => {
if (!cameraGroup || !allGroupsStreamingSettings) {
return;
}
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== cameraGroup,
),
),
[cameraGroup]: {
...Object.fromEntries(
Object.entries(settings).map(([cameraName, cameraSettings]) => [
cameraName,
cameraName === camera
? {
...cameraSettings,
playAudio: audioState ?? cameraSettings.playAudio ?? false,
volume: volumeState ?? cameraSettings.volume ?? 1,
}
: cameraSettings,
]),
),
// Add the current camera if it doesn't exist
...(!settings[camera]
? {
[camera]: {
streamName: streamName,
streamType: "smart",
compatibilityMode: false,
playAudio: audioState,
volume: volumeState ?? 1,
},
}
: {}),
},
};
setAllGroupsStreamingSettings?.(updatedSettings);
},
[
camera,
streamName,
cameraGroup,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
audioState,
volumeState,
],
);
// ui
const audioControlsUsed = useRef(false);
const VolumeIcon = useMemo(() => {
if (!volumeState || volumeState == 0.0 || !audioState) {
return MdVolumeOff;
} else if (volumeState <= 0.33) {
return MdVolumeMute;
} else if (volumeState <= 0.67) {
return MdVolumeDown;
} else {
return MdVolumeUp;
}
// only update when specific fields change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeState, audioState]);
const handleVolumeIconClick = (e: React.MouseEvent) => {
e.stopPropagation();
audioControlsUsed.current = true;
toggleAudio();
};
const handleVolumeChange = (value: number[]) => {
audioControlsUsed.current = true;
setVolumeState(value[0]);
};
const handleOpenChange = (open: boolean) => {
if (!open && audioControlsUsed.current) {
onSave(groupStreamingSettings);
audioControlsUsed.current = false;
}
};
// navigate for debug view
const navigate = useNavigate();
return (
<div className={cn("w-full", className)}>
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md capitalize text-primary-variant">
{camera.replaceAll("_", " ")}
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">Low-bandwidth mode</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
<ContextMenuSeparator className="mb-1" />
<div className="p-2 text-sm">
<div className="flex w-full flex-col gap-1">
<p>Audio</p>
<div className="flex flex-row items-center gap-1">
<VolumeIcon
className="size-5"
onClick={handleVolumeIconClick}
/>
<VolumeSlider
disabled={!audioState}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
max={1}
step={0.02}
onValueChange={handleVolumeChange}
/>
</div>
</div>
</div>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)}
>
<div className="text-primary">Debug View</div>
</div>
</ContextMenuItem>
{cameraGroup && cameraGroup !== "default" && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)}
>
<div className="text-primary">Streaming Settings</div>
</div>
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
>
<div className="text-primary">Reset</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
/>
</Dialog>
</div>
);
}

View File

@@ -673,7 +673,8 @@ export function ObjectSnapshotTab({
</TransformComponent>
{search.data.type == "object" &&
search.plus_id !== "not_enabled" &&
search.end_time && (
search.end_time &&
search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}>

View File

@@ -58,6 +58,7 @@ export default function BirdseyeLivePlayer({
height={birdseyeConfig.height}
containerRef={containerRef}
playbackEnabled={true}
useWebGL={true}
/>
);
} else {

View File

@@ -1,6 +1,7 @@
import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
// @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player";
import React, { useEffect, useMemo, useRef, useState } from "react";
@@ -12,6 +13,8 @@ type JSMpegPlayerProps = {
height: number;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
playbackEnabled: boolean;
useWebGL: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
};
@@ -22,6 +25,8 @@ export default function JSMpegPlayer({
className,
containerRef,
playbackEnabled,
useWebGL = false,
setStats,
onPlaying,
}: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
@@ -33,6 +38,9 @@ export default function JSMpegPlayer({
const [hasData, setHasData] = useState(false);
const hasDataRef = useRef(hasData);
const [dimensionsReady, setDimensionsReady] = useState(false);
const bytesReceivedRef = useRef(0);
const lastTimestampRef = useRef(Date.now());
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
const selectedContainerRef = useMemo(
() => (containerRef.current ? containerRef : internalContainerRef),
@@ -111,6 +119,8 @@ export default function JSMpegPlayer({
const canvas = canvasRef.current;
let videoElement: JSMpeg.VideoElement | null = null;
let frameCount = 0;
setHasData(false);
if (videoWrapper && playbackEnabled) {
@@ -123,21 +133,68 @@ export default function JSMpegPlayer({
{
protocols: [],
audio: false,
disableGl: camera != "birdseye",
disableWebAssembly: camera != "birdseye",
disableGl: !useWebGL,
disableWebAssembly: !useWebGL,
videoBufferSize: 1024 * 1024 * 4,
onVideoDecode: () => {
if (!hasDataRef.current) {
setHasData(true);
onPlayingRef.current?.();
}
frameCount++;
},
},
);
// Set up WebSocket message handler
if (
videoElement.player &&
videoElement.player.source &&
videoElement.player.source.socket
) {
const socket = videoElement.player.source.socket;
socket.addEventListener("message", (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
bytesReceivedRef.current += event.data.byteLength;
}
});
}
// Update stats every second
statsIntervalRef.current = setInterval(() => {
const currentTimestamp = Date.now();
const timeDiff = (currentTimestamp - lastTimestampRef.current) / 1000; // in seconds
const bitrate = (bytesReceivedRef.current * 8) / timeDiff / 1000; // in kbps
setStats?.({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
});
bytesReceivedRef.current = 0;
lastTimestampRef.current = currentTimestamp;
}, 1000);
return () => {
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
frameCount = 0;
statsIntervalRef.current = null;
}
};
}, 0);
return () => {
clearTimeout(initPlayer);
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
}
if (videoElement) {
try {
// this causes issues in react strict mode

View File

@@ -11,6 +11,7 @@ import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import { getIconForLabel } from "@/utils/iconUtil";
@@ -20,20 +21,26 @@ import { cn } from "@/lib/utils";
import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string;
cameraConfig: CameraConfig;
streamName: string;
preferredLiveMode: LivePlayerMode;
showStillWithoutActivity?: boolean;
useWebGL: boolean;
windowVisible?: boolean;
playAudio?: boolean;
volume?: number;
playInBackground: boolean;
micEnabled?: boolean; // only webrtc supports mic
iOSCompatFullScreen?: boolean;
pip?: boolean;
autoLive?: boolean;
showStats?: boolean;
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
@@ -45,14 +52,19 @@ export default function LivePlayer({
containerRef,
className,
cameraConfig,
streamName,
preferredLiveMode,
showStillWithoutActivity = true,
useWebGL = false,
windowVisible = true,
playAudio = false,
volume,
playInBackground = false,
micEnabled = false,
iOSCompatFullScreen = false,
pip,
autoLive = true,
showStats = false,
onClick,
setFullResolution,
onError,
@@ -60,6 +72,18 @@ export default function LivePlayer({
}: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null);
// stats
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kbps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0, // percentage
});
// camera activity
const { activeMotion, activeTracking, objects, offline } =
@@ -144,6 +168,25 @@ export default function LivePlayer({
setLiveReady(false);
}, [preferredLiveMode]);
const [key, setKey] = useState(0);
const resetPlayer = () => {
setLiveReady(false);
setKey((prevKey) => prevKey + 1);
};
useEffect(() => {
if (streamName) {
resetPlayer();
}
}, [streamName]);
useEffect(() => {
if (showStillWithoutActivity && !autoLive) {
setLiveReady(false);
}
}, [showStillWithoutActivity, autoLive]);
const playerIsPlaying = useCallback(() => {
setLiveReady(true);
}, []);
@@ -153,15 +196,19 @@ export default function LivePlayer({
}
let player;
if (!autoLive) {
if (!autoLive || !streamName) {
player = null;
} else if (preferredLiveMode == "webrtc") {
player = (
<WebRtcPlayer
key={"webrtc_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
getStats={showStats}
setStats={setStats}
audioEnabled={playAudio}
volume={volume}
microphoneEnabled={micEnabled}
iOSCompatFullScreen={iOSCompatFullScreen}
onPlaying={playerIsPlaying}
@@ -173,10 +220,15 @@ export default function LivePlayer({
if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = (
<MSEPlayer
key={"mse_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
audioEnabled={playAudio}
volume={volume}
playInBackground={playInBackground}
getStats={showStats}
setStats={setStats}
onPlaying={playerIsPlaying}
pip={pip}
setFullResolution={setFullResolution}
@@ -194,6 +246,7 @@ export default function LivePlayer({
if (cameraActive || !showStillWithoutActivity || liveReady) {
player = (
<JSMpegPlayer
key={"jsmpeg_" + key}
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
camera={cameraConfig.name}
width={cameraConfig.detect.width}
@@ -201,6 +254,8 @@ export default function LivePlayer({
playbackEnabled={
cameraActive || !showStillWithoutActivity || liveReady
}
useWebGL={useWebGL}
setStats={setStats}
containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying}
/>
@@ -293,7 +348,7 @@ export default function LivePlayer({
)}
>
<AutoUpdatingCameraImage
className="size-full"
className="pointer-events-none size-full"
cameraClasses="relative size-full flex justify-center"
camera={cameraConfig.name}
showFps={false}
@@ -331,6 +386,9 @@ export default function LivePlayer({
</Chip>
)}
</div>
{showStats && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
)}
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, VideoResolutionType } from "@/types/live";
import {
LivePlayerError,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import {
SetStateAction,
useCallback,
@@ -15,7 +19,11 @@ type MSEPlayerProps = {
className?: string;
playbackEnabled?: boolean;
audioEnabled?: boolean;
volume?: number;
playInBackground?: boolean;
pip?: boolean;
getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
@@ -26,7 +34,11 @@ function MSEPlayer({
className,
playbackEnabled = true,
audioEnabled = false,
volume,
playInBackground = false,
pip = false,
getStats = false,
setStats,
onPlaying,
setFullResolution,
onError,
@@ -57,6 +69,7 @@ function MSEPlayer({
const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -316,6 +329,8 @@ function MSEPlayer({
let bufLen = 0;
ondataRef.current = (data) => {
totalBytesLoaded.current += data.byteLength;
if (sb?.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
@@ -508,12 +523,22 @@ function MSEPlayer({
}
};
document.addEventListener("visibilitychange", listener);
if (!playInBackground) {
document.addEventListener("visibilitychange", listener);
}
return () => {
document.removeEventListener("visibilitychange", listener);
if (!playInBackground) {
document.removeEventListener("visibilitychange", listener);
}
};
}, [playbackEnabled, visibilityCheck, onConnect, onDisconnect]);
}, [
playbackEnabled,
visibilityCheck,
playInBackground,
onConnect,
onDisconnect,
]);
// control pip
@@ -525,6 +550,16 @@ function MSEPlayer({
videoRef.current.requestPictureInPicture();
}, [pip, videoRef]);
// control volume
useEffect(() => {
if (!videoRef.current || volume == undefined) {
return;
}
videoRef.current.volume = volume;
}, [volume, videoRef]);
// ensure we disconnect for slower connections
useEffect(() => {
@@ -542,6 +577,68 @@ function MSEPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// stats
useEffect(() => {
const video = videoRef.current;
let lastLoadedBytes = totalBytesLoaded.current;
let lastTimestamp = Date.now();
if (!getStats) return;
const updateStats = () => {
if (video) {
const now = Date.now();
const bytesLoaded = totalBytesLoaded.current;
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1024; // kbps
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
? (droppedVideoFrames / totalVideoFrames) * 100
: 0;
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
droppedFrameRate,
});
}
};
const interval = setInterval(updateStats, 1000); // Update every second
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
};
}, [setStats, getStats]);
return (
<video
ref={videoRef}

View File

@@ -0,0 +1,100 @@
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
type PlayerStatsProps = {
stats: PlayerStatsType;
minimal: boolean;
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const fullStatsContent = (
<>
<p>
<span className="text-white/70">Stream Type:</span>{" "}
<span className="text-white">{stats.streamType}</span>
</p>
<p>
<span className="text-white/70">Bandwidth:</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">Latency:</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} seconds
</span>
</p>
)}
<p>
<span className="text-white/70">Total Frames:</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
</p>
{stats.droppedFrames != undefined && (
<p>
<span className="text-white/70">Dropped Frames:</span>{" "}
<span className="text-white">{stats.droppedFrames}</span>
</p>
)}
{stats.decodedFrames != undefined && (
<p>
<span className="text-white/70">Decoded Frames:</span>{" "}
<span className="text-white">{stats.decodedFrames}</span>
</p>
)}
{stats.droppedFrameRate != undefined && (
<p>
<span className="text-white/70">Dropped Frame Rate:</span>{" "}
<span className="text-white">
{stats.droppedFrameRate.toFixed(2)}%
</span>
</p>
)}
</>
);
const minimalStatsContent = (
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">Type</span>
<span className="text-white">{stats.streamType}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">Bandwidth</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">Latency</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} sec
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">Dropped</span>
<span className="text-white">{stats.droppedFrames} frames</span>
</div>
)}
</div>
);
return (
<>
<div
className={cn(
minimal
? "absolute bottom-0 left-0 max-h-[50%] w-full overflow-y-auto rounded-b-lg p-1 md:rounded-b-xl md:p-3"
: "absolute bottom-2 right-2 min-w-52 rounded-2xl p-4",
"z-50 flex flex-col gap-1 bg-black/70 text-[9px] duration-300 animate-in fade-in md:text-xs",
)}
>
{minimal ? minimalStatsContent : fullStatsContent}
</div>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError } from "@/types/live";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type WebRtcPlayerProps = {
@@ -7,9 +7,12 @@ type WebRtcPlayerProps = {
camera: string;
playbackEnabled?: boolean;
audioEnabled?: boolean;
volume?: number;
microphoneEnabled?: boolean;
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
pip?: boolean;
getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
};
@@ -19,9 +22,12 @@ export default function WebRtcPlayer({
camera,
playbackEnabled = true,
audioEnabled = false,
volume,
microphoneEnabled = false,
iOSCompatFullScreen = false,
pip = false,
getStats = false,
setStats,
onPlaying,
onError,
}: WebRtcPlayerProps) {
@@ -194,6 +200,16 @@ export default function WebRtcPlayer({
videoRef.current.requestPictureInPicture();
}, [pip, videoRef]);
// control volume
useEffect(() => {
if (!videoRef.current || volume == undefined) {
return;
}
videoRef.current.volume = volume;
}, [volume, videoRef]);
useEffect(() => {
videoLoadTimeoutRef.current = setTimeout(() => {
onError?.("stalled");
@@ -215,6 +231,75 @@ export default function WebRtcPlayer({
onPlaying?.();
};
// stats
useEffect(() => {
if (!pcRef.current || !getStats) return;
let lastBytesReceived = 0;
let lastTimestamp = 0;
const interval = setInterval(async () => {
if (pcRef.current && videoRef.current && !videoRef.current.paused) {
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
if (stat.type === "inbound-rtp" && stat.kind === "video") {
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
const bitrate =
timeDiff > 0
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
: 0; // in kbps
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: framesDropped,
decodedFrames: framesDecoded,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
});
lastBytesReceived = bytesReceived;
lastTimestamp = timestamp;
}
}, 1000);
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
};
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pcRef, pcRef.current, getStats]);
return (
<video
ref={videoRef}

View File

@@ -0,0 +1,371 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import { IoIosWarning } from "react-icons/io";
import { Button } from "@/components/ui/button";
import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
FrigateConfig,
GroupStreamingSettings,
StreamType,
} from "@/types/frigateConfig";
import ActivityIndicator from "../indicators/activity-indicator";
import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
type CameraStreamingDialogProps = {
camera: string;
groupStreamingSettings: GroupStreamingSettings;
setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings>
>;
setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: (settings: GroupStreamingSettings) => void;
};
export function CameraStreamingDialog({
camera,
groupStreamingSettings,
setGroupStreamingSettings,
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [isLoading, setIsLoading] = useState(false);
const [streamName, setStreamName] = useState(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
);
const [streamType, setStreamType] = useState<StreamType>("smart");
const [compatibilityMode, setCompatibilityMode] = useState(false);
// metadata
const isRestreamed = useMemo(
() =>
config &&
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
[config, streamName],
);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
);
const supportsAudioOutput = useMemo(() => {
if (!cameraMetadata) {
return false;
}
return (
cameraMetadata.producers.find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, recvonly")) !=
undefined,
) != undefined
);
}, [cameraMetadata]);
// handlers
useEffect(() => {
if (!config) {
return;
}
if (groupStreamingSettings && groupStreamingSettings[camera]) {
const cameraSettings = groupStreamingSettings[camera];
setStreamName(cameraSettings.streamName || "");
setStreamType(cameraSettings.streamType || "smart");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
"",
);
setStreamType("smart");
setCompatibilityMode(false);
}
}, [groupStreamingSettings, camera, config]);
const handleSave = useCallback(() => {
setIsLoading(true);
const updatedSettings = {
...groupStreamingSettings,
[camera]: {
streamName,
streamType,
compatibilityMode,
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
},
};
setGroupStreamingSettings(updatedSettings);
setIsDialogOpen(false);
setIsLoading(false);
onSave?.(updatedSettings);
}, [
groupStreamingSettings,
setGroupStreamingSettings,
camera,
streamName,
streamType,
compatibilityMode,
setIsDialogOpen,
onSave,
]);
const handleCancel = useCallback(() => {
if (!config) {
return;
}
if (groupStreamingSettings && groupStreamingSettings[camera]) {
const cameraSettings = groupStreamingSettings[camera];
setStreamName(cameraSettings.streamName || "");
setStreamType(cameraSettings.streamType || "smart");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
"",
);
setStreamType("smart");
setCompatibilityMode(false);
}
setIsDialogOpen(false);
}, [groupStreamingSettings, camera, config, setIsDialogOpen]);
if (!config) {
return null;
}
return (
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="mb-4">
<DialogTitle className="capitalize">
{camera.replaceAll("_", " ")} Streaming Settings
</DialogTitle>
<DialogDescription>
Change the live streaming options for this camera group's dashboard.{" "}
<em>These settings are device/browser-specific.</em>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-8">
{!isRestreamed && (
<div className="flex flex-col gap-2">
<Label>Stream</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="stream" className="text-right">
Stream
</Label>
<Select value={streamName} onValueChange={setStreamName}>
<SelectTrigger className="">
<SelectValue placeholder="Choose a stream" />
</SelectTrigger>
<SelectContent>
{camera !== "birdseye" &&
Object.entries(config?.cameras[camera].live.streams).map(
([name, stream]) => (
<SelectItem key={stream} value={stream}>
{name}
</SelectItem>
),
)}
</SelectContent>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
</Select>
</div>
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
Streaming Method
</Label>
<Select
value={streamType}
onValueChange={(value) => setStreamType(value as StreamType)}
>
<SelectTrigger className="">
<SelectValue placeholder="Choose a streaming option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-streaming">No Streaming</SelectItem>
<SelectItem value="smart">
Smart Streaming (recommended)
</SelectItem>
<SelectItem value="continuous">Continuous Streaming</SelectItem>
</SelectContent>
</Select>
{streamType === "no-streaming" && (
<p className="text-sm text-muted-foreground">
Camera images will only update once per minute and no live
streaming will occur.
</p>
)}
{streamType === "smart" && (
<p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute when
no detectable activity is occurring to conserve bandwidth and
resources. When activity is detected, the image seamlessly
switches to a live stream.
</p>
)}
{streamType === "continuous" && (
<>
<p className="text-sm text-muted-foreground">
Camera image will always be a live stream when visible on the
dashboard, even if no activity is being detected.
</p>
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
Continuous streaming may cause high bandwidth usage and
performance issues. Use with caution.
</div>
</div>
</>
)}
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="compatibility"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={compatibilityMode}
onCheckedChange={() => setCompatibilityMode(!compatibilityMode)}
/>
<Label
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Compatibility mode
</Label>
</div>
<div className="flex flex-col gap-2 leading-none">
<p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is displaying
color artifacts and has a diagonal line on the right side of the
image.
</p>
</div>
</div>
</div>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading}
className="flex flex-1"
onClick={handleSave}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogContent>
);
}

View File

@@ -18,7 +18,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full cursor-pointer border-2 border-primary bg-primary ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-primary ring-offset-background transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
@@ -36,9 +36,9 @@ const VolumeSlider = React.forwardRef<
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-white" />
<SliderPrimitive.Range className="absolute h-full bg-primary data-[disabled]:opacity-20" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-white ring-white focus:ring-white disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-primary ring-primary data-[disabled]:pointer-events-none data-[disabled]:bg-muted focus:ring-primary" />
</SliderPrimitive.Root>
));
VolumeSlider.displayName = SliderPrimitive.Root.displayName;
@@ -58,7 +58,7 @@ const NoThumbSlider = React.forwardRef<
<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" />
<SliderPrimitive.Thumb className="block h-4 w-16 -translate-y-[50%] cursor-col-resize rounded-full bg-transparent ring-offset-transparent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-transparent" />
</SliderPrimitive.Root>
));
NoThumbSlider.displayName = SliderPrimitive.Root.displayName;
@@ -78,8 +78,8 @@ const DualThumbSlider = React.forwardRef<
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-selected/60">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
</SliderPrimitive.Root>
));
DualThumbSlider.displayName = SliderPrimitive.Root.displayName;