mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
import WebRtcPlayer from "./WebRTCPlayer";
|
|
import { CameraConfig } from "@/types/frigateConfig";
|
|
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
|
|
import ActivityIndicator from "../indicators/activity-indicator";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
import MSEPlayer from "./MsePlayer";
|
|
import JSMpegPlayer from "./JSMpegPlayer";
|
|
import { MdCircle } from "react-icons/md";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
|
import {
|
|
LivePlayerError,
|
|
LivePlayerMode,
|
|
PlayerStatsType,
|
|
VideoResolutionType,
|
|
} from "@/types/live";
|
|
import { getIconForLabel } from "@/utils/iconUtil";
|
|
import Chip from "../indicators/Chip";
|
|
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";
|
|
import { LuVideoOff } from "react-icons/lu";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
import { formatList } from "@/utils/stringUtil";
|
|
|
|
type LivePlayerProps = {
|
|
cameraRef?: (ref: HTMLDivElement | null) => void;
|
|
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
|
className?: string;
|
|
cameraConfig: CameraConfig;
|
|
streamName: string;
|
|
preferredLiveMode: LivePlayerMode;
|
|
showStillWithoutActivity?: boolean;
|
|
alwaysShowCameraName?: 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;
|
|
onResetLiveMode?: () => void;
|
|
};
|
|
|
|
export default function LivePlayer({
|
|
cameraRef = undefined,
|
|
containerRef,
|
|
className,
|
|
cameraConfig,
|
|
streamName,
|
|
preferredLiveMode,
|
|
showStillWithoutActivity = true,
|
|
alwaysShowCameraName = false,
|
|
useWebGL = false,
|
|
windowVisible = true,
|
|
playAudio = false,
|
|
volume,
|
|
playInBackground = false,
|
|
micEnabled = false,
|
|
iOSCompatFullScreen = false,
|
|
pip,
|
|
autoLive = true,
|
|
showStats = false,
|
|
onClick,
|
|
setFullResolution,
|
|
onError,
|
|
onResetLiveMode,
|
|
}: LivePlayerProps) {
|
|
const { t } = useTranslation(["components/player"]);
|
|
|
|
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const cameraName = useCameraFriendlyName(cameraConfig);
|
|
|
|
// player is showing on a dashboard if containerRef is not provided
|
|
|
|
const inDashboard = containerRef?.current == null;
|
|
|
|
const [overlayDimensions] = useResizeObserver(overlayRef);
|
|
const isCompact =
|
|
overlayDimensions.width > 0 && overlayDimensions.width < 280;
|
|
|
|
// 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 {
|
|
enabled: cameraEnabled,
|
|
activeMotion,
|
|
activeTracking,
|
|
objects,
|
|
offline,
|
|
} = useCameraActivity(cameraConfig);
|
|
|
|
const cameraActive = useMemo(
|
|
() =>
|
|
!showStillWithoutActivity ||
|
|
(windowVisible && (activeMotion || activeTracking)),
|
|
[activeMotion, activeTracking, showStillWithoutActivity, windowVisible],
|
|
);
|
|
|
|
// camera live state
|
|
|
|
const [liveReady, setLiveReady] = useState(false);
|
|
|
|
const liveReadyRef = useRef(liveReady);
|
|
const cameraActiveRef = useRef(cameraActive);
|
|
|
|
useEffect(() => {
|
|
liveReadyRef.current = liveReady;
|
|
cameraActiveRef.current = cameraActive;
|
|
}, [liveReady, cameraActive]);
|
|
|
|
useEffect(() => {
|
|
if (!autoLive || !liveReady) {
|
|
return;
|
|
}
|
|
|
|
if (!cameraActive) {
|
|
const timer = setTimeout(() => {
|
|
if (liveReadyRef.current && !cameraActiveRef.current) {
|
|
setLiveReady(false);
|
|
onResetLiveMode?.();
|
|
}
|
|
}, 500);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
};
|
|
}
|
|
// live mode won't change
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [autoLive, cameraActive, liveReady]);
|
|
|
|
// camera still state
|
|
|
|
const stillReloadInterval = useMemo(() => {
|
|
if (!windowVisible || offline || !showStillWithoutActivity) {
|
|
return -1; // no reason to update the image when the window is not visible
|
|
}
|
|
|
|
if (liveReady && !cameraActive) {
|
|
return 300;
|
|
}
|
|
|
|
if (liveReady) {
|
|
return 60000;
|
|
}
|
|
|
|
if (activeMotion || activeTracking) {
|
|
if (autoLive) {
|
|
return 200;
|
|
} else {
|
|
return 59000;
|
|
}
|
|
}
|
|
|
|
return 30000;
|
|
}, [
|
|
autoLive,
|
|
showStillWithoutActivity,
|
|
liveReady,
|
|
activeMotion,
|
|
activeTracking,
|
|
offline,
|
|
windowVisible,
|
|
cameraActive,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setLiveReady(false);
|
|
}, [preferredLiveMode]);
|
|
|
|
const [key, setKey] = useState(0);
|
|
const prevStreamNameRef = useRef(streamName);
|
|
|
|
const resetPlayer = () => {
|
|
setLiveReady(false);
|
|
setKey((prevKey) => prevKey + 1);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (prevStreamNameRef.current !== streamName) {
|
|
prevStreamNameRef.current = streamName;
|
|
if (streamName) {
|
|
resetPlayer();
|
|
}
|
|
}
|
|
}, [streamName]);
|
|
|
|
useEffect(() => {
|
|
if (showStillWithoutActivity && !autoLive) {
|
|
setLiveReady(false);
|
|
}
|
|
}, [showStillWithoutActivity, autoLive]);
|
|
|
|
const playerIsPlaying = useCallback(() => {
|
|
setLiveReady(true);
|
|
}, []);
|
|
|
|
// enabled states
|
|
|
|
const [isReEnabling, setIsReEnabling] = useState(false);
|
|
const prevCameraEnabledRef = useRef(cameraEnabled ?? true);
|
|
|
|
useEffect(() => {
|
|
if (cameraEnabled == undefined) {
|
|
return;
|
|
}
|
|
if (!prevCameraEnabledRef.current && cameraEnabled) {
|
|
// Camera enabled
|
|
setLiveReady(false);
|
|
setIsReEnabling(true);
|
|
setKey((prevKey) => prevKey + 1);
|
|
} else if (prevCameraEnabledRef.current && !cameraEnabled) {
|
|
// Camera disabled
|
|
setLiveReady(false);
|
|
setKey((prevKey) => prevKey + 1);
|
|
}
|
|
prevCameraEnabledRef.current = cameraEnabled;
|
|
}, [cameraEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (liveReady && isReEnabling) {
|
|
setIsReEnabling(false);
|
|
}
|
|
}, [liveReady, isReEnabling]);
|
|
|
|
if (!cameraConfig) {
|
|
return <ActivityIndicator />;
|
|
}
|
|
|
|
let player;
|
|
if (!autoLive || !streamName || !cameraEnabled) {
|
|
player = null;
|
|
} else if (preferredLiveMode == "webrtc") {
|
|
player = (
|
|
<WebRtcPlayer
|
|
key={"webrtc_" + key}
|
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
|
camera={streamName}
|
|
playbackEnabled={cameraActive || liveReady}
|
|
getStats={showStats}
|
|
setStats={setStats}
|
|
audioEnabled={playAudio}
|
|
volume={volume}
|
|
microphoneEnabled={micEnabled}
|
|
iOSCompatFullScreen={iOSCompatFullScreen}
|
|
onPlaying={playerIsPlaying}
|
|
pip={pip}
|
|
onError={onError}
|
|
/>
|
|
);
|
|
} else if (preferredLiveMode == "mse") {
|
|
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
|
player = (
|
|
<MSEPlayer
|
|
key={"mse_" + key}
|
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
|
camera={streamName}
|
|
playbackEnabled={cameraActive || liveReady}
|
|
audioEnabled={playAudio}
|
|
volume={volume}
|
|
playInBackground={playInBackground}
|
|
getStats={showStats}
|
|
setStats={setStats}
|
|
onPlaying={playerIsPlaying}
|
|
pip={pip}
|
|
setFullResolution={setFullResolution}
|
|
onError={onError}
|
|
/>
|
|
);
|
|
} else {
|
|
player = (
|
|
<div className="w-5xl text-center text-sm">
|
|
{t("livePlayerRequiredIOSVersion")}
|
|
</div>
|
|
);
|
|
}
|
|
} else if (preferredLiveMode == "jsmpeg") {
|
|
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}
|
|
height={cameraConfig.detect.height}
|
|
playbackEnabled={
|
|
cameraActive || !showStillWithoutActivity || liveReady
|
|
}
|
|
useWebGL={useWebGL}
|
|
setStats={setStats}
|
|
containerRef={containerRef ?? internalContainerRef}
|
|
onPlaying={playerIsPlaying}
|
|
/>
|
|
);
|
|
} else {
|
|
player = null;
|
|
}
|
|
} else {
|
|
player = <ActivityIndicator />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={(node) => {
|
|
overlayRef.current = node;
|
|
if (cameraRef) {
|
|
cameraRef(node);
|
|
} else {
|
|
internalContainerRef.current = node;
|
|
}
|
|
}}
|
|
data-camera={cameraConfig.name}
|
|
className={cn(
|
|
"relative flex w-full cursor-pointer justify-center outline",
|
|
activeTracking &&
|
|
((showStillWithoutActivity && !liveReady) || liveReady)
|
|
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
|
: "outline-0 outline-background",
|
|
"transition-all duration-500",
|
|
className,
|
|
)}
|
|
onClick={onClick}
|
|
onAuxClick={(e) => {
|
|
if (e.button === 1) {
|
|
window.open(`${baseUrl}#${cameraConfig.name}`, "_blank")?.focus();
|
|
}
|
|
}}
|
|
>
|
|
{cameraEnabled &&
|
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
|
<ImageShadowOverlay
|
|
upperClassName="md:rounded-2xl"
|
|
lowerClassName="md:rounded-2xl"
|
|
/>
|
|
)}
|
|
{player}
|
|
{cameraEnabled &&
|
|
!offline &&
|
|
(!showStillWithoutActivity || isReEnabling) &&
|
|
!liveReady && <ActivityIndicator />}
|
|
|
|
{((showStillWithoutActivity && !liveReady) || liveReady) &&
|
|
objects.length > 0 && (
|
|
<div className="absolute left-0 top-2 z-40">
|
|
<Tooltip>
|
|
<div className="flex">
|
|
<TooltipTrigger asChild>
|
|
<div className="mx-3 pb-1 text-sm text-white">
|
|
<Chip
|
|
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
|
>
|
|
{[
|
|
...new Set([
|
|
...(objects || []).map(({ label }) => label),
|
|
]),
|
|
]
|
|
.map((label) => {
|
|
return getIconForLabel(
|
|
label,
|
|
"object",
|
|
"size-3 text-white",
|
|
);
|
|
})
|
|
.sort()}
|
|
</Chip>
|
|
</div>
|
|
</TooltipTrigger>
|
|
</div>
|
|
<TooltipPortal>
|
|
<TooltipContent>
|
|
{formatList(
|
|
[
|
|
...new Set(
|
|
(objects || [])
|
|
.map(({ label, sub_label }) => {
|
|
const isManual = label.endsWith("verified");
|
|
const text = isManual ? sub_label : label;
|
|
const type = isManual ? "manual" : "object";
|
|
return getTranslatedLabel(text, type);
|
|
})
|
|
.filter(
|
|
(translated) =>
|
|
translated && !translated.includes("-verified"),
|
|
),
|
|
),
|
|
].sort(),
|
|
)}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"absolute inset-0 w-full",
|
|
showStillWithoutActivity &&
|
|
!liveReady &&
|
|
!isReEnabling &&
|
|
cameraEnabled
|
|
? "visible"
|
|
: "invisible",
|
|
)}
|
|
>
|
|
<AutoUpdatingCameraImage
|
|
className="pointer-events-none size-full"
|
|
cameraClasses="relative size-full flex justify-center"
|
|
camera={cameraConfig.name}
|
|
showFps={false}
|
|
reloadInterval={stillReloadInterval}
|
|
periodicCache
|
|
/>
|
|
</div>
|
|
|
|
{offline && inDashboard && (
|
|
<>
|
|
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
|
<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>
|
|
<TbExclamationCircle className="size-6" />
|
|
{!isCompact && (
|
|
<p className="text-center text-sm">
|
|
<Trans
|
|
ns="components/player"
|
|
values={{
|
|
cameraName: cameraName,
|
|
}}
|
|
>
|
|
streamOffline.desc
|
|
</Trans>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{offline && !showStillWithoutActivity && cameraEnabled && (
|
|
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
|
|
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
|
|
<p className="my-5 text-lg">{t("streamOffline.title")}</p>
|
|
<TbExclamationCircle className="mb-3 size-10" />
|
|
{!isCompact && (
|
|
<p className="max-w-96 text-center">
|
|
<Trans
|
|
ns="components/player"
|
|
values={{
|
|
cameraName: cameraName,
|
|
}}
|
|
>
|
|
streamOffline.desc
|
|
</Trans>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!cameraEnabled && (
|
|
<div className="relative flex h-full w-full items-center justify-center rounded-2xl 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">
|
|
{t("cameraOff")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="absolute right-2 top-2 flex items-center gap-3">
|
|
{(alwaysShowCameraName ||
|
|
(offline && showStillWithoutActivity) ||
|
|
!cameraEnabled) && (
|
|
<Chip
|
|
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
|
|
>
|
|
{cameraName}
|
|
</Chip>
|
|
)}
|
|
{autoLive &&
|
|
!offline &&
|
|
activeMotion &&
|
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
|
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
|
)}
|
|
</div>
|
|
{showStats && (
|
|
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|