mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 23:32:48 +03:00
Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode * hide natural aspect and layout import on phones * fix merge conflict * add confirmation dialog for clearing groups and streaming settings * add confirmation dialog for natural aspect switch * clean up * remove note * wording tweak * remove unused * fixes
This commit is contained in:
@@ -103,7 +103,7 @@ export default function CameraImage({
|
||||
)}
|
||||
{!imageLoaded && enabled ? (
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
importedLayoutsNaturalAspect,
|
||||
ImportSummary,
|
||||
TransferSection,
|
||||
UiSettingsFile,
|
||||
@@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = {
|
||||
fileName: string;
|
||||
file: UiSettingsFile;
|
||||
summary: ImportSummary;
|
||||
currentNaturalAspect: boolean;
|
||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({
|
||||
fileName,
|
||||
file,
|
||||
summary,
|
||||
currentNaturalAspect,
|
||||
onConfirm,
|
||||
}: ImportUiSettingsDialogProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
|
||||
|
||||
const available = useMemo(
|
||||
() => ({
|
||||
layouts: summary.layoutGroupCount > 0,
|
||||
// phones use the static grid, so a saved grid layout has nothing to
|
||||
// apply to and would only flip the tile sizing mode behind the scenes
|
||||
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
|
||||
streaming: summary.streamingCameraCount > 0,
|
||||
preferences: summary.preferenceCount > 0,
|
||||
}),
|
||||
@@ -90,6 +96,16 @@ export default function ImportUiSettingsDialog({
|
||||
[sections.streaming, summary.unknownCameras],
|
||||
);
|
||||
|
||||
// importing layouts also applies the tile-sizing mode they were built for
|
||||
const layoutsModeChange = useMemo(() => {
|
||||
if (!sections.layouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = importedLayoutsNaturalAspect(file);
|
||||
return mode === null || mode === currentNaturalAspect ? null : mode;
|
||||
}, [sections.layouts, file, currentNaturalAspect]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsImporting(true);
|
||||
await onConfirm(sections);
|
||||
@@ -113,73 +129,102 @@ export default function ImportUiSettingsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="break-all text-base text-primary-variant">{fileName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||
date: exportedDate,
|
||||
version: file.frigate_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
|
||||
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-all text-base font-medium text-primary-variant">
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||
date: exportedDate,
|
||||
version: file.frigate_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.layouts", {
|
||||
count: summary.layoutGroupCount,
|
||||
})}
|
||||
isChecked={sections.layouts}
|
||||
disabled={!available.layouts || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, layouts: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.streaming", {
|
||||
count: summary.streamingCameraCount,
|
||||
})}
|
||||
isChecked={sections.streaming}
|
||||
disabled={!available.streaming || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, streaming: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.preferences", {
|
||||
count: summary.preferenceCount,
|
||||
})}
|
||||
isChecked={sections.preferences}
|
||||
disabled={!available.preferences || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, preferences: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.layouts", {
|
||||
count: summary.layoutGroupCount,
|
||||
})}
|
||||
isChecked={sections.layouts}
|
||||
disabled={!available.layouts || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, layouts: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.streaming", {
|
||||
count: summary.streamingCameraCount,
|
||||
})}
|
||||
isChecked={sections.streaming}
|
||||
disabled={!available.streaming || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, streaming: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.preferences", {
|
||||
count: summary.preferenceCount,
|
||||
})}
|
||||
isChecked={sections.preferences}
|
||||
disabled={!available.preferences || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, preferences: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<LuTriangleAlert className="size-5" />
|
||||
<AlertDescription className="space-y-2">
|
||||
{visibleUnknownGroups.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownGroups", {
|
||||
count: visibleUnknownGroups.length,
|
||||
groups: visibleUnknownGroups.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{visibleUnknownCameras.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownCameras", {
|
||||
count: visibleUnknownCameras.length,
|
||||
cameras: visibleUnknownCameras.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isMobileOnly && summary.layoutGroupCount > 0 && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t("general.backupRestore.importDialog.layoutsPhone")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{layoutsModeChange !== null && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
layoutsModeChange
|
||||
? "general.backupRestore.importDialog.layoutsModeOn"
|
||||
: "general.backupRestore.importDialog.layoutsModeOff",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<LuTriangleAlert className="size-5" />
|
||||
<AlertDescription className="space-y-2">
|
||||
{visibleUnknownGroups.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownGroups", {
|
||||
count: visibleUnknownGroups.length,
|
||||
groups: visibleUnknownGroups.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{visibleUnknownCameras.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownCameras", {
|
||||
count: visibleUnknownCameras.length,
|
||||
cameras: visibleUnknownCameras.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -198,7 +243,7 @@ export default function ImportUiSettingsDialog({
|
||||
>
|
||||
{isImporting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else if (liveMode == "mse") {
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else {
|
||||
player = (
|
||||
<div className="w-5xl text-center text-sm">
|
||||
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex size-full justify-center overflow-hidden"
|
||||
camera="birdseye"
|
||||
width={birdseyeConfig.width}
|
||||
height={birdseyeConfig.height}
|
||||
@@ -65,22 +53,31 @@ export default function BirdseyeLivePlayer({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center",
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={
|
||||
{
|
||||
"--pic-ar":
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ type LivePlayerProps = {
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
@@ -80,6 +81,7 @@ export default function LivePlayer({
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
onResetLiveMode,
|
||||
onLiveAspectChange,
|
||||
}: LivePlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
|
||||
@@ -127,6 +129,37 @@ export default function LivePlayer({
|
||||
// camera live state
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
const [liveAspect, setLiveAspect] = useState<number | undefined>();
|
||||
|
||||
const handleFullResolution = useCallback(
|
||||
(value: React.SetStateAction<VideoResolutionType>) => {
|
||||
setFullResolution?.(value);
|
||||
|
||||
if (typeof value === "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveAspect(
|
||||
value.width && value.height ? value.width / value.height : undefined,
|
||||
);
|
||||
},
|
||||
[setFullResolution],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLiveAspectChange?.(liveReady ? liveAspect : undefined);
|
||||
}, [liveReady, liveAspect, onLiveAspectChange]);
|
||||
|
||||
// The card can be a different shape than the picture (a bucketed tile, or a
|
||||
// still whose detect aspect differs from the stream), so overlays that are
|
||||
// meant to sit on the image have to be fitted to it rather than to the card.
|
||||
const pictureAspect = useMemo(() => {
|
||||
if (liveReady && liveAspect) {
|
||||
return liveAspect;
|
||||
}
|
||||
const { width, height } = cameraConfig.detect;
|
||||
return width && height ? width / height : 16 / 9;
|
||||
}, [liveReady, liveAspect, cameraConfig.detect]);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
const cameraActiveRef = useRef(cameraActive);
|
||||
@@ -262,11 +295,12 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
key={"webrtc_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
setFullResolution={handleFullResolution}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
@@ -282,7 +316,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<MSEPlayer
|
||||
key={"mse_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
@@ -292,7 +326,7 @@ export default function LivePlayer({
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
setFullResolution={handleFullResolution}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
@@ -308,7 +342,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
key={"jsmpeg_" + key}
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex justify-center overflow-hidden"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
@@ -325,7 +359,9 @@ export default function LivePlayer({
|
||||
player = null;
|
||||
}
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = (
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -340,10 +376,12 @@ export default function LivePlayer({
|
||||
}}
|
||||
data-camera={cameraConfig.name}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center outline",
|
||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
||||
// image, and every overlay to this one radius so they stay concentric
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl",
|
||||
activeTracking &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
||||
: "outline-0 outline-background",
|
||||
"transition-all duration-500",
|
||||
className,
|
||||
@@ -357,10 +395,14 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
@@ -368,7 +410,7 @@ export default function LivePlayer({
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
!liveReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -447,7 +489,7 @@ export default function LivePlayer({
|
||||
|
||||
{offline && inDashboard && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
@@ -491,7 +533,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
|
||||
{!cameraEnabled && (
|
||||
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
|
||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
TwoWayTalkError,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||
@@ -20,6 +21,7 @@ type WebRtcPlayerProps = {
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
@@ -36,6 +38,7 @@ export default function WebRtcPlayer({
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
setFullResolution,
|
||||
onPlaying,
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
@@ -342,6 +345,12 @@ export default function WebRtcPlayer({
|
||||
if (videoLoadTimeoutRef.current) {
|
||||
clearTimeout(videoLoadTimeoutRef.current);
|
||||
}
|
||||
if (videoRef.current) {
|
||||
setFullResolution?.({
|
||||
width: videoRef.current.videoWidth,
|
||||
height: videoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
onPlaying?.();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user