mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-26 11:31:28 +08:00
Live view improvements (#20177)
This commit is contained in:
@@ -84,6 +84,17 @@ function MSEPlayer({
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
||||
}, [camera]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: LivePlayerError, description: string = "Unknown error") => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${camera} - MSE error '${error}': ${description} See the documentation: https://docs.frigate.video/configuration/live`,
|
||||
);
|
||||
onError?.(error);
|
||||
},
|
||||
[camera, onError],
|
||||
);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current && setFullResolution) {
|
||||
setFullResolution({
|
||||
@@ -237,9 +248,9 @@ function MSEPlayer({
|
||||
onDisconnect();
|
||||
}
|
||||
if (isIOS || isSafari) {
|
||||
onError?.("mse-decode");
|
||||
handleError("mse-decode", "Safari cannot open MediaSource.");
|
||||
} else {
|
||||
onError?.("startup");
|
||||
handleError("startup", "Error opening MediaSource.");
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -267,9 +278,9 @@ function MSEPlayer({
|
||||
onDisconnect();
|
||||
}
|
||||
if (isIOS || isSafari) {
|
||||
onError?.("mse-decode");
|
||||
handleError("mse-decode", "Safari cannot open MediaSource.");
|
||||
} else {
|
||||
onError?.("startup");
|
||||
handleError("startup", "Error opening MediaSource.");
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -297,7 +308,7 @@ function MSEPlayer({
|
||||
if (wsRef.current) {
|
||||
onDisconnect();
|
||||
}
|
||||
onError?.("mse-decode");
|
||||
handleError("mse-decode", "Safari reported InvalidStateError.");
|
||||
return;
|
||||
} else {
|
||||
throw e; // Re-throw if it's not the error we're handling
|
||||
@@ -424,7 +435,10 @@ function MSEPlayer({
|
||||
(bufferThreshold > 10 || bufferTime > 10)
|
||||
) {
|
||||
onDisconnect();
|
||||
onError?.("stalled");
|
||||
handleError(
|
||||
"stalled",
|
||||
"Buffer time (10 seconds) exceeded, browser may not be playing media correctly.",
|
||||
);
|
||||
}
|
||||
|
||||
const playbackRate = calculateAdaptivePlaybackRate(
|
||||
@@ -470,7 +484,7 @@ function MSEPlayer({
|
||||
videoRef.current
|
||||
) {
|
||||
onDisconnect();
|
||||
onError("stalled");
|
||||
handleError("stalled", "Media playback has stalled.");
|
||||
}
|
||||
}, timeoutDuration),
|
||||
);
|
||||
@@ -479,6 +493,7 @@ function MSEPlayer({
|
||||
bufferTimeout,
|
||||
isPlaying,
|
||||
onDisconnect,
|
||||
handleError,
|
||||
onError,
|
||||
onPlaying,
|
||||
playbackEnabled,
|
||||
@@ -663,7 +678,7 @@ function MSEPlayer({
|
||||
if (wsRef.current) {
|
||||
onDisconnect();
|
||||
}
|
||||
onError?.("startup");
|
||||
handleError("startup", "Browser reported a network error.");
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -674,7 +689,7 @@ function MSEPlayer({
|
||||
if (wsRef.current) {
|
||||
onDisconnect();
|
||||
}
|
||||
onError?.("mse-decode");
|
||||
handleError("mse-decode", "Safari reported decoding errors.");
|
||||
}
|
||||
|
||||
setErrorCount((prevCount) => prevCount + 1);
|
||||
@@ -683,7 +698,7 @@ function MSEPlayer({
|
||||
onDisconnect();
|
||||
if (errorCount >= 3) {
|
||||
// too many mse errors, try jsmpeg
|
||||
onError?.("startup");
|
||||
handleError("startup", `Max error count ${errorCount} exceeded.`);
|
||||
} else {
|
||||
reconnect(5000);
|
||||
}
|
||||
|
@@ -37,6 +37,18 @@ export default function WebRtcPlayer({
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
|
||||
}, [camera]);
|
||||
|
||||
// error handler
|
||||
const handleError = useCallback(
|
||||
(error: LivePlayerError, description: string = "Unknown error") => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${camera} - WebRTC error '${error}': ${description} See the documentation: https://docs.frigate.video/configuration/live`,
|
||||
);
|
||||
onError?.(error);
|
||||
},
|
||||
[camera, onError],
|
||||
);
|
||||
|
||||
// camera states
|
||||
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>();
|
||||
@@ -212,7 +224,7 @@ export default function WebRtcPlayer({
|
||||
|
||||
useEffect(() => {
|
||||
videoLoadTimeoutRef.current = setTimeout(() => {
|
||||
onError?.("stalled");
|
||||
handleError("stalled", "WebRTC connection timed out.");
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
@@ -327,7 +339,7 @@ export default function WebRtcPlayer({
|
||||
document.visibilityState === "visible" &&
|
||||
pcRef.current != undefined
|
||||
) {
|
||||
onError("stalled");
|
||||
handleError("stalled", "WebRTC connection stalled.");
|
||||
}
|
||||
}, 3000),
|
||||
);
|
||||
@@ -344,7 +356,7 @@ export default function WebRtcPlayer({
|
||||
// @ts-expect-error code does exist
|
||||
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
|
||||
) {
|
||||
onError?.("startup");
|
||||
handleError("startup", "Browser reported a network error.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@@ -28,7 +28,6 @@ import {
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
@@ -116,6 +115,7 @@ import {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -499,122 +499,118 @@ export default function LiveCameraView({
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={`flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
|
||||
>
|
||||
{fullscreen && (
|
||||
<Button
|
||||
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-secondary-foreground">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{supportsFullscreen && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={
|
||||
fullscreen
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })
|
||||
}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
{!isIOS && !isFirefox && preferredLiveMode != "jsmpeg" && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={LuPictureInPicture}
|
||||
isActive={pip}
|
||||
title={
|
||||
pip
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.pictureInPicture", { ns: "common" })
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pip) {
|
||||
setPip(true);
|
||||
} else {
|
||||
document.exitPictureInPicture();
|
||||
setPip(false);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
{supports2WayTalk && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
||||
isActive={mic}
|
||||
title={
|
||||
mic
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={audio ? GiSpeaker : GiSpeakerOff}
|
||||
isActive={audio ?? false}
|
||||
title={
|
||||
audio
|
||||
? t("cameraAudio.disable", { ns: "views/live" })
|
||||
: t("cameraAudio.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => setAudio(!audio)}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
<FrigateCameraFeatures
|
||||
camera={camera}
|
||||
recordingEnabled={camera.record.enabled_in_config}
|
||||
audioDetectEnabled={camera.audio.enabled_in_config}
|
||||
autotrackingEnabled={
|
||||
camera.onvif.autotracking.enabled_in_config
|
||||
<div
|
||||
className={`flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
|
||||
>
|
||||
{fullscreen && (
|
||||
<Button
|
||||
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-secondary-foreground">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{supportsFullscreen && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={
|
||||
fullscreen
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })
|
||||
}
|
||||
transcriptionEnabled={
|
||||
camera.audio_transcription.enabled_in_config
|
||||
}
|
||||
fullscreen={fullscreen}
|
||||
streamName={streamName ?? ""}
|
||||
setStreamName={setStreamName}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
playInBackground={playInBackground ?? false}
|
||||
setPlayInBackground={setPlayInBackground}
|
||||
showStats={showStats}
|
||||
setShowStats={setShowStats}
|
||||
isRestreamed={isRestreamed ?? false}
|
||||
setLowBandwidth={setLowBandwidth}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
supports2WayTalk={supports2WayTalk}
|
||||
cameraEnabled={cameraEnabled}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!isIOS && !isFirefox && preferredLiveMode != "jsmpeg" && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={LuPictureInPicture}
|
||||
isActive={pip}
|
||||
title={
|
||||
pip
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.pictureInPicture", { ns: "common" })
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pip) {
|
||||
setPip(true);
|
||||
} else {
|
||||
document.exitPictureInPicture();
|
||||
setPip(false);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
{supports2WayTalk && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
||||
isActive={mic}
|
||||
title={
|
||||
mic
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={audio ? GiSpeaker : GiSpeakerOff}
|
||||
isActive={audio ?? false}
|
||||
title={
|
||||
audio
|
||||
? t("cameraAudio.disable", { ns: "views/live" })
|
||||
: t("cameraAudio.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => setAudio(!audio)}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
)}
|
||||
<FrigateCameraFeatures
|
||||
camera={camera}
|
||||
recordingEnabled={camera.record.enabled_in_config}
|
||||
audioDetectEnabled={camera.audio.enabled_in_config}
|
||||
autotrackingEnabled={camera.onvif.autotracking.enabled_in_config}
|
||||
transcriptionEnabled={
|
||||
camera.audio_transcription.enabled_in_config
|
||||
}
|
||||
fullscreen={fullscreen}
|
||||
streamName={streamName ?? ""}
|
||||
setStreamName={setStreamName}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
playInBackground={playInBackground ?? false}
|
||||
setPlayInBackground={setPlayInBackground}
|
||||
showStats={showStats}
|
||||
setShowStats={setShowStats}
|
||||
isRestreamed={isRestreamed ?? false}
|
||||
setLowBandwidth={setLowBandwidth}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
supports2WayTalk={supports2WayTalk}
|
||||
cameraEnabled={cameraEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-container" className="size-full" ref={containerRef}>
|
||||
<TransformComponent
|
||||
@@ -707,27 +703,25 @@ function TooltipButton({
|
||||
...props
|
||||
}: TooltipButtonProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -961,59 +955,56 @@ function PtzControlPanel({
|
||||
)}
|
||||
|
||||
{ptz?.features?.includes("pt-r-fov") && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||
aria-label={t("ptz.move.clickMove.label")}
|
||||
onClick={() => setClickOverlay(!clickOverlay)}
|
||||
>
|
||||
<TbViewfinder />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{clickOverlay
|
||||
? t("ptz.move.clickMove.disable")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||
aria-label={t("ptz.move.clickMove.label")}
|
||||
onClick={() => setClickOverlay(!clickOverlay)}
|
||||
>
|
||||
<TbViewfinder />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{clickOverlay
|
||||
? t("ptz.move.clickMove.disable")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(ptz?.presets?.length ?? 0) > 0 && (
|
||||
<TooltipProvider>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={t("ptz.presets")}>
|
||||
<BsThreeDotsVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="scrollbar-container max-h-[40dvh] overflow-y-auto"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{ptz?.presets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
aria-label={preset}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => sendPtz(`preset_${preset}`)}
|
||||
>
|
||||
{preset}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={t("ptz.presets")}>
|
||||
<BsThreeDotsVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("ptz.presets")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="scrollbar-container max-h-[40dvh] overflow-y-auto"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{ptz?.presets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
aria-label={preset}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => sendPtz(`preset_${preset}`)}
|
||||
>
|
||||
{preset}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1401,9 +1392,11 @@ function FrigateCameraFeatures({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
@@ -1733,9 +1726,11 @@ function FrigateCameraFeatures({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
|
Reference in New Issue
Block a user