Compare commits

...
2 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 0e3dca5915 Live view improvements (#24967)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* hold the last frame on screen while switching live streams

Switching streams in the single camera view blanked the player until the new stream played, because the old player unmounts in the same render and the hidden new player gave the card no height. The outgoing video's last frame is now drawn to a canvas before the remount and covers the gap until the new stream plays, and the card takes the camera's aspect ratio so it keeps its size while the video is hidden.

* show live streams as a one-tap grid in the mobile drawer

Switching streams on mobile meant scrolling past the camera toggles, opening a dropdown, and picking a stream. The drawer now opens with the stream section, and streams are a wrapping grid of toggles sized to their names, so a switch is two taps. A single stream shows as plain text. The audio and two-way talk lines show a spinner while the stream's go2rtc metadata loads instead of reporting audio as unavailable, which showed on the first switch to a transcoded stream.

* don't capture the switch frame on tiles that show the camera still
2026-11-13 09:51:16 -07:00
Josh HawkinsandGitHub 9276502e4f Switch Debug Replay scenes at runtime (#24958)
* allow switching the detect scene of a debug replay camera without a restart

- show the scene field in the replay detect settings
- drop the replay camera's cached model on a detect change so the refresh recycle picks up the new scene's model
- recreate the tracked object state on refresh when the camera's model changed
- reject a scene no model can serve with a 400 instead of crashing the tracker and maintainer threads
- size each camera's detection input shm for the largest model and recreate leftover smaller segments, so a camera re-added under a larger model no longer overflows it
- ignore event ends for objects already dropped by a state reset

* end tracked events before replacing replay camera state on a scene switch

- test the replay model rebind against two configured models
2026-11-13 09:08:29 -07:00
13 changed files with 346 additions and 157 deletions
+17 -1
View File
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import ( from frigate.jobs.media_sync import (
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400, status_code=400,
) )
# an unresolvable scene would crash the threads that look up the model
detect_update = sections.get("detect")
if isinstance(detect_update, dict) and "scene" in detect_update:
try:
config._resolve_camera_model(cam_name, detect_update["scene"])
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Invalid detect scene"},
status_code=400,
)
for section_name, update in sections.items(): for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update) err = apply_section_update(camera_config, section_name, update)
if err is not None: if err is not None:
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400, status_code=400,
) )
# the model is resolved again from the scene when the replay
# camera is recycled by the refresh below
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
config.drop_camera_model(cam_name)
# Publish ZMQ updates so processing threads pick up changes # Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"): if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/") _, _, camera, field = body.update_topic.split("/")
+2 -9
View File
@@ -76,7 +76,7 @@ from frigate.models import (
from frigate.notices import install_registry from frigate.notices import install_registry
from frigate.notices.registry import NoticeRegistry from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size from frigate.object_detection.util import create_detection_input_shm
from frigate.output.output import OutputProcess from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTracker from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController from frigate.ptz.onvif import OnvifController
@@ -399,14 +399,7 @@ class FrigateApp:
model = self.config.model_for_camera(name) model = self.config.model_for_camera(name)
model_cameras[model.scene].append(name) model_cameras[model.scene].append(name)
try: shm_in = create_detection_input_shm(name, self.config.models)
shm_in = UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
shm_in = UntrackedSharedMemory(name=name)
try: try:
shm_out = UntrackedSharedMemory( shm_out = UntrackedSharedMemory(
+3 -6
View File
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
) )
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions from frigate.models import Regions
from frigate.object_detection.util import detection_frame_size from frigate.object_detection.util import create_detection_input_shm
from frigate.util.builtin import empty_and_close_queue from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.object import get_camera_regions_grid from frigate.util.object import get_camera_regions_grid
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
max(model.width, model.height), max(model.width, model.height),
) )
create_detection_input_shm(name, self.config.models)
try: try:
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4) UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError: except FileExistsError:
pass pass
+2 -1
View File
@@ -296,7 +296,8 @@ class CameraState:
return frame_copy return frame_copy
def finished(self, obj_id: str) -> None: def finished(self, obj_id: str) -> None:
del self.tracked_objects[obj_id] # state rebuilt after a resolution or scene change no longer has the object
self.tracked_objects.pop(obj_id, None)
def on(self, event_type: str, callback: Callable[..., Any]) -> None: def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback) self.callbacks[event_type].append(callback)
+23 -14
View File
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
return self.detect_api.receive_output() return self.detect_api.receive_output()
def map_input_frame(
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
) -> np.ndarray | None:
"""Map a camera's detection input segment as this model's input tensor.
The segment is sized for the largest configured model, so it can be larger
than this model's input.
"""
buf = frame_manager.write(camera)
shape = (1, model.height, model.width, 3)
if buf is None or len(buf) < model.height * model.width * 3:
return None
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
class DetectorRunner(FrigateProcess): class DetectorRunner(FrigateProcess):
def __init__( def __init__(
self, self,
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
connection_id = self.detection_queue.get(timeout=1) connection_id = self.detection_queue.get(timeout=1)
except queue.Empty: except queue.Empty:
continue continue
input_frame = frame_manager.get( input_frame = map_input_frame(
frame_manager,
connection_id, connection_id,
( self.detector_config.model, # type: ignore[arg-type]
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
) )
if input_frame is None: if input_frame is None:
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
continue continue
assert self._frame_manager is not None assert self._frame_manager is not None
input_frame = self._frame_manager.get( input_frame = map_input_frame(
self._frame_manager,
connection_id, connection_id,
( self.detector_config.model, # type: ignore[arg-type]
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
) )
if input_frame is None: if input_frame is None:
+26 -5
View File
@@ -6,18 +6,39 @@ import threading
from numpy import ndarray from numpy import ndarray
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
from frigate.util.image import UntrackedSharedMemory
def detection_frame_size(model: ModelConfig) -> int: def create_detection_input_shm(
"""Get the shared memory size a camera needs to hand frames to a model. camera: str, models: list[ModelConfig]
) -> UntrackedSharedMemory:
"""Create the shared memory a camera hands detection frames through.
It is sized for the largest model so a camera re-added under the same name
with another scene can reuse it. Detectors keep mappings of it, so it is
never unlinked while they run.
Args: Args:
model: The model the camera runs on camera: Name of the camera
models: Every configured model
Returns: Returns:
Size in bytes of one model input frame The camera's detection input segment
""" """
return model.height * model.width * 3 size = max(model.height * model.width * 3 for model in models)
try:
return UntrackedSharedMemory(name=camera, create=True, size=size)
except FileExistsError:
shm = UntrackedSharedMemory(name=camera)
if shm.size >= size:
return shm
# left by an earlier run with smaller models, so no detector maps it yet
shm.close()
shm.unlink()
return UntrackedSharedMemory(name=camera, create=True, size=size)
class RequestStore: class RequestStore:
+40
View File
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"] assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
@patch("frigate.detectors.detector_config.load_labels")
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
self, mock_labels
):
mock_labels.return_value = {}
replay = "_replay_front_door"
self.minimal_config["models"] = [
{"devices": ["cpu"]},
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
]
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app()
app.config_publisher = Mock()
assert app.frigate_config.model_for_camera(replay).scene == "default"
with AuthTestClient(app) as client:
response = client.put(
"/config/set",
json={
"requires_restart": 0,
"skip_save": True,
"update_topic": f"config/cameras/{replay}/detect",
"config_data": {
"cameras": {replay: {"detect": {"scene": "thermal"}}}
},
},
)
assert response.status_code == 200
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
topics = [
call.args[0].update_type.name
for call in app.config_publisher.publish_update.call_args_list
]
assert topics[0] == "detect"
assert topics[-1] == "refresh"
#################################################################################################################### ####################################################################################################################
################################### Credential redaction sentinel ################################################ ################################### Credential redaction sentinel ################################################
#################################################################################################################### ####################################################################################################################
+16
View File
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion, CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects, CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.refresh,
CameraConfigUpdateEnum.remove, CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.timestamp_style, CameraConfigUpdateEnum.timestamp_style,
CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.zones,
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity) self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
# a replay camera recycled onto another scene's model needs new state
for camera in updated_topics.get("refresh", []):
camera_state = self.camera_states.get(camera)
if (
camera_state is None
or camera_state.model is self.config.model_for_camera(camera)
):
continue
self.force_end_all_events(camera, camera_state)
camera_state.shutdown()
self.frame_manager.close_camera(camera)
self.create_camera_state(camera)
# manage camera disabled state # manage camera disabled state
for camera, config in self.config.cameras.items(): for camera, config in self.config.cameras.items():
if not config.enabled_in_config: if not config.enabled_in_config:
+3
View File
@@ -173,6 +173,9 @@
"label": "Play in background", "label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden." "tips": "Enable this option to continue streaming when the player is hidden."
}, },
"metadata": {
"loading": "Fetching stream metadata…"
},
"mode": "Streaming Technology", "mode": "Streaming Technology",
"technology": { "technology": {
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.", "description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
}, },
replay: { replay: {
restartRequired: [], restartRequired: [],
fieldOrder: ["width", "height", "fps"], fieldOrder: ["scene", "width", "height", "fps"],
fieldGroups: { fieldGroups: {
resolution: ["width", "height", "fps"], resolution: ["width", "height", "fps"],
}, },
hiddenFields: [ hiddenFields: [
"enabled", "enabled",
"enabled_in_config", "enabled_in_config",
"scene", hideSceneWithoutChoice,
"min_initialized", "min_initialized",
"max_disappeared", "max_disappeared",
"annotation_offset", "annotation_offset",
+34
View File
@@ -248,10 +248,21 @@ export default function LivePlayer({
// remounts them in the same render and the new player is hidden until it // remounts them in the same render and the new player is hidden until it
// plays. jsmpeg plays the camera, not the stream, and keeps playing // plays. jsmpeg plays the camera, not the stream, and keeps playing
const [renderedStream, setRenderedStream] = useState(streamName); const [renderedStream, setRenderedStream] = useState(streamName);
const [switchFrame, setSwitchFrame] = useState<HTMLCanvasElement>();
if (renderedStream !== streamName) { if (renderedStream !== streamName) {
setRenderedStream(streamName); setRenderedStream(streamName);
if (preferredLiveMode !== "jsmpeg") { if (preferredLiveMode !== "jsmpeg") {
setLiveReady(false); setLiveReady(false);
// the old video is still in the DOM until this render commits, so its
// last frame can cover the gap until the new stream plays. Tiles that
// show the camera still already cover it
if (!showStillWithoutActivity) {
const frame = captureVideoFrame(overlayRef.current);
if (frame) {
setSwitchFrame(frame);
}
}
} }
} }
@@ -263,6 +274,7 @@ export default function LivePlayer({
const playerIsPlaying = useCallback(() => { const playerIsPlaying = useCallback(() => {
setLiveReady(true); setLiveReady(true);
setSwitchFrame(undefined);
}, []); }, []);
// enabled states // enabled states
@@ -416,6 +428,12 @@ export default function LivePlayer({
</div> </div>
)} )}
{player} {player}
{switchFrame && !liveReady && (
<div
className="absolute inset-0"
ref={(node) => node?.replaceChildren(switchFrame)}
/>
)}
{cameraEnabled && {cameraEnabled &&
!offline && !offline &&
(!showStillWithoutActivity || isReEnabling) && (!showStillWithoutActivity || isReEnabling) &&
@@ -582,3 +600,19 @@ export default function LivePlayer({
</div> </div>
); );
} }
function captureVideoFrame(
container: HTMLElement | null,
): HTMLCanvasElement | undefined {
const video = container?.querySelector("video");
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return undefined;
}
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.className = "size-full object-contain";
canvas.getContext("2d")?.drawImage(video, 0, 0);
return canvas;
}
+74 -38
View File
@@ -12,11 +12,15 @@ import { useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu"; import { LuInfo } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md"; import { MdOutlineRestartAlt } from "react-icons/md";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
// Radix Select needs a non-empty value. Auto itself is stored as the // Radix Select needs a non-empty value. Auto itself is stored as the
// absence of a pinned stream // absence of a pinned stream
const AUTO_VALUE = "__frigate_auto__"; const AUTO_VALUE = "__frigate_auto__";
const GRID_ITEM =
"h-auto min-h-10 min-w-[calc((100%-1rem)/3)] grow whitespace-normal bg-secondary [overflow-wrap:anywhere] px-2 py-2 text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:hover:bg-selected data-[state=on]:hover:text-selected-foreground";
type LiveStreamSelectProps = { type LiveStreamSelectProps = {
// friendly name to go2rtc stream name, in ladder order // friendly name to go2rtc stream name, in ladder order
streams: Record<string, string>; streams: Record<string, string>;
@@ -29,6 +33,7 @@ type LiveStreamSelectProps = {
// sends auto back to the top of the ladder // sends auto back to the top of the ladder
onRetry: () => void; onRetry: () => void;
disabled: boolean; disabled: boolean;
variant?: "select" | "grid";
}; };
export default function LiveStreamSelect({ export default function LiveStreamSelect({
@@ -40,6 +45,7 @@ export default function LiveStreamSelect({
onSelect, onSelect,
onRetry, onRetry,
disabled, disabled,
variant = "select",
}: LiveStreamSelectProps) { }: LiveStreamSelectProps) {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
@@ -52,48 +58,78 @@ export default function LiveStreamSelect({
const auto = autoAvailable && pinnedStream == undefined; const auto = autoAvailable && pinnedStream == undefined;
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream); const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
const autoLabel = auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: t("stream.auto.label");
return ( return (
<> <>
<Select {variant === "grid" &&
value={value} !autoAvailable &&
disabled={disabled} Object.keys(streams).length <= 1 ? (
onValueChange={(next) => <div className="text-sm">{labelFor(playingStream)}</div>
onSelect(next === AUTO_VALUE ? undefined : next) ) : variant === "grid" ? (
} <ToggleGroup
> type="single"
<SelectTrigger className="w-full"> className="flex flex-wrap items-stretch gap-2"
<SelectValue> value={value}
{auto disabled={disabled}
? t("stream.auto.selected", { onValueChange={(next) => {
stream: // radix sends an empty value when the active item is tapped again
autoReason === "floor" if (next) {
? t("stream.auto.floorLabel") onSelect(next === AUTO_VALUE ? undefined : next);
: labelFor(playingStream), }
}) }}
: labelFor(value)} >
</SelectValue> {autoAvailable && (
</SelectTrigger> <ToggleGroupItem value={AUTO_VALUE} className={GRID_ITEM}>
{autoLabel}
</ToggleGroupItem>
)}
{Object.entries(streams).map(([label, name]) => (
<ToggleGroupItem key={label} value={name} className={GRID_ITEM}>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
) : (
<Select
value={value}
disabled={disabled}
onValueChange={(next) =>
onSelect(next === AUTO_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue>{auto ? autoLabel : labelFor(value)}</SelectValue>
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{autoAvailable && ( {autoAvailable && (
<SelectItem className="cursor-pointer" value={AUTO_VALUE}> <SelectItem className="cursor-pointer" value={AUTO_VALUE}>
<span className="flex flex-col gap-0.5 whitespace-normal"> <span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.auto.label")}</span> <span>{t("stream.auto.label")}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t("stream.auto.tips")} {t("stream.auto.tips")}
</span>
</span> </span>
</span> </SelectItem>
</SelectItem> )}
)} {Object.entries(streams).map(([label, name]) => (
{Object.entries(streams).map(([label, name]) => ( <SelectItem key={label} className="cursor-pointer" value={name}>
<SelectItem key={label} className="cursor-pointer" value={name}> {label}
{label} </SelectItem>
</SelectItem> ))}
))} </SelectGroup>
</SelectGroup> </SelectContent>
</SelectContent> </Select>
</Select> )}
{auto && autoReason && !disabled && ( {auto && autoReason && !disabled && (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
+104 -81
View File
@@ -230,15 +230,16 @@ export default function LiveCameraView({
} }
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]); }, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>( const { data: cameraMetadata, isLoading: cameraMetadataLoading } =
isRestreamed ? `go2rtc/streams/${streamName}` : null, useSWR<LiveStreamMetadata>(
{ isRestreamed ? `go2rtc/streams/${streamName}` : null,
revalidateOnFocus: false, {
revalidateOnReconnect: false, revalidateOnFocus: false,
revalidateIfStale: false, revalidateOnReconnect: false,
dedupingInterval: 60000, revalidateIfStale: false,
}, dedupingInterval: 60000,
); },
);
const webRTCAvailability = useWebRTCAvailableForStream( const webRTCAvailability = useWebRTCAvailableForStream(
cameraMetadata, cameraMetadata,
@@ -847,6 +848,7 @@ export default function LiveCameraView({
onResetStream={resetStream} onResetStream={resetStream}
supportsAudioOutput={supportsAudioOutput} supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk} supports2WayTalk={supports2WayTalk}
streamMetadataLoading={cameraMetadataLoading}
cameraEnabled={cameraEnabled} cameraEnabled={cameraEnabled}
debug={debug} debug={debug}
setDebug={setDebug} setDebug={setDebug}
@@ -873,9 +875,12 @@ export default function LiveCameraView({
growClassName, growClassName,
)} )}
ref={clickOverlayRef} ref={clickOverlayRef}
style={{ style={
aspectRatio: constrainedAspectRatio, {
}} aspectRatio: constrainedAspectRatio,
"--camera-aspect": cameraAspectRatio,
} as React.CSSProperties
}
> >
{clickOverlay && overlaySize.width > 0 && ( {clickOverlay && overlaySize.width > 0 && (
<div <div
@@ -914,7 +919,10 @@ export default function LiveCameraView({
)} )}
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
className={fullscreen ? "rounded-none" : ""} className={cn(
"aspect-[var(--camera-aspect)]",
fullscreen && "rounded-none",
)}
windowVisible windowVisible
showStillWithoutActivity={false} showStillWithoutActivity={false}
alwaysShowCameraName={false} alwaysShowCameraName={false}
@@ -1007,6 +1015,7 @@ type FrigateCameraFeaturesProps = {
onResetStream: () => void; onResetStream: () => void;
supportsAudioOutput: boolean; supportsAudioOutput: boolean;
supports2WayTalk: boolean; supports2WayTalk: boolean;
streamMetadataLoading: boolean;
cameraEnabled: boolean; cameraEnabled: boolean;
debug: boolean; debug: boolean;
setDebug: (debug: boolean) => void; setDebug: (debug: boolean) => void;
@@ -1038,6 +1047,7 @@ function FrigateCameraFeatures({
onResetStream, onResetStream,
supportsAudioOutput, supportsAudioOutput,
supports2WayTalk, supports2WayTalk,
streamMetadataLoading,
cameraEnabled, cameraEnabled,
debug, debug,
setDebug, setDebug,
@@ -1434,7 +1444,12 @@ function FrigateCameraFeatures({
!debug && !debug &&
isRestreamed && ( isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? ( {streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<> <>
<LuCheck className="size-4 text-success" /> <LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div> <div>{t("stream.audio.available")}</div>
@@ -1693,72 +1708,7 @@ function FrigateCameraFeatures({
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl"> <DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4"> <div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
<> <>
{isAdmin && ( <div className="mb-3 flex flex-col gap-5">
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
{!isRestreamed && ( {!isRestreamed && (
<div className="flex flex-col gap-2 p-2"> <div className="flex flex-col gap-2 p-2">
<Label>{t("stream.title")}</Label> <Label>{t("stream.title")}</Label>
@@ -1811,6 +1761,7 @@ function FrigateCameraFeatures({
onSelect={onSelectStream} onSelect={onSelectStream}
onRetry={onResetStream} onRetry={onResetStream}
disabled={debug || forceLowBandwidth} disabled={debug || forceLowBandwidth}
variant="grid"
/> />
{debug && ( {debug && (
@@ -1826,7 +1777,12 @@ function FrigateCameraFeatures({
!debug && !debug &&
isRestreamed && ( isRestreamed && (
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground"> <div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? ( {streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" size={14} />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<> <>
<LuCheck className="size-4 text-success" /> <LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div> <div>{t("stream.audio.available")}</div>
@@ -1955,6 +1911,73 @@ function FrigateCameraFeatures({
</p> </p>
</div> </div>
)} )}
</div>
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
{isRestreamed && {isRestreamed &&
Object.values(camera.live.streams).length > 0 && ( Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">