Files
frigate/web/src/components/player/LivePlayer.tsx
T

464 lines
14 KiB
TypeScript
Raw Normal View History

2023-12-15 16:24:50 -07:00
import WebRtcPlayer from "./WebRTCPlayer";
import { CameraConfig } from "@/types/frigateConfig";
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
2024-03-03 10:32:47 -06:00
import ActivityIndicator from "../indicators/activity-indicator";
2024-06-29 10:02:30 -05:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2024-01-03 17:38:52 -06:00
import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer";
2024-03-03 10:32:47 -06:00
import { MdCircle } from "react-icons/md";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
2024-05-31 07:52:42 -06:00
import {
LivePlayerError,
LivePlayerMode,
2025-02-10 10:42:35 -06:00
PlayerStatsType,
2024-05-31 07:52:42 -06:00
VideoResolutionType,
} from "@/types/live";
import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { cn } from "@/lib/utils";
2024-06-19 06:09:49 -06:00
import { TbExclamationCircle } from "react-icons/tb";
2024-08-27 20:14:22 -05:00
import { TooltipPortal } from "@radix-ui/react-tooltip";
2024-09-11 11:26:01 -06:00
import { baseUrl } from "@/api/baseUrl";
2025-02-10 10:42:35 -06:00
import { PlayerStats } from "./PlayerStats";
2025-03-03 09:30:52 -06:00
import { LuVideoOff } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
2025-08-26 15:29:52 -05:00
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
2025-10-22 07:36:09 -06:00
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
2023-12-15 16:24:50 -07:00
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
2024-05-29 08:01:39 -06:00
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
2024-02-10 05:30:53 -07:00
className?: string;
2023-12-15 16:24:50 -07:00
cameraConfig: CameraConfig;
2025-02-10 10:42:35 -06:00
streamName: string;
2024-08-17 13:16:48 -05:00
preferredLiveMode: LivePlayerMode;
2024-02-10 05:30:53 -07:00
showStillWithoutActivity?: boolean;
alwaysShowCameraName?: boolean;
2025-02-10 10:42:35 -06:00
useWebGL: boolean;
2024-02-12 18:28:36 -07:00
windowVisible?: boolean;
2024-03-01 17:43:02 -07:00
playAudio?: boolean;
2025-02-10 10:42:35 -06:00
volume?: number;
playInBackground: boolean;
micEnabled?: boolean; // only webrtc supports mic
2024-03-15 12:46:17 -06:00
iOSCompatFullScreen?: boolean;
2024-04-02 06:45:16 -06:00
pip?: boolean;
2024-05-29 08:01:39 -06:00
autoLive?: boolean;
2025-02-10 10:42:35 -06:00
showStats?: boolean;
2024-03-01 17:43:02 -07:00
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
2024-05-31 07:52:42 -06:00
onError?: (error: LivePlayerError) => void;
2024-08-17 13:16:48 -05:00
onResetLiveMode?: () => void;
2023-12-15 16:24:50 -07:00
};
export default function LivePlayer({
cameraRef = undefined,
2024-05-29 08:01:39 -06:00
containerRef,
2024-02-10 05:30:53 -07:00
className,
2023-12-15 16:24:50 -07:00
cameraConfig,
2025-02-10 10:42:35 -06:00
streamName,
2024-02-10 05:30:53 -07:00
preferredLiveMode,
showStillWithoutActivity = true,
alwaysShowCameraName = false,
2025-02-10 10:42:35 -06:00
useWebGL = false,
2024-02-12 18:28:36 -07:00
windowVisible = true,
2024-03-01 17:43:02 -07:00
playAudio = false,
2025-02-10 10:42:35 -06:00
volume,
playInBackground = false,
micEnabled = false,
2024-03-15 12:46:17 -06:00
iOSCompatFullScreen = false,
2024-04-02 06:45:16 -06:00
pip,
2024-05-29 08:01:39 -06:00
autoLive = true,
2025-02-10 10:42:35 -06:00
showStats = false,
2024-03-01 17:43:02 -07:00
onClick,
setFullResolution,
2024-05-31 07:52:42 -06:00
onError,
2024-08-17 13:16:48 -05:00
onResetLiveMode,
2023-12-15 16:24:50 -07:00
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
2024-06-29 10:02:30 -05:00
const internalContainerRef = useRef<HTMLDivElement | null>(null);
2025-08-26 15:29:52 -05:00
const cameraName = useCameraFriendlyName(cameraConfig);
2025-02-10 10:42:35 -06:00
// stats
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
2025-09-01 19:23:44 -05:00
bandwidth: 0, // in kBps
2025-02-10 10:42:35 -06:00
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0, // percentage
});
2024-02-10 05:30:53 -07:00
// camera activity
2024-02-12 18:28:36 -07:00
2025-03-03 09:30:52 -06:00
const {
enabled: cameraEnabled,
activeMotion,
activeTracking,
objects,
offline,
} = useCameraActivity(cameraConfig);
2023-12-15 16:24:50 -07:00
2024-02-12 17:34:45 -06:00
const cameraActive = useMemo(
2024-03-01 17:43:02 -07:00
() =>
!showStillWithoutActivity ||
(windowVisible && (activeMotion || activeTracking)),
[activeMotion, activeTracking, showStillWithoutActivity, windowVisible],
2024-02-12 17:34:45 -06:00
);
2024-02-12 18:28:36 -07:00
// camera live state
2024-02-10 05:30:53 -07:00
const [liveReady, setLiveReady] = useState(false);
const liveReadyRef = useRef(liveReady);
const cameraActiveRef = useRef(cameraActive);
useEffect(() => {
liveReadyRef.current = liveReady;
cameraActiveRef.current = cameraActive;
}, [liveReady, cameraActive]);
2024-02-10 05:30:53 -07:00
useEffect(() => {
2024-06-29 10:02:30 -05:00
if (!autoLive || !liveReady) {
2024-02-10 05:30:53 -07:00
return;
}
if (!cameraActive) {
const timer = setTimeout(() => {
if (liveReadyRef.current && !cameraActiveRef.current) {
setLiveReady(false);
2024-08-17 13:16:48 -05:00
onResetLiveMode?.();
}
}, 500);
return () => {
clearTimeout(timer);
};
2024-02-10 05:30:53 -07:00
}
2024-02-28 15:23:56 -07:00
// live mode won't change
// eslint-disable-next-line react-hooks/exhaustive-deps
2024-05-29 08:01:39 -06:00
}, [autoLive, cameraActive, liveReady]);
2024-02-10 05:30:53 -07:00
2024-02-12 18:28:36 -07:00
// camera still state
2024-02-10 05:30:53 -07:00
2024-02-12 18:28:36 -07:00
const stillReloadInterval = useMemo(() => {
2024-06-10 20:20:58 -05:00
if (!windowVisible || offline || !showStillWithoutActivity) {
2024-02-12 18:28:36 -07:00
return -1; // no reason to update the image when the window is not visible
}
if (liveReady && !cameraActive) {
return 300;
}
2024-02-12 18:28:36 -07:00
if (liveReady) {
return 60000;
}
if (activeMotion || activeTracking) {
2024-05-29 08:01:39 -06:00
if (autoLive) {
return 200;
} else {
return 59000;
}
2024-02-12 18:28:36 -07:00
}
return 30000;
2024-05-29 08:01:39 -06:00
}, [
autoLive,
2024-06-10 20:20:58 -05:00
showStillWithoutActivity,
2024-05-29 08:01:39 -06:00
liveReady,
activeMotion,
activeTracking,
offline,
windowVisible,
cameraActive,
2024-05-29 08:01:39 -06:00
]);
2023-12-15 16:24:50 -07:00
useEffect(() => {
setLiveReady(false);
}, [preferredLiveMode]);
2025-02-10 10:42:35 -06:00
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);
}, []);
2025-03-03 09:30:52 -06:00
// enabled states
const [isReEnabling, setIsReEnabling] = useState(false);
2025-03-03 14:05:49 -07:00
const prevCameraEnabledRef = useRef(cameraEnabled ?? true);
2025-03-03 09:30:52 -06:00
useEffect(() => {
2025-03-03 16:28:34 -07:00
if (cameraEnabled == undefined) {
return;
}
2025-03-03 09:30:52 -06:00
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]);
2024-02-10 05:30:53 -07:00
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
2025-03-03 09:30:52 -06:00
if (!autoLive || !streamName || !cameraEnabled) {
2024-05-29 08:01:39 -06:00
player = null;
2024-08-17 13:16:48 -05:00
} else if (preferredLiveMode == "webrtc") {
2024-02-10 05:30:53 -07:00
player = (
<WebRtcPlayer
2025-02-10 10:42:35 -06:00
key={"webrtc_" + key}
2024-05-14 10:06:44 -05:00
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
2025-02-10 10:42:35 -06:00
camera={streamName}
playbackEnabled={cameraActive || liveReady}
2025-02-10 10:42:35 -06:00
getStats={showStats}
setStats={setStats}
2024-03-01 17:43:02 -07:00
audioEnabled={playAudio}
2025-02-10 10:42:35 -06:00
volume={volume}
microphoneEnabled={micEnabled}
2024-03-15 12:46:17 -06:00
iOSCompatFullScreen={iOSCompatFullScreen}
onPlaying={playerIsPlaying}
2024-04-02 06:45:16 -06:00
pip={pip}
onError={onError}
2024-02-10 05:30:53 -07:00
/>
2023-12-15 16:24:50 -07:00
);
2024-08-17 13:16:48 -05:00
} else if (preferredLiveMode == "mse") {
2023-12-31 07:31:05 -06:00
if ("MediaSource" in window || "ManagedMediaSource" in window) {
2024-02-10 05:30:53 -07:00
player = (
<MSEPlayer
2025-02-10 10:42:35 -06:00
key={"mse_" + key}
2024-05-14 10:06:44 -05:00
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
2025-02-10 10:42:35 -06:00
camera={streamName}
playbackEnabled={cameraActive || liveReady}
2024-03-01 17:43:02 -07:00
audioEnabled={playAudio}
2025-02-10 10:42:35 -06:00
volume={volume}
playInBackground={playInBackground}
getStats={showStats}
setStats={setStats}
onPlaying={playerIsPlaying}
2024-04-02 06:45:16 -06:00
pip={pip}
setFullResolution={setFullResolution}
2024-05-31 07:52:42 -06:00
onError={onError}
2024-02-10 05:30:53 -07:00
/>
2023-12-31 07:31:05 -06:00
);
} else {
2024-02-10 05:30:53 -07:00
player = (
2023-12-31 07:31:05 -06:00
<div className="w-5xl text-center text-sm">
{t("livePlayerRequiredIOSVersion")}
2023-12-31 07:31:05 -06:00
</div>
);
}
2024-08-17 13:16:48 -05:00
} else if (preferredLiveMode == "jsmpeg") {
if (cameraActive || !showStillWithoutActivity || liveReady) {
player = (
<JSMpegPlayer
2025-02-10 10:42:35 -06:00
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
}
2025-02-10 10:42:35 -06:00
useWebGL={useWebGL}
setStats={setStats}
2024-06-29 10:02:30 -05:00
containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying}
/>
);
} else {
player = null;
}
2023-12-15 16:24:50 -07:00
} else {
2024-02-10 05:30:53 -07:00
player = <ActivityIndicator />;
2023-12-15 16:24:50 -07:00
}
2024-02-10 05:30:53 -07:00
return (
<div
2024-06-29 10:02:30 -05:00
ref={cameraRef ?? internalContainerRef}
data-camera={cameraConfig.name}
className={cn(
"relative flex w-full cursor-pointer justify-center outline",
2024-06-10 18:24:25 -05:00
activeTracking &&
((showStillWithoutActivity && !liveReady) || liveReady)
2024-05-14 10:06:44 -05:00
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
: "outline-0 outline-background",
"transition-all duration-500",
className,
)}
2024-03-01 17:43:02 -07:00
onClick={onClick}
2024-09-13 07:25:29 -06:00
onAuxClick={(e) => {
if (e.button === 1) {
window.open(`${baseUrl}#${cameraConfig.name}`, "_blank")?.focus();
}
}}
2024-02-10 05:30:53 -07:00
>
2025-03-03 09:30:52 -06:00
{cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && (
2025-11-10 11:03:56 -06:00
<ImageShadowOverlay
upperClassName="md:rounded-2xl"
lowerClassName="md:rounded-2xl"
/>
2025-03-03 09:30:52 -06:00
)}
2024-06-10 18:24:25 -05:00
{player}
2025-03-03 09:30:52 -06:00
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
2024-06-10 18:24:25 -05:00
{((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, "size-3 text-white");
})
.sort()}
</Chip>
</div>
</TooltipTrigger>
</div>
2024-08-27 20:14:22 -05:00
<TooltipPortal>
<TooltipContent className="smart-capitalize">
2024-08-27 20:14:22 -05:00
{[
...new Set([
...(objects || []).map(({ label, sub_label }) =>
label.endsWith("verified")
? sub_label
: label.replaceAll("_", " "),
2024-08-27 20:14:22 -05:00
),
]),
]
.filter((label) => label?.includes("-verified") == false)
.map((label) => capitalizeFirstLetter(label))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</TooltipPortal>
2024-06-10 18:24:25 -05:00
</Tooltip>
</div>
)}
2024-02-10 05:30:53 -07:00
<div
2024-07-08 08:14:10 -05:00
className={cn(
"absolute inset-0 w-full",
2025-03-20 10:20:44 -06:00
showStillWithoutActivity &&
!liveReady &&
!isReEnabling &&
cameraEnabled
2025-03-03 09:30:52 -06:00
? "visible"
: "invisible",
2024-07-08 08:14:10 -05:00
)}
2024-02-10 05:30:53 -07:00
>
<AutoUpdatingCameraImage
2025-02-10 10:42:35 -06:00
className="pointer-events-none size-full"
cameraClasses="relative size-full flex justify-center"
2024-02-10 05:30:53 -07:00
camera={cameraConfig.name}
showFps={false}
2024-02-12 18:28:36 -07:00
reloadInterval={stillReloadInterval}
periodicCache
2024-02-10 05:30:53 -07:00
/>
</div>
2025-03-12 07:09:09 -05:00
{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" />
<p className="max-w-96 text-center">
<Trans
2025-03-20 11:37:40 -05:00
ns="components/player"
values={{
2025-08-27 01:15:01 +08:00
cameraName: cameraName,
}}
>
streamOffline.desc
</Trans>
</p>
</div>
2024-06-19 06:09:49 -06:00
</div>
)}
2025-03-03 09:30:52 -06:00
{!cameraEnabled && (
2025-03-20 11:37:40 -05:00
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
2025-03-03 09:30:52 -06:00
<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("cameraDisabled")}
2025-03-03 09:30:52 -06:00
</p>
</div>
</div>
)}
<div className="absolute right-2 top-2 flex items-center gap-3">
{(alwaysShowCameraName ||
(offline && showStillWithoutActivity) ||
!cameraEnabled) && (
2024-05-26 15:49:12 -06:00
<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`}
>
2025-08-27 01:15:01 +08:00
{cameraName}
2024-05-26 15:49:12 -06:00
</Chip>
)}
{autoLive &&
!offline &&
activeMotion &&
((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
</div>
2025-02-10 10:42:35 -06:00
{showStats && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
)}
2024-02-10 05:30:53 -07:00
</div>
);
2023-12-15 16:24:50 -07:00
}