mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b26ecc1fd | ||
|
|
a30f9b41fe | ||
|
|
dce06d5852 | ||
|
|
0928324840 | ||
|
|
386b3d31aa | ||
|
|
3f00c75bbd | ||
|
|
dd5ba8bd8e | ||
|
|
f2bfcebbc4 | ||
|
|
90426cfba8 | ||
|
|
3adf2cf48e | ||
|
|
225e014979 | ||
|
|
85a2731510 | ||
|
|
71d7741e12 | ||
|
|
7164f4b4a2 | ||
|
|
3f7e2c4686 |
@@ -393,6 +393,11 @@ def config(request: Request):
|
|||||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||||
model_dict["labelmap"] = model.merged_labelmap
|
model_dict["labelmap"] = model.merged_labelmap
|
||||||
|
|
||||||
|
# report the configured reference rather than the resolved cache path,
|
||||||
|
# so saving the config back doesn't lose the Frigate+ model
|
||||||
|
if model.plus_id:
|
||||||
|
model_dict["path"] = f"plus://{model.plus_id}"
|
||||||
|
|
||||||
if not config["plus"]["enabled"]:
|
if not config["plus"]["enabled"]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
|||||||
|
|
||||||
if app.stats_emitter is not None:
|
if app.stats_emitter is not None:
|
||||||
app.stats_emitter.config = config
|
app.stats_emitter.config = config
|
||||||
|
app.stats_emitter.hardware_stats.set_config(config)
|
||||||
|
|
||||||
if app.dispatcher is not None:
|
if app.dispatcher is not None:
|
||||||
app.dispatcher.config = config
|
app.dispatcher.config = config
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
|
|||||||
null_audio_glitches,
|
null_audio_glitches,
|
||||||
plan_clip,
|
plan_clip,
|
||||||
resolve_coverage,
|
resolve_coverage,
|
||||||
stream_has_audio,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -681,15 +680,10 @@ async def _vod_response(
|
|||||||
end_ts,
|
end_ts,
|
||||||
force_discontinuity,
|
force_discontinuity,
|
||||||
)
|
)
|
||||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
|
||||||
|
|
||||||
# rows contradicting their stream's audio composition are
|
# rows contradicting their stream's audio composition are
|
||||||
# truncated-shutdown glitches
|
# truncated-shutdown glitches
|
||||||
main_audio = stream_has_audio(intervals, main=True)
|
|
||||||
sub_audio = stream_has_audio(intervals, main=False)
|
|
||||||
|
|
||||||
spans = build_spans(
|
spans = build_spans(
|
||||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||||
stream_preference,
|
stream_preference,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
|
|||||||
_all_attributes: list[str] = PrivateAttr()
|
_all_attributes: list[str] = PrivateAttr()
|
||||||
_all_attribute_logos: list[str] = PrivateAttr()
|
_all_attribute_logos: list[str] = PrivateAttr()
|
||||||
_model_hash: str = PrivateAttr()
|
_model_hash: str = PrivateAttr()
|
||||||
|
_plus_id: str | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def merged_labelmap(self) -> dict[int, str]:
|
def merged_labelmap(self) -> dict[int, str]:
|
||||||
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
|
|||||||
def model_hash(self) -> str:
|
def model_hash(self) -> str:
|
||||||
return self._model_hash
|
return self._model_hash
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plus_id(self) -> str | None:
|
||||||
|
"""The Frigate+ model id, once a plus:// path has been resolved."""
|
||||||
|
return self._plus_id
|
||||||
|
|
||||||
def __init__(self, **config):
|
def __init__(self, **config):
|
||||||
super().__init__(**config)
|
super().__init__(**config)
|
||||||
|
|
||||||
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
|
|||||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
model_id = self.path[7:]
|
model_id = self.path[7:]
|
||||||
|
self._plus_id = model_id
|
||||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||||
model_info_path = f"{self.path}.json"
|
model_info_path = f"{self.path}.json"
|
||||||
|
|
||||||
|
|||||||
+90
-22
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
|
|||||||
from frigate.util.recording_coverage import (
|
from frigate.util.recording_coverage import (
|
||||||
build_spans,
|
build_spans,
|
||||||
known_video_codecs,
|
known_video_codecs,
|
||||||
|
null_audio_glitches,
|
||||||
|
realized_timeline,
|
||||||
resolve_coverage,
|
resolve_coverage,
|
||||||
stream_media_summary,
|
stream_media_summary,
|
||||||
)
|
)
|
||||||
@@ -84,10 +86,20 @@ class StreamRun:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ChapterWindow:
|
class _ChapterWindow:
|
||||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
||||||
|
|
||||||
|
lead_in is the output time the slice's vod clip plays before start_time,
|
||||||
|
from snapping its first frame back to a keyframe.
|
||||||
|
"""
|
||||||
|
|
||||||
start_time: float
|
start_time: float
|
||||||
end_time: float
|
end_time: float
|
||||||
|
lead_in: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_in(recording: Any) -> float:
|
||||||
|
"""Output seconds a chapter source plays before its first wall second."""
|
||||||
|
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
||||||
|
|
||||||
|
|
||||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||||
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
|
|||||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||||
|
|
||||||
Delegates to the same coverage resolution the manifest builder
|
Delegates to the same coverage resolution and glitch nulling the
|
||||||
uses, so what we plan around and what nginx-vod emits agree by
|
manifest builder uses, so what we plan around and what nginx-vod
|
||||||
construction. Returns the spans (each [row, start, end, is_main]),
|
emits agree by construction. Returns the spans (each [row, start,
|
||||||
the known video codecs, and whether audio survives the range.
|
end, is_main]), the known video codecs, and whether audio survives
|
||||||
|
the range.
|
||||||
Memoized: several stages of the export ask the same question, and
|
Memoized: several stages of the export ask the same question, and
|
||||||
the recordings backing a finished range do not change under us.
|
the recordings backing a finished range do not change under us.
|
||||||
"""
|
"""
|
||||||
if self._coverage is None:
|
if self._coverage is None:
|
||||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
intervals = null_audio_glitches(
|
||||||
|
resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||||
|
)
|
||||||
self._coverage = (
|
self._coverage = (
|
||||||
build_spans(intervals, self.pinned_stream),
|
build_spans(intervals, self.pinned_stream),
|
||||||
known_video_codecs(intervals),
|
known_video_codecs(intervals),
|
||||||
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
|
|||||||
# hand-off to stage around
|
# hand-off to stage around
|
||||||
return True
|
return True
|
||||||
|
|
||||||
spans, codecs, keep_audio = self._resolve_coverage()
|
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||||
runs = self._stream_runs(spans)
|
runs = self._planned_stream_runs()
|
||||||
|
|
||||||
# a range one stream covers end to end has nothing to hand off,
|
# a range one stream covers end to end has nothing to hand off,
|
||||||
# so it stays on the existing path however long it is
|
# so it stays on the existing path however long it is
|
||||||
if len(runs) < 2:
|
if len(runs) < 2:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
|
||||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||||
|
|
||||||
|
def _planned_stream_runs(self) -> list[StreamRun]:
|
||||||
|
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
||||||
|
runs = self._stream_runs(self._merged_spans())
|
||||||
|
|
||||||
|
if len(runs) < 2:
|
||||||
|
return runs
|
||||||
|
|
||||||
|
return [piece for run in runs for piece in self._split_long_run(run)]
|
||||||
|
|
||||||
|
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
||||||
|
"""Chapter windows for the staged files as they were rendered.
|
||||||
|
|
||||||
|
Each staged run comes from its own pinned vod playlist, whose first
|
||||||
|
clip snaps back to the preceding keyframe, so a staged file runs up
|
||||||
|
to a GOP longer than its slice of the merged timeline. Planning each
|
||||||
|
run the way its playlist does carries that lead-in into the chapter
|
||||||
|
offsets instead of letting it accumulate at every hand-off.
|
||||||
|
"""
|
||||||
|
windows: list[_ChapterWindow] = []
|
||||||
|
|
||||||
|
for run in self._planned_stream_runs():
|
||||||
|
intervals = null_audio_glitches(
|
||||||
|
resolve_coverage(self.camera, run.start_time, run.end_time)
|
||||||
|
)
|
||||||
|
|
||||||
|
for clip in realized_timeline(intervals, run.stream_type):
|
||||||
|
# a skipped clip is absent from the playlist and the file
|
||||||
|
if clip["duration"] <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
span = clip["end_time"] - clip["start_time"]
|
||||||
|
windows.append(
|
||||||
|
_ChapterWindow(
|
||||||
|
clip["start_time"],
|
||||||
|
clip["end_time"],
|
||||||
|
max(0.0, clip["duration"] / 1000 - span),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return windows
|
||||||
|
|
||||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||||
|
|
||||||
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||||
if clipped_end <= clipped_start:
|
if clipped_end <= clipped_start:
|
||||||
continue
|
continue
|
||||||
|
# a staged window's keyframe lead-in plays before it
|
||||||
|
output_offset += _lead_in(rec)
|
||||||
windows.append((clipped_start, clipped_end, output_offset))
|
windows.append((clipped_start, clipped_end, output_offset))
|
||||||
output_offset += clipped_end - clipped_start
|
output_offset += clipped_end - clipped_start
|
||||||
|
|
||||||
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
|||||||
if duration_ms <= 0:
|
if duration_ms <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
# a staged window's keyframe lead-in opens its chapter, with
|
||||||
timespec="seconds"
|
# frames captured that long before the window
|
||||||
)
|
lead_in = _lead_in(rec)
|
||||||
|
duration_ms += int(round(lead_in * 1000))
|
||||||
|
title = datetime.datetime.fromtimestamp(
|
||||||
|
clipped_start - lead_in, tz=tz
|
||||||
|
).isoformat(timespec="seconds")
|
||||||
chapter_blocks.append(
|
chapter_blocks.append(
|
||||||
"[CHAPTER]\n"
|
"[CHAPTER]\n"
|
||||||
"TIMEBASE=1/1000\n"
|
"TIMEBASE=1/1000\n"
|
||||||
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
|||||||
if self.staged_runs:
|
if self.staged_runs:
|
||||||
# each run was already rendered to a temp file with a common
|
# each run was already rendered to a temp file with a common
|
||||||
# track timescale, so the concat demuxer has nothing left to
|
# track timescale, so the concat demuxer has nothing left to
|
||||||
# reconcile and every chapter offset lines up with the merged
|
# reconcile
|
||||||
# timeline the staged files reproduce
|
recordings = (
|
||||||
recordings = [
|
self._staged_chapter_windows()
|
||||||
_ChapterWindow(span_start, span_end)
|
if self.chapters not in (None, ChaptersEnum.none)
|
||||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
else []
|
||||||
]
|
)
|
||||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||||
ffmpeg_input = (
|
ffmpeg_input = (
|
||||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||||
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
|
|||||||
# its own rows are the ones the chapters describe
|
# its own rows are the ones the chapters describe
|
||||||
recordings = self._get_recordings_for_range(pin)
|
recordings = self._get_recordings_for_range(pin)
|
||||||
else:
|
else:
|
||||||
# never mix streams in one playlist; use main when available
|
# an unstaged auto range resolves to at most one stream run, and
|
||||||
# and fall back to sub for expired-main history
|
# its rows are the ones the chapters describe. Main rows the
|
||||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
# manifest drops (glitches, slivers at the edges of a sub range)
|
||||||
|
# must not stand in for it.
|
||||||
|
runs = self._stream_runs(self._merged_spans())
|
||||||
|
recordings = self._get_recordings_for_range(
|
||||||
|
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
||||||
|
)
|
||||||
|
|
||||||
if not recordings:
|
# never mix streams in one playlist; fall back to sub for
|
||||||
|
# expired-main history
|
||||||
|
if not recordings and not runs:
|
||||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||||
|
|
||||||
playlist_lines = []
|
playlist_lines = []
|
||||||
|
|||||||
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
reviews = reviews_by_camera[camera]
|
reviews = reviews_by_camera[camera]
|
||||||
|
|
||||||
tasks.extend(
|
# probes run concurrently, but each segment's start chains off the
|
||||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
# previous segment's end, so starts resolve in segment order
|
||||||
|
previous: asyncio.Event | None = None
|
||||||
|
for recording in recordings:
|
||||||
|
resolved = asyncio.Event()
|
||||||
|
tasks.append(
|
||||||
|
self._validate_in_order(
|
||||||
|
camera, reviews, recording, previous, resolved
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
previous = resolved
|
||||||
|
|
||||||
# publish most recently available recording time and None if disabled
|
# publish most recently available recording time and None if disabled
|
||||||
if stream_type == STREAM_TYPE_MAIN:
|
if stream_type == STREAM_TYPE_MAIN:
|
||||||
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
while info and info[0][0] < expire_before:
|
while info and info[0][0] < expire_before:
|
||||||
info.pop(0)
|
info.pop(0)
|
||||||
|
|
||||||
|
async def _validate_in_order(
|
||||||
|
self,
|
||||||
|
camera: str,
|
||||||
|
reviews: Any,
|
||||||
|
recording: dict[str, Any],
|
||||||
|
previous_start: asyncio.Event | None,
|
||||||
|
start_resolved: asyncio.Event,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Validate a segment, always releasing the next one in its stream."""
|
||||||
|
try:
|
||||||
|
return await self.validate_and_move_segment(
|
||||||
|
camera, reviews, recording, previous_start, start_resolved
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
start_resolved.set()
|
||||||
|
|
||||||
def drop_segment(self, cache_path: str) -> None:
|
def drop_segment(self, cache_path: str) -> None:
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
|
||||||
async def validate_and_move_segment(
|
async def validate_and_move_segment(
|
||||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
self,
|
||||||
|
camera: str,
|
||||||
|
reviews: Any,
|
||||||
|
recording: dict[str, Any],
|
||||||
|
previous_start: asyncio.Event | None = None,
|
||||||
|
start_resolved: asyncio.Event | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
cache_path: str = recording["cache_path"]
|
cache_path: str = recording["cache_path"]
|
||||||
start_time: datetime.datetime = recording["start_time"]
|
start_time: datetime.datetime = recording["start_time"]
|
||||||
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
async with self.probe_semaphore:
|
async with self.probe_semaphore:
|
||||||
keyframes = await get_keyframe_offsets(cache_path)
|
keyframes = await get_keyframe_offsets(cache_path)
|
||||||
|
|
||||||
|
if previous_start is not None:
|
||||||
|
await previous_start.wait()
|
||||||
|
|
||||||
start_time = self._resolve_segment_start(
|
start_time = self._resolve_segment_start(
|
||||||
camera, stream_type, start_time, duration, cache_path
|
camera, stream_type, start_time, duration, cache_path
|
||||||
)
|
)
|
||||||
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
RecordingsDataTypeEnum.valid.value,
|
RecordingsDataTypeEnum.valid.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# the start is settled, so the next segment of the stream can chain
|
||||||
|
# off it while this one waits on retention and the move
|
||||||
|
if start_resolved is not None:
|
||||||
|
start_resolved.set()
|
||||||
|
|
||||||
record_config = self.config.cameras[camera].record
|
record_config = self.config.cameras[camera].record
|
||||||
|
|
||||||
# sub's alerts/detections carry the retain mode directly, unlike
|
# sub's alerts/detections carry the retain mode directly, unlike
|
||||||
|
|||||||
@@ -256,6 +256,17 @@ class HardwareStats:
|
|||||||
)
|
)
|
||||||
self.update_config()
|
self.update_config()
|
||||||
|
|
||||||
|
def set_config(self, config: FrigateConfig) -> None:
|
||||||
|
"""Follow a runtime config swap and recalculate the monitored hardware.
|
||||||
|
|
||||||
|
The camera update subscriber has to follow too, or later camera updates
|
||||||
|
would land on the discarded config.
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self._config_subscriber.config = config
|
||||||
|
self._config_subscriber.camera_configs = config.cameras
|
||||||
|
self.update_config()
|
||||||
|
|
||||||
def update_config(self) -> None:
|
def update_config(self) -> None:
|
||||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||||
|
|||||||
@@ -111,15 +111,18 @@ def get_detector_stats(
|
|||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||||
detector_stats: dict[str, dict[str, Any]] = {}
|
detector_stats: dict[str, dict[str, Any]] = {}
|
||||||
detector_type_indices: dict[str, int] = {}
|
# detector type -> device -> index into that type's temperatures
|
||||||
|
device_indices: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
for name, detector in stats_tracking["detectors"].items():
|
for name, detector in stats_tracking["detectors"].items():
|
||||||
pid = detector.detect_process.pid if detector.detect_process else None
|
pid = detector.detect_process.pid if detector.detect_process else None
|
||||||
detector_type = detector.detector_config.type
|
detector_type = detector.detector_config.type
|
||||||
|
|
||||||
# Keep track of the index for each detector type to match temperatures correctly
|
# temperatures are per physical unit, so a repeated device
|
||||||
current_index = detector_type_indices.get(detector_type, 0)
|
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
||||||
detector_type_indices[detector_type] = current_index + 1
|
device = name.partition("#")[0]
|
||||||
|
type_devices = device_indices.setdefault(detector_type, {})
|
||||||
|
current_index = type_devices.setdefault(device, len(type_devices))
|
||||||
|
|
||||||
detector_stat = {
|
detector_stat = {
|
||||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import frigate.genai
|
import frigate.genai
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||||
from frigate.genai import GenAIClient
|
from frigate.genai import GenAIClient
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.stats.emitter import StatsEmitter
|
from frigate.stats.emitter import StatsEmitter
|
||||||
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
mqtt = response.json()["mqtt"]
|
mqtt = response.json()["mqtt"]
|
||||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||||
|
|
||||||
|
def test_config_response_keeps_plus_model_reference(self):
|
||||||
|
model_id = "test_plus_reference"
|
||||||
|
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||||
|
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
with open(model_path, "w") as f:
|
||||||
|
f.write("model")
|
||||||
|
|
||||||
|
with open(f"{model_path}.json", "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"id": model_id,
|
||||||
|
"type": "ssd",
|
||||||
|
"supportedDetectors": ["cpu"],
|
||||||
|
"width": 320,
|
||||||
|
"height": 320,
|
||||||
|
"inputShape": "nhwc",
|
||||||
|
"pixelFormat": "rgb",
|
||||||
|
"labelMap": {"0": "person"},
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addCleanup(os.remove, model_path)
|
||||||
|
self.addCleanup(os.remove, f"{model_path}.json")
|
||||||
|
self.minimal_config["models"] = [
|
||||||
|
{"path": f"plus://{model_id}", "devices": ["cpu"]}
|
||||||
|
]
|
||||||
|
app = super().create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.get("/config")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
|
||||||
|
|
||||||
|
# detection still loads the resolved cache file
|
||||||
|
assert app.frigate_config.models[0].path == model_path
|
||||||
|
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
################################### POST /genai/probe Endpoint ##################################################
|
################################### POST /genai/probe Endpoint ##################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
app.genai_manager.update_config.assert_called_once_with(config)
|
app.genai_manager.update_config.assert_called_once_with(config)
|
||||||
app.profile_manager.update_config.assert_called_once_with(config)
|
app.profile_manager.update_config.assert_called_once_with(config)
|
||||||
self.assertIs(app.stats_emitter.config, config)
|
self.assertIs(app.stats_emitter.config, config)
|
||||||
|
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
|
||||||
self.assertIs(app.dispatcher.config, config)
|
self.assertIs(app.dispatcher.config, config)
|
||||||
for comm in app.dispatcher.comms:
|
for comm in app.dispatcher.comms:
|
||||||
self.assertIs(comm.config, config)
|
self.assertIs(comm.config, config)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tests for per-detector stats."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from frigate.stats.util import get_detector_stats
|
||||||
|
|
||||||
|
|
||||||
|
def _detector(detector_type: str) -> MagicMock:
|
||||||
|
detector = MagicMock()
|
||||||
|
detector.detector_config.type = detector_type
|
||||||
|
detector.avg_inference_speed.value = 0.01
|
||||||
|
detector.detection_start.value = 0.0
|
||||||
|
detector.detect_process.pid = 1
|
||||||
|
return detector
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectorTemperatures(unittest.TestCase):
|
||||||
|
def test_repeated_device_shares_its_unit_temperature(self):
|
||||||
|
stats_tracking = {
|
||||||
|
"detectors": {
|
||||||
|
"hailo:PCIe": _detector("hailo8l"),
|
||||||
|
"hailo:PCIe#2": _detector("hailo8l"),
|
||||||
|
"hailo:PCIe:1": _detector("hailo8l"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
|
||||||
|
):
|
||||||
|
stats = get_detector_stats(stats_tracking)
|
||||||
|
|
||||||
|
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
|
||||||
|
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
|
||||||
|
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
|
|||||||
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportTimelineAlignment(unittest.TestCase):
|
||||||
|
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
|
||||||
|
# a sub-only range whose main rows are glitches the manifest drops
|
||||||
|
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
|
||||||
|
streams: list[str] = []
|
||||||
|
|
||||||
|
def rows(stream: str) -> list:
|
||||||
|
streams.append(stream)
|
||||||
|
return [_FakeRow(f"/{stream}.mp4")]
|
||||||
|
|
||||||
|
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
|
||||||
|
exporter.get_record_export_command("/exports/out.mp4")
|
||||||
|
|
||||||
|
self.assertEqual(streams, ["sub"])
|
||||||
|
|
||||||
|
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
|
||||||
|
exporter = _make_exporter(
|
||||||
|
[
|
||||||
|
_span("/m1.mp4", 1_000, 1_020, True),
|
||||||
|
_span("/s1.mp4", 1_020, 1_040, False),
|
||||||
|
],
|
||||||
|
{"h264"},
|
||||||
|
)
|
||||||
|
exporter.config.ui.timezone = None
|
||||||
|
|
||||||
|
# each run's vod clip snaps 1.5s back to a keyframe
|
||||||
|
def timeline(_intervals: list, stream: str) -> list[dict]:
|
||||||
|
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"start_time": start,
|
||||||
|
"end_time": end,
|
||||||
|
"duration": (end - start + 1.5) * 1000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("frigate.record.export.resolve_coverage", return_value=[]),
|
||||||
|
patch("frigate.record.export.realized_timeline", side_effect=timeline),
|
||||||
|
):
|
||||||
|
windows = exporter._staged_chapter_windows()
|
||||||
|
|
||||||
|
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
||||||
|
self.addCleanup(os.remove, path)
|
||||||
|
content = Path(path).read_text()
|
||||||
|
|
||||||
|
self.assertIn("START=0\nEND=21500", content)
|
||||||
|
self.assertIn("START=21500\nEND=43000", content)
|
||||||
|
|
||||||
|
|
||||||
class TestStagedFileCleanup(unittest.TestCase):
|
class TestStagedFileCleanup(unittest.TestCase):
|
||||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
|
|||||||
|
|
||||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||||
|
|
||||||
|
def test_follows_a_runtime_config_swap(self):
|
||||||
|
stats = self.make_stats(self.make_config())
|
||||||
|
self.assertEqual(set(stats._monitored), set())
|
||||||
|
|
||||||
|
swapped = self.make_config("preset-rk-h264")
|
||||||
|
stats.set_config(swapped)
|
||||||
|
|
||||||
|
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||||
|
self.assertIs(self.subscriber.return_value.config, swapped)
|
||||||
|
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateStats(HardwareStatsTestCase):
|
class TestUpdateStats(HardwareStatsTestCase):
|
||||||
def run_stats(self, stats: HardwareStats) -> dict:
|
def run_stats(self, stats: HardwareStats) -> dict:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
# Mock complex imports before importing maintainer, saving originals so we can
|
# Mock complex imports before importing maintainer, saving originals so we can
|
||||||
# restore them after import and avoid polluting sys.modules for other tests.
|
# restore them after import and avoid polluting sys.modules for other tests.
|
||||||
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||||
):
|
):
|
||||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||||
# Mock validate_and_move_segment to avoid further logic
|
# Mock validate_and_move_segment to avoid further logic.
|
||||||
maintainer.validate_and_move_segment = MagicMock()
|
# The requestor is real when another test imported the
|
||||||
|
# maintainer first, and it would block on a reply.
|
||||||
|
maintainer.validate_and_move_segment = AsyncMock()
|
||||||
|
maintainer.requestor = MagicMock()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await maintainer.move_files()
|
await maintainer.move_files()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
||||||
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
||||||
|
|
||||||
|
async def test_out_of_order_probes_chain_in_segment_order(self):
|
||||||
|
maintainer = _build_chaining_maintainer(self.T0)
|
||||||
|
|
||||||
|
async def probe(_ffmpeg, cache_path, get_duration=False):
|
||||||
|
# the earlier segment's probe finishes last
|
||||||
|
if "chain0" in cache_path:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
return {"has_valid_video": True, "duration": 10.4}
|
||||||
|
|
||||||
|
recordings = [
|
||||||
|
{
|
||||||
|
"start_time": datetime.datetime.fromtimestamp(
|
||||||
|
self.T0 + offset, tz=datetime.UTC
|
||||||
|
),
|
||||||
|
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
|
||||||
|
"stream_type": "main",
|
||||||
|
}
|
||||||
|
for offset in (0, 10)
|
||||||
|
]
|
||||||
|
first_resolved = asyncio.Event()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||||
|
patch(
|
||||||
|
"frigate.record.maintainer.get_keyframe_offsets",
|
||||||
|
AsyncMock(return_value=[0]),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"frigate.record.maintainer.os.path.getmtime",
|
||||||
|
MagicMock(side_effect=OSError("missing")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await asyncio.gather(
|
||||||
|
maintainer._validate_in_order(
|
||||||
|
"test_cam", [], recordings[0], None, first_resolved
|
||||||
|
),
|
||||||
|
maintainer._validate_in_order(
|
||||||
|
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
starts = sorted(
|
||||||
|
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
|
||||||
|
)
|
||||||
|
self.assertEqual(starts[0], self.T0)
|
||||||
|
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
maintainer.last_segment_end[("test_cam", "main")],
|
||||||
|
self.T0 + 20.8,
|
||||||
|
places=3,
|
||||||
|
)
|
||||||
|
|
||||||
async def test_genuine_gap_is_not_snapped(self):
|
async def test_genuine_gap_is_not_snapped(self):
|
||||||
maintainer = _build_chaining_maintainer(self.T0)
|
maintainer = _build_chaining_maintainer(self.T0)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# (connect, read) seconds. The read timeout bounds each socket read rather than
|
||||||
|
# the whole download, so large models still finish.
|
||||||
|
DOWNLOAD_TIMEOUT = (15, 60)
|
||||||
|
|
||||||
# target path -> first line of the last download error for it; every existing
|
# target path -> first line of the last download error for it; every existing
|
||||||
# download function swallows its exceptions, so this is how the downloader
|
# download function swallows its exceptions, so this is how the downloader
|
||||||
# thread learns why a file is still missing
|
# thread learns why a file is still missing
|
||||||
@@ -124,7 +128,9 @@ class ModelDownloader:
|
|||||||
logger.info(f"Downloading model file from: {url}")
|
logger.info(f"Downloading model file from: {url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with requests.get(url, stream=True, allow_redirects=True) as r:
|
with requests.get(
|
||||||
|
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
|
||||||
|
) as r:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
with open(temporary_filename, "wb") as f:
|
with open(temporary_filename, "wb") as f:
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
|||||||
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def null_audio_glitches(
|
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
|
||||||
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
|
|
||||||
) -> list[CoverageInterval]:
|
|
||||||
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
||||||
|
|
||||||
nginx-vod requires every clip in a sequence to carry the same track
|
nginx-vod requires every clip in a sequence to carry the same track
|
||||||
count, so a truncated video-only segment (a backend restart can flush
|
count, so a truncated video-only segment (a backend restart can flush
|
||||||
a sub-second file before any audio packet landed) poisons every
|
a sub-second file before any audio packet landed) poisons every
|
||||||
manifest that includes it. Nulling the row turns the glitch into a
|
manifest that includes it. Nulling the row turns the glitch into a
|
||||||
hole the span builder skips like any recording gap.
|
hole the span builder skips like any recording gap. Every consumer of
|
||||||
|
a window's coverage (the vod manifest, its realized timelines, and
|
||||||
|
exports) goes through here, so they all agree on which rows exist.
|
||||||
"""
|
"""
|
||||||
|
main_audio = stream_has_audio(intervals, main=True)
|
||||||
|
sub_audio = stream_has_audio(intervals, main=False)
|
||||||
result: list[CoverageInterval] = []
|
result: list[CoverageInterval] = []
|
||||||
for interval in intervals:
|
for interval in intervals:
|
||||||
main = interval.main
|
main = interval.main
|
||||||
@@ -449,9 +451,7 @@ def realized_timelines(
|
|||||||
assembles each variant's realized spans. Keyframe snapping reads the
|
assembles each variant's realized spans. Keyframe snapping reads the
|
||||||
per-row index stored at record time, so no file is touched.
|
per-row index stored at record time, so no file is touched.
|
||||||
"""
|
"""
|
||||||
main_audio = stream_has_audio(intervals, main=True)
|
nulled = null_audio_glitches(intervals)
|
||||||
sub_audio = stream_has_audio(intervals, main=False)
|
|
||||||
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"auto": realized_timeline(nulled, None),
|
"auto": realized_timeline(nulled, None),
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
|
||||||
* the persisted layout, and measuring rendered tiles.
|
|
||||||
*
|
|
||||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
|
||||||
* keys by username, and every write is an async idb put. A test that seeds the
|
|
||||||
* bare key, or seeds before the app's own first write has landed, silently
|
|
||||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
|
||||||
* holes, so prefer it over building the key by hand.
|
|
||||||
*
|
|
||||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
|
||||||
* then reflows narrower once useResizeObserver reports the real container.
|
|
||||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
|
||||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
|
||||||
*
|
|
||||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export type LayoutItem = {
|
|
||||||
i: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PersistedLayout = {
|
|
||||||
version: number;
|
|
||||||
naturalAspect: boolean;
|
|
||||||
layout: LayoutItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function layoutKeySuffix(group: string): string {
|
|
||||||
return `${group}-draggable-layout`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The key the app has actually written an envelope to, or undefined while its
|
|
||||||
* first write is still in flight.
|
|
||||||
*/
|
|
||||||
function findWrittenKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(suffix) =>
|
|
||||||
new Promise<string | undefined>((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const store = open.result
|
|
||||||
.transaction("keyval", "readonly")
|
|
||||||
.objectStore("keyval");
|
|
||||||
// getAllKeys and getAll both return in key order, so the indexes align
|
|
||||||
const keys = store.getAllKeys();
|
|
||||||
const values = store.getAll();
|
|
||||||
keys.transaction.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
const names = keys.result as string[];
|
|
||||||
const stored = values.result as { version?: number }[];
|
|
||||||
const match = names.findIndex(
|
|
||||||
(name, index) =>
|
|
||||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
|
||||||
typeof stored[index]?.version === "number",
|
|
||||||
);
|
|
||||||
resolve(match === -1 ? undefined : names[match]);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
layoutKeySuffix(group),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the grid to persist its own layout, then return the key it used.
|
|
||||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
|
||||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
|
||||||
*/
|
|
||||||
export async function persistedLayoutKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string> {
|
|
||||||
let key: string | undefined;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `grid never persisted a layout for group "${group}"`,
|
|
||||||
})
|
|
||||||
.not.toBeUndefined();
|
|
||||||
|
|
||||||
return key!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
|
||||||
export function seedLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
return page.evaluate(
|
|
||||||
([key, value]) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readwrite");
|
|
||||||
tx.objectStore("keyval").put(value, key as string);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
};
|
|
||||||
open.onerror = () => reject(open.error);
|
|
||||||
}),
|
|
||||||
[key, value] as const,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the stored layout back. Undefined until the app writes it. */
|
|
||||||
export function readLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
): Promise<PersistedLayout | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(target) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readonly");
|
|
||||||
const request = tx.objectStore("keyval").get(target);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
key,
|
|
||||||
) as Promise<PersistedLayout | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Box = { w: number; h: number; x: number; y: number };
|
|
||||||
|
|
||||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
|
||||||
export type BoxTarget = "card" | "cell";
|
|
||||||
|
|
||||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
|
||||||
function snapshotBoxes(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly string[],
|
|
||||||
target: BoxTarget,
|
|
||||||
): Promise<Record<string, Box> | null> {
|
|
||||||
return page.evaluate(
|
|
||||||
({ cams, target }) => {
|
|
||||||
const boxes: Record<string, Box> = {};
|
|
||||||
|
|
||||||
for (const cam of cams) {
|
|
||||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
|
||||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
// a re-rendering tile can briefly report no box at all
|
|
||||||
if (!r.width || !r.height) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
return boxes;
|
|
||||||
},
|
|
||||||
{ cams: cameras as readonly string[], target },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure the given cameras' tiles together, once they have all rendered.
|
|
||||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
|
||||||
*/
|
|
||||||
export async function cameraBoxes<T extends string>(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly T[],
|
|
||||||
target: BoxTarget = "cell",
|
|
||||||
): Promise<Record<T, Box>> {
|
|
||||||
let boxes: Record<string, Box> | null = null;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
|
||||||
})
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
return boxes as unknown as Record<T, Box>;
|
|
||||||
}
|
|
||||||
@@ -45,11 +45,6 @@ export class LivePage extends BasePage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
|
||||||
get editLayoutButton(): Locator {
|
|
||||||
return this.page.getByTestId("toggle-edit-layout");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the right-click context menu on a camera card (desktop only). */
|
/** Open the right-click context menu on a camera card (desktop only). */
|
||||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live grid aspect modes.
|
|
||||||
*
|
|
||||||
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
|
|
||||||
* tile, and converts layouts saved by pre-masonry versions instead of
|
|
||||||
* discarding them. Natural mode sizes each tile to its own camera.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
type LayoutItem,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor";
|
|
||||||
const GRID_COLS = 96;
|
|
||||||
|
|
||||||
test.describe("Live grid aspect modes @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { backyard: wide, front_door: normal } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
|
|
||||||
expect(wide.w / normal.w).toBeCloseTo(2, 1);
|
|
||||||
expect(wide.h).toBeCloseTo(normal.h, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a letterboxed still image rounds its own corners", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
|
|
||||||
// overflow-hidden clip never reaches the picture's corners. The image has
|
|
||||||
// to carry the radius itself or it renders with square edges on the tile.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const radii = await frigateApp.page.evaluate(() => {
|
|
||||||
const card = document.querySelector("[data-camera='backyard']");
|
|
||||||
const img = card?.querySelector("img");
|
|
||||||
return {
|
|
||||||
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
|
|
||||||
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(radii.card).not.toBe("0px");
|
|
||||||
expect(radii.img).toBe(radii.card);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pre-masonry layout is converted, keeping resized tiles", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
|
|
||||||
// backyard was manually resized to 8x8 and sits beside front_door's column,
|
|
||||||
// front_door is a standard tile on the row below.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
|
|
||||||
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The conversion is written back on first load, replacing the legacy array
|
|
||||||
// with an envelope. Poll for it: that write is an async idb put.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
|
|
||||||
|
|
||||||
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
|
|
||||||
// standard tile), so the manual resize survives instead of snapping back.
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 32,
|
|
||||||
y: 0,
|
|
||||||
w: 64,
|
|
||||||
h: 144,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 0,
|
|
||||||
y: 144,
|
|
||||||
w: 32,
|
|
||||||
h: 72,
|
|
||||||
});
|
|
||||||
|
|
||||||
// arrangement on screen: backyard indented, front_door below it
|
|
||||||
const { backyard, front_door: frontDoor } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
|
|
||||||
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("conversion is a pure scale, so odd sizes and positions survive", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The old grid exposed all four resize corners with no aspect constraint,
|
|
||||||
// so a stored tile can be any size. These two are adjacent and non-standard.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
|
|
||||||
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
const frontDoor = stored.layout.find(
|
|
||||||
(i: LayoutItem) => i.i === "front_door",
|
|
||||||
)!;
|
|
||||||
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
|
|
||||||
|
|
||||||
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
|
|
||||||
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
|
|
||||||
|
|
||||||
// still adjacent, still inside the grid, still not overlapping
|
|
||||||
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
|
|
||||||
expect(backyard.x + backyard.w).toBe(GRID_COLS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masonry live grid -- custom-group draggable layout.
|
|
||||||
*
|
|
||||||
* Verifies natural-aspect tile sizing and that a saved layout the current
|
|
||||||
* version cannot read is regenerated cleanly. The grid renders only for a
|
|
||||||
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
|
|
||||||
* grid, which the @mobile block below guards.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor"; // custom group: front_door + backyard
|
|
||||||
|
|
||||||
test.describe("Masonry live grid @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable masonry grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("custom group renders its cameras in the draggable grid", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tiles render at their camera's natural aspect ratio", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["front_door", "backyard"] as const,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
|
|
||||||
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dragging a tile does not shove other tiles far away", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const cameras = ["front_door", "backyard"] as const;
|
|
||||||
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drag backyard onto front_door's position (a deliberate collision). With
|
|
||||||
// free-placement + prevent-collision, front_door must NOT be shoved down.
|
|
||||||
const from = {
|
|
||||||
x: draggedBox.x + draggedBox.w / 2,
|
|
||||||
y: draggedBox.y + draggedBox.h / 2,
|
|
||||||
};
|
|
||||||
const to = {
|
|
||||||
x: fixedBefore.x + fixedBefore.w / 2,
|
|
||||||
y: fixedBefore.y + fixedBefore.h / 2,
|
|
||||||
};
|
|
||||||
await frigateApp.page.mouse.move(from.x, from.y);
|
|
||||||
await frigateApp.page.mouse.down();
|
|
||||||
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
|
|
||||||
await frigateApp.page.mouse.up();
|
|
||||||
|
|
||||||
const { front_door: fixedAfter } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// Allow a few px of snap; a collision-push would move it a whole tile down.
|
|
||||||
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A lone top tile has room to grow sideways, which is what exposed the bug:
|
|
||||||
// a top-edge handle let width grow while height stayed clamped at y=0.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const tile = frigateApp.page.locator(".react-grid-item", {
|
|
||||||
has: frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
});
|
|
||||||
const only = ["front_door"] as const;
|
|
||||||
const { front_door: before } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
const aspect = before.w / before.h;
|
|
||||||
|
|
||||||
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
|
|
||||||
// distort the aspect (the old bug grew width while height stayed clamped).
|
|
||||||
const ne = tile.locator(".react-resizable-handle-ne");
|
|
||||||
if (await ne.count()) {
|
|
||||||
await ne.dragTo(tile, {
|
|
||||||
force: true,
|
|
||||||
targetPosition: { x: 1000, y: -160 },
|
|
||||||
});
|
|
||||||
const { front_door: afterNe } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// It must actually resize (not a silent no-op) AND keep its aspect.
|
|
||||||
expect(afterNe.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positive: growing from the bottom-right corner resizes and keeps aspect.
|
|
||||||
const se = tile.locator(".react-resizable-handle-se");
|
|
||||||
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
|
|
||||||
const { front_door: grown } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
expect(grown.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the grid keeps its measured width after a back navigation", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// The grid sizes itself from window.innerWidth until its container is
|
|
||||||
// measured. On a warm back navigation nothing re-renders after that
|
|
||||||
// container mounts, so an observer that never attaches leaves every tile
|
|
||||||
// sized against the full window: the layout widens by the sidebar's width
|
|
||||||
// and the rightmost column clips on a full row.
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// total width the tiles span; tracks the width the grid laid out against
|
|
||||||
const span = () =>
|
|
||||||
frigateApp.page.evaluate(() => {
|
|
||||||
const tiles = [...document.querySelectorAll(".react-grid-item")];
|
|
||||||
|
|
||||||
if (!tiles.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rects = tiles.map((tile) => tile.getBoundingClientRect());
|
|
||||||
return +(
|
|
||||||
Math.max(...rects.map((r) => r.right)) -
|
|
||||||
Math.min(...rects.map((r) => r.left))
|
|
||||||
).toFixed(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let fresh: number | null = null;
|
|
||||||
await expect
|
|
||||||
.poll(async () => (fresh = await span()), { timeout: 10_000 })
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
await live.cameraCard("front_door").first().click();
|
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
|
||||||
|
|
||||||
await frigateApp.page.goBack();
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the layout must settle back to the measured width, not window.innerWidth
|
|
||||||
await expect
|
|
||||||
.poll(span, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(fresh! + 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a camera added to a saved layout fills an open column", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// one tall tile in the first column; backyard is missing from the layout
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
|
|
||||||
});
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const stored = await readLayout(frigateApp.page, key);
|
|
||||||
return stored?.layout.find((item) => item.i === "backyard");
|
|
||||||
})
|
|
||||||
.toMatchObject({ x: 32, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("saved layout from an unreadable version regenerates without error", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A bare array is converted rather than discarded (covered in
|
|
||||||
// live-grid-aspect-modes), so use a version the current grid cannot read.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 1,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
|
|
||||||
// Grid regenerated; both cameras still render and the error collector
|
|
||||||
// (frigate-test fixture) catches any crash.
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// The app must have replaced the value it could not read. Without this the
|
|
||||||
// test would still pass against a key the app never touches.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Masonry live grid on mobile @critical @mobile", () => {
|
|
||||||
test("custom group keeps the static grid, with no draggable layout", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
|
||||||
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, false);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
|
|
||||||
// grid items nor the edit-layout toggle may appear.
|
|
||||||
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
|
|
||||||
await expect(live.editLayoutButton).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -217,6 +217,23 @@ test.describe("Detection models settings @high", () => {
|
|||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shareable hardware another model uses can still be picked", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installRoutes(frigateApp.page, [
|
||||||
|
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||||
|
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
|
||||||
|
]);
|
||||||
|
await openPage(frigateApp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").first(),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
|
||||||
|
"used by outdoor",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("adding a model appends a card with an unused scene", async ({
|
test("adding a model appends a card with an unused scene", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
|
|||||||
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
// the backend resolves plus:// to a cache path before serving the config
|
|
||||||
// back, so the plus metadata is the only signal the model is a Plus one
|
|
||||||
await installRoutes(
|
await installRoutes(
|
||||||
frigateApp.page,
|
frigateApp.page,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
scene: "all",
|
scene: "all",
|
||||||
devices: ["openvino:GPU.0"],
|
devices: ["openvino:GPU.0"],
|
||||||
path: "/config/model_cache/abc123",
|
path: "plus://abc123",
|
||||||
plus: PLUS_MODEL,
|
plus: PLUS_MODEL,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -302,6 +317,40 @@ test.describe("Detection models settings @high", () => {
|
|||||||
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("saving a Frigate+ model keeps its reference without the Frigate+ fields", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// the backend fills these in from the Frigate+ model info when it loads
|
||||||
|
const saves = await installRoutes(
|
||||||
|
frigateApp.page,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
scene: "all",
|
||||||
|
devices: ["openvino:GPU.0"],
|
||||||
|
path: "plus://abc123",
|
||||||
|
plus: PLUS_MODEL,
|
||||||
|
width: 320,
|
||||||
|
height: 320,
|
||||||
|
input_tensor: "nchw",
|
||||||
|
model_type: "yolo-generic",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await openPage(frigateApp);
|
||||||
|
|
||||||
|
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
|
||||||
|
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
||||||
|
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const model = saves.at(-1)?.config_data?.models?.[0];
|
||||||
|
expect(model?.path).toBe("plus://abc123");
|
||||||
|
expect(model?.devices).toEqual(["openvino:GPU.0", "openvino:GPU.1"]);
|
||||||
|
expect(model).not.toHaveProperty("width");
|
||||||
|
expect(model).not.toHaveProperty("input_tensor");
|
||||||
|
expect(model).not.toHaveProperty("model_type");
|
||||||
|
});
|
||||||
|
|
||||||
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
|
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -13,26 +13,7 @@ import type { Page } from "@playwright/test";
|
|||||||
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
||||||
const STREAMING_KEY = "streaming-settings:admin";
|
const STREAMING_KEY = "streaming-settings:admin";
|
||||||
|
|
||||||
// the shape DraggableGridLayout writes
|
const OUTDOOR_LAYOUT = [
|
||||||
const OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const NATURAL_OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: true,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_OUTDOOR_LAYOUT = [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
||||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||||
];
|
];
|
||||||
@@ -178,160 +159,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
expect(payload.sections.preferences.playbackRate).toBe(2);
|
expect(payload.sections.preferences.playbackRate).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exports only layouts built for the current tile sizing mode", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// a group not opened since the mode changed still holds a layout from
|
|
||||||
// the other mode, which would import into a mode that cannot show it
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
|
||||||
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
"naturalAspectLayout:admin": true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
|
|
||||||
|
|
||||||
expect(payload.sections.layouts).toEqual({
|
|
||||||
default: NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggling tile sizing mode clears stored layouts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
|
|
||||||
|
|
||||||
await frigateApp.page.locator("#natural-aspect-desktop").click();
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("alertdialog")
|
|
||||||
.getByRole("button", { name: "Enable" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
|
|
||||||
.toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("round-trips a layout left unconverted by an upgrade", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
|
|
||||||
// dashboard is opened, so exporting first carries the bare array into the
|
|
||||||
// file. Import must accept it back rather than rejecting the whole file.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
"playbackRate:admin": 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const contents = readFileSync((await download.path())!, "utf-8");
|
|
||||||
|
|
||||||
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
await clearIdb(frigateApp.page);
|
|
||||||
await chooseImportText(frigateApp.page, contents);
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
// the rest of the file must survive alongside it
|
|
||||||
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy layouts import turns natural aspect off so they display", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// Bare-array layouts only render in bucketed mode; with natural aspect on
|
|
||||||
// they would be discarded and regenerated on the next dashboard visit. The
|
|
||||||
// import applies the mode the layouts were built for, and the file's own
|
|
||||||
// naturalAspectLayout preference must not override that.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: { naturalAspectLayout: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const note = frigateApp.page.getByText(/standard tile sizing/);
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
// the note is about the layouts section, so it follows its switch
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeHidden();
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("natural aspect layouts import turns the setting on", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("omits settings that were never stored", async ({ frigateApp }) => {
|
test("omits settings that were never stored", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
@@ -363,15 +190,12 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByText("UI preferences (2 settings)"),
|
frigateApp.page.getByText("UI preferences (2 settings)"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// patio is layout-only, so its warning follows the layouts section
|
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
|
|
||||||
visible: !frigateApp.isMobile,
|
|
||||||
});
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
@@ -382,7 +206,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
test("hides the unknown-group warning when layouts are switched off", async ({
|
test("hides the unknown-group warning when layouts are switched off", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
// patio is a layout-only group absent from this server, so the warning
|
// patio is a layout-only group absent from this server, so the warning
|
||||||
@@ -412,39 +235,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Phone-only");
|
|
||||||
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
|
|
||||||
|
|
||||||
await chooseImportFile(frigateApp.page, importPayload());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/aren't imported on phones/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// the section is still listed, but cannot be switched on
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
|
|
||||||
).toBeDisabled();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
|
||||||
STREAMING_SETTINGS,
|
|
||||||
);
|
|
||||||
// a layouts import is what flips this, so it must stay put
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +325,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"clear": "Clear",
|
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
|||||||
@@ -167,11 +167,6 @@
|
|||||||
"label": "Always Show Camera Names",
|
"label": "Always Show Camera Names",
|
||||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||||
},
|
},
|
||||||
"naturalAspectLayout": {
|
|
||||||
"label": "Use Natural Aspect Ratios",
|
|
||||||
"desc": "On camera group live view dashboards, size each tile to its camera's own natural aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.",
|
|
||||||
"descNote": "Toggling this setting on or off will clear the stored layout for all camera group live dashboards. Manual reconfiguration will be required."
|
|
||||||
},
|
|
||||||
"liveFallbackTimeout": {
|
"liveFallbackTimeout": {
|
||||||
"label": "Live Player Fallback Timeout",
|
"label": "Live Player Fallback Timeout",
|
||||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||||
@@ -180,14 +175,12 @@
|
|||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
"title": "Stored Layouts",
|
"title": "Stored Layouts",
|
||||||
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Layouts",
|
"clearAll": "Clear All Layouts"
|
||||||
"clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"cameraGroupStreaming": {
|
"cameraGroupStreaming": {
|
||||||
"title": "Camera Group Streaming Settings",
|
"title": "Camera Group Streaming Settings",
|
||||||
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Streaming Settings",
|
"clearAll": "Clear All Streaming Settings"
|
||||||
"clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"backupRestore": {
|
"backupRestore": {
|
||||||
"title": "Backup & Restore",
|
"title": "Backup & Restore",
|
||||||
@@ -202,9 +195,6 @@
|
|||||||
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
||||||
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
||||||
"layouts_one": "Camera group layouts ({{count}} group)",
|
"layouts_one": "Camera group layouts ({{count}} group)",
|
||||||
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
|
|
||||||
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".",
|
|
||||||
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural Aspect Ratios\".",
|
|
||||||
"layouts_other": "Camera group layouts ({{count}} groups)",
|
"layouts_other": "Camera group layouts ({{count}} groups)",
|
||||||
"streaming_one": "Streaming settings ({{count}} camera)",
|
"streaming_one": "Streaming settings ({{count}} camera)",
|
||||||
"streaming_other": "Streaming settings ({{count}} cameras)",
|
"streaming_other": "Streaming settings ({{count}} cameras)",
|
||||||
@@ -2063,7 +2053,7 @@
|
|||||||
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
||||||
"description": "The hardware this model runs its detection on.",
|
"description": "The hardware this model runs its detection on.",
|
||||||
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
||||||
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
|
"unitsDescription": "Each unit runs its own detection process. A unit that can't be shared, such as a Coral, can only be used by one model."
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"plus": "Frigate+",
|
"plus": "Frigate+",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function CameraImage({
|
|||||||
)}
|
)}
|
||||||
{!imageLoaded && enabled ? (
|
{!imageLoaded && enabled ? (
|
||||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
<ActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RJSFSchema } from "@rjsf/utils";
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
|
import { omit } from "lodash";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { isJsonObject } from "@/lib/utils";
|
import { isJsonObject } from "@/lib/utils";
|
||||||
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||||
@@ -352,6 +353,17 @@ export function synthesizeMissingFilters(
|
|||||||
return { ...(data as JsonObject), filters: newFilters };
|
return { ...(data as JsonObject), filters: newFilters };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the backend fills these from the Frigate+ model info when it loads a
|
||||||
|
// plus:// model, so saving them would only pin values Frigate+ owns
|
||||||
|
const PLUS_SUPPLIED_MODEL_FIELDS = [
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"input_tensor",
|
||||||
|
"input_pixel_format",
|
||||||
|
"input_dtype",
|
||||||
|
"model_type",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize overrides payloads for section-specific quirks.
|
* Sanitize overrides payloads for section-specific quirks.
|
||||||
*/
|
*/
|
||||||
@@ -360,6 +372,21 @@ export function sanitizeOverridesForSection(
|
|||||||
level: string,
|
level: string,
|
||||||
overrides: unknown,
|
overrides: unknown,
|
||||||
): unknown {
|
): unknown {
|
||||||
|
// the models list is saved whole
|
||||||
|
if (sectionPath === "models" && Array.isArray(overrides)) {
|
||||||
|
return overrides.map((model) => {
|
||||||
|
if (
|
||||||
|
!isJsonObject(model) ||
|
||||||
|
typeof model.path !== "string" ||
|
||||||
|
!model.path.startsWith("plus://")
|
||||||
|
) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
return omit(model, PLUS_SUPPLIED_MODEL_FIELDS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!overrides || !isJsonObject(overrides)) {
|
if (!overrides || !isJsonObject(overrides)) {
|
||||||
return overrides;
|
return overrides;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ type HardwarePickerProps = {
|
|||||||
// scopes the unit checkbox ids, since several models can list the same unit
|
// scopes the unit checkbox ids, since several models can list the same unit
|
||||||
idPrefix: string;
|
idPrefix: string;
|
||||||
devices: string[];
|
devices: string[];
|
||||||
// device strings already taken by another model, mapped to that model's scene
|
// device strings already taken by another model, mapped to that model's
|
||||||
|
// scene. Only binding for hardware that can't be shared.
|
||||||
claimedElsewhere: Record<string, string>;
|
claimedElsewhere: Record<string, string>;
|
||||||
cameraCount: number;
|
cameraCount: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -84,8 +85,11 @@ export function HardwarePicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// start with the first unit no other model has taken
|
// start with the first unit no other model has taken, falling back to
|
||||||
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
|
// a taken one when the hardware can be shared
|
||||||
|
const free =
|
||||||
|
entry.units.find((unit) => !claimedElsewhere[unit.device]) ??
|
||||||
|
(entry.unlimited ? entry.units[0] : undefined);
|
||||||
|
|
||||||
if (!free) {
|
if (!free) {
|
||||||
onChange([]);
|
onChange([]);
|
||||||
@@ -184,7 +188,9 @@ export function HardwarePicker({
|
|||||||
{t("detectionModels.hardware.unitsDescription")}
|
{t("detectionModels.hardware.unitsDescription")}
|
||||||
</p>
|
</p>
|
||||||
{selected.units.map((unit) => {
|
{selected.units.map((unit) => {
|
||||||
const claimedBy = claimedElsewhere[unit.device];
|
const claimedBy = selected.unlimited
|
||||||
|
? undefined
|
||||||
|
: claimedElsewhere[unit.device];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ function plusModelId(path: unknown): string | undefined {
|
|||||||
|
|
||||||
type ModelSourcePickerProps = {
|
type ModelSourcePickerProps = {
|
||||||
path: unknown;
|
path: unknown;
|
||||||
// Frigate+ metadata the backend attaches to a saved model, and the only
|
// Frigate+ metadata the backend attaches to a saved model
|
||||||
// reliable signal that one is active: it resolves `plus://<id>` to a local
|
|
||||||
// cache path before serving the config back
|
|
||||||
plus?: { id: string } | null;
|
plus?: { id: string } | null;
|
||||||
// the detector this model runs on, used to filter incompatible Plus models
|
// the detector this model runs on, used to filter incompatible Plus models
|
||||||
detector?: string;
|
detector?: string;
|
||||||
|
|||||||
@@ -169,20 +169,23 @@ export function ModelsField(props: FieldProps) {
|
|||||||
[savedModels],
|
[savedModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
// a model serves the cameras naming its scene, plus every camera that names
|
// a model serves the cameras naming its scene, and like the backend, the
|
||||||
// no scene at all when it is the "all" model
|
// "all" model also serves every camera whose scene has no model of its own
|
||||||
const cameraCountForScene = useCallback(
|
const cameraCountForScene = useCallback(
|
||||||
(scene: string | undefined): number => {
|
(scene: string | undefined): number => {
|
||||||
if (!cameras) {
|
if (!cameras) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelScenes = new Set(models.map((model) => model.scene ?? "all"));
|
||||||
|
|
||||||
return Object.values(cameras).filter((camera) => {
|
return Object.values(cameras).filter((camera) => {
|
||||||
const cameraScene = camera?.detect?.scene;
|
const cameraScene = camera?.detect?.scene ?? "all";
|
||||||
return cameraScene ? cameraScene === scene : scene === "all";
|
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all";
|
||||||
|
return servedBy === (scene ?? "all");
|
||||||
}).length;
|
}).length;
|
||||||
},
|
},
|
||||||
[cameras],
|
[cameras, models],
|
||||||
);
|
);
|
||||||
|
|
||||||
const claimedByOtherModels = useCallback(
|
const claimedByOtherModels = useCallback(
|
||||||
@@ -314,7 +317,9 @@ export function ModelsField(props: FieldProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={`${baseId}-${index}`} className="w-full">
|
// keyed by scene, which is unique per model, so deleting a card
|
||||||
|
// doesn't hand its state (such as the model source tab) to the next
|
||||||
|
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(nextOpen) =>
|
onOpenChange={(nextOpen) =>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
"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",
|
||||||
upperClassName,
|
upperClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
"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",
|
||||||
lowerClassName,
|
lowerClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
import { LuTriangleAlert } from "react-icons/lu";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import {
|
import {
|
||||||
importedLayoutsNaturalAspect,
|
|
||||||
ImportSummary,
|
ImportSummary,
|
||||||
TransferSection,
|
TransferSection,
|
||||||
UiSettingsFile,
|
UiSettingsFile,
|
||||||
@@ -27,7 +25,6 @@ type ImportUiSettingsDialogProps = {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
file: UiSettingsFile;
|
file: UiSettingsFile;
|
||||||
summary: ImportSummary;
|
summary: ImportSummary;
|
||||||
currentNaturalAspect: boolean;
|
|
||||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
fileName,
|
fileName,
|
||||||
file,
|
file,
|
||||||
summary,
|
summary,
|
||||||
currentNaturalAspect,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ImportUiSettingsDialogProps) {
|
}: ImportUiSettingsDialogProps) {
|
||||||
const { t } = useTranslation(["views/settings", "common"]);
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
@@ -45,9 +41,7 @@ export default function ImportUiSettingsDialog({
|
|||||||
|
|
||||||
const available = useMemo(
|
const available = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
// phones use the static grid, so a saved grid layout has nothing to
|
layouts: summary.layoutGroupCount > 0,
|
||||||
// apply to and would only flip the tile sizing mode behind the scenes
|
|
||||||
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
|
|
||||||
streaming: summary.streamingCameraCount > 0,
|
streaming: summary.streamingCameraCount > 0,
|
||||||
preferences: summary.preferenceCount > 0,
|
preferences: summary.preferenceCount > 0,
|
||||||
}),
|
}),
|
||||||
@@ -96,16 +90,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
[sections.streaming, summary.unknownCameras],
|
[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 () => {
|
const handleConfirm = useCallback(async () => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
await onConfirm(sections);
|
await onConfirm(sections);
|
||||||
@@ -129,23 +113,17 @@ export default function ImportUiSettingsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
|
<p className="break-all text-base text-primary-variant">{fileName}</p>
|
||||||
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
|
<p className="text-sm text-muted-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", {
|
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||||
date: exportedDate,
|
date: exportedDate,
|
||||||
version: file.frigate_version,
|
version: file.frigate_version,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-3">
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
label={t("general.backupRestore.importDialog.layouts", {
|
label={t("general.backupRestore.importDialog.layouts", {
|
||||||
count: summary.layoutGroupCount,
|
count: summary.layoutGroupCount,
|
||||||
@@ -178,28 +156,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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 ||
|
{(visibleUnknownGroups.length > 0 ||
|
||||||
visibleUnknownCameras.length > 0) && (
|
visibleUnknownCameras.length > 0) && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
@@ -224,7 +180,6 @@ export default function ImportUiSettingsDialog({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -243,7 +198,7 @@ export default function ImportUiSettingsDialog({
|
|||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<ActivityIndicator className="size-4" />
|
<ActivityIndicator />
|
||||||
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,10 +29,22 @@ export default function BirdseyeLivePlayer({
|
|||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
let player;
|
let player;
|
||||||
if (liveMode == "webrtc") {
|
if (liveMode == "webrtc") {
|
||||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
player = (
|
||||||
|
<WebRtcPlayer
|
||||||
|
className={`size-full rounded-lg md:rounded-2xl`}
|
||||||
|
camera="birdseye"
|
||||||
|
pip={pip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (liveMode == "mse") {
|
} else if (liveMode == "mse") {
|
||||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
player = (
|
||||||
|
<MSEPlayer
|
||||||
|
className={`size-full rounded-lg md:rounded-2xl`}
|
||||||
|
camera="birdseye"
|
||||||
|
pip={pip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
player = (
|
player = (
|
||||||
<div className="w-5xl text-center text-sm">
|
<div className="w-5xl text-center text-sm">
|
||||||
@@ -43,7 +55,7 @@ export default function BirdseyeLivePlayer({
|
|||||||
} else if (liveMode == "jsmpeg") {
|
} else if (liveMode == "jsmpeg") {
|
||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
className="flex size-full justify-center overflow-hidden"
|
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||||
camera="birdseye"
|
camera="birdseye"
|
||||||
width={birdseyeConfig.width}
|
width={birdseyeConfig.width}
|
||||||
height={birdseyeConfig.height}
|
height={birdseyeConfig.height}
|
||||||
@@ -53,31 +65,22 @@ export default function BirdseyeLivePlayer({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
player = <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
"relative flex w-full cursor-pointer justify-center",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div
|
<ImageShadowOverlay
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
upperClassName="md:rounded-2xl"
|
||||||
style={
|
lowerClassName="md:rounded-2xl"
|
||||||
{
|
/>
|
||||||
"--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}>
|
<div className="size-full" ref={playerRef}>
|
||||||
{player}
|
{player}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
|||||||
// Android native hls does not seek correctly
|
// Android native hls does not seek correctly
|
||||||
const USE_NATIVE_HLS = false;
|
const USE_NATIVE_HLS = false;
|
||||||
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
||||||
|
const DEFAULT_MAX_BUFFER_LENGTH_S = 10;
|
||||||
const unsupportedErrorCodes: number[] = [
|
const unsupportedErrorCodes: number[] = [
|
||||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||||
MediaError.MEDIA_ERR_DECODE,
|
MediaError.MEDIA_ERR_DECODE,
|
||||||
@@ -148,6 +149,42 @@ export default function HlsVideoPlayer({
|
|||||||
// a ref rather than an effect-scoped counter so the element error
|
// a ref rather than an effect-scoped counter so the element error
|
||||||
// handler can hold its toast while a recovery is still possible
|
// handler can hold its toast while a recovery is still possible
|
||||||
const mediaRecoveryBudgetRef = useRef(0);
|
const mediaRecoveryBudgetRef = useRef(0);
|
||||||
|
// hls.js and the element can both report one failure, so each source
|
||||||
|
// toasts at most once
|
||||||
|
const failureReportedRef = useRef(false);
|
||||||
|
|
||||||
|
const reportPlaybackFailure = useCallback(
|
||||||
|
(code: number, message: string) => {
|
||||||
|
if (failureReportedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failureReportedRef.current = true;
|
||||||
|
toast.error(t("toast.error.playRecordingsFailed", { code, message }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const reportPlaybackFailureRef = useRef(reportPlaybackFailure);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reportPlaybackFailureRef.current = reportPlaybackFailure;
|
||||||
|
}, [reportPlaybackFailure]);
|
||||||
|
|
||||||
|
// a quality switch changes the buffer length a commit before the new
|
||||||
|
// source arrives, so it updates the live instance instead of rebuilding
|
||||||
|
// it on the outgoing playlist
|
||||||
|
const bufferLengthRef = useRef(bufferLength);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bufferLengthRef.current = bufferLength;
|
||||||
|
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.config.maxBufferLength =
|
||||||
|
bufferLength ?? DEFAULT_MAX_BUFFER_LENGTH_S;
|
||||||
|
}
|
||||||
|
}, [bufferLength]);
|
||||||
|
|
||||||
const applyVideoDimensions = useCallback(
|
const applyVideoDimensions = useCallback(
|
||||||
(width: number, height: number) => {
|
(width: number, height: number) => {
|
||||||
@@ -225,6 +262,7 @@ export default function HlsVideoPlayer({
|
|||||||
// the element already holds a decoded frame, and keeping it visible
|
// the element already holds a decoded frame, and keeping it visible
|
||||||
// bridges the gap while the new source loads
|
// bridges the gap while the new source loads
|
||||||
const currentPlaybackRate = videoRef.current.playbackRate;
|
const currentPlaybackRate = videoRef.current.playbackRate;
|
||||||
|
failureReportedRef.current = false;
|
||||||
|
|
||||||
if (!useHlsCompat) {
|
if (!useHlsCompat) {
|
||||||
nativeRetryRef.current = 0;
|
nativeRetryRef.current = 0;
|
||||||
@@ -236,7 +274,7 @@ export default function HlsVideoPlayer({
|
|||||||
|
|
||||||
// Base HLS configuration
|
// Base HLS configuration
|
||||||
const hlsConfig: Partial<HlsConfig> = {
|
const hlsConfig: Partial<HlsConfig> = {
|
||||||
maxBufferLength: bufferLength ?? 10,
|
maxBufferLength: bufferLengthRef.current ?? DEFAULT_MAX_BUFFER_LENGTH_S,
|
||||||
maxBufferSize: 20 * 1000 * 1000,
|
maxBufferSize: 20 * 1000 * 1000,
|
||||||
startPosition: currentSource.startPosition,
|
startPosition: currentSource.startPosition,
|
||||||
};
|
};
|
||||||
@@ -269,10 +307,18 @@ export default function HlsVideoPlayer({
|
|||||||
data.details ===
|
data.details ===
|
||||||
Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR ||
|
Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR ||
|
||||||
data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR;
|
data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR;
|
||||||
if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) {
|
if (isCodecError) {
|
||||||
|
// with no stream to fall back to, the element may never raise
|
||||||
|
// an error of its own, so report the failure here
|
||||||
|
if (!qualitySignalsRef.current.onFatalCodecError?.()) {
|
||||||
|
reportPlaybackFailureRef.current(
|
||||||
|
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||||
|
data.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCodecError && mediaRecoveryBudgetRef.current > 0) {
|
if (mediaRecoveryBudgetRef.current > 0) {
|
||||||
mediaRecoveryBudgetRef.current -= 1;
|
mediaRecoveryBudgetRef.current -= 1;
|
||||||
hls.recoverMediaError();
|
hls.recoverMediaError();
|
||||||
}
|
}
|
||||||
@@ -308,7 +354,7 @@ export default function HlsVideoPlayer({
|
|||||||
hlsRef.current.destroy();
|
hlsRef.current.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]);
|
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||||
|
|
||||||
// state handling
|
// state handling
|
||||||
|
|
||||||
@@ -714,15 +760,7 @@ export default function HlsVideoPlayer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(
|
reportPlaybackFailure(mediaError.code, mediaError.message);
|
||||||
t("toast.error.playRecordingsFailed", {
|
|
||||||
code: mediaError.code,
|
|
||||||
message: mediaError.message,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ type LivePlayerProps = {
|
|||||||
onError?: (error: LivePlayerError) => void;
|
onError?: (error: LivePlayerError) => void;
|
||||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||||
onResetLiveMode?: () => void;
|
onResetLiveMode?: () => void;
|
||||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivePlayer({
|
export default function LivePlayer({
|
||||||
@@ -81,7 +80,6 @@ export default function LivePlayer({
|
|||||||
onError,
|
onError,
|
||||||
onMicrophoneError,
|
onMicrophoneError,
|
||||||
onResetLiveMode,
|
onResetLiveMode,
|
||||||
onLiveAspectChange,
|
|
||||||
}: LivePlayerProps) {
|
}: LivePlayerProps) {
|
||||||
const { t } = useTranslation(["components/player"]);
|
const { t } = useTranslation(["components/player"]);
|
||||||
|
|
||||||
@@ -129,37 +127,6 @@ export default function LivePlayer({
|
|||||||
// camera live state
|
// camera live state
|
||||||
|
|
||||||
const [liveReady, setLiveReady] = useState(false);
|
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 liveReadyRef = useRef(liveReady);
|
||||||
const cameraActiveRef = useRef(cameraActive);
|
const cameraActiveRef = useRef(cameraActive);
|
||||||
@@ -295,12 +262,11 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<WebRtcPlayer
|
<WebRtcPlayer
|
||||||
key={"webrtc_" + key}
|
key={"webrtc_" + key}
|
||||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
getStats={showStats}
|
getStats={showStats}
|
||||||
setStats={setStats}
|
setStats={setStats}
|
||||||
setFullResolution={handleFullResolution}
|
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
microphoneEnabled={micEnabled}
|
microphoneEnabled={micEnabled}
|
||||||
@@ -316,7 +282,7 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<MSEPlayer
|
<MSEPlayer
|
||||||
key={"mse_" + key}
|
key={"mse_" + key}
|
||||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
@@ -326,7 +292,7 @@ export default function LivePlayer({
|
|||||||
setStats={setStats}
|
setStats={setStats}
|
||||||
onPlaying={playerIsPlaying}
|
onPlaying={playerIsPlaying}
|
||||||
pip={pip}
|
pip={pip}
|
||||||
setFullResolution={handleFullResolution}
|
setFullResolution={setFullResolution}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -342,7 +308,7 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
key={"jsmpeg_" + key}
|
key={"jsmpeg_" + key}
|
||||||
className="flex justify-center overflow-hidden"
|
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
width={cameraConfig.detect.width}
|
width={cameraConfig.detect.width}
|
||||||
height={cameraConfig.detect.height}
|
height={cameraConfig.detect.height}
|
||||||
@@ -359,9 +325,7 @@ export default function LivePlayer({
|
|||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
player = (
|
player = <ActivityIndicator />;
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -376,12 +340,10 @@ export default function LivePlayer({
|
|||||||
}}
|
}}
|
||||||
data-camera={cameraConfig.name}
|
data-camera={cameraConfig.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
"relative flex w-full cursor-pointer justify-center outline",
|
||||||
// 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 &&
|
activeTracking &&
|
||||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||||
: "outline-0 outline-background",
|
: "outline-0 outline-background",
|
||||||
"transition-all duration-500",
|
"transition-all duration-500",
|
||||||
className,
|
className,
|
||||||
@@ -395,14 +357,10 @@ export default function LivePlayer({
|
|||||||
>
|
>
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||||
<div
|
<ImageShadowOverlay
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
upperClassName="md:rounded-2xl"
|
||||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
lowerClassName="md:rounded-2xl"
|
||||||
>
|
/>
|
||||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
|
||||||
<ImageShadowOverlay />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{player}
|
{player}
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
@@ -410,7 +368,7 @@ export default function LivePlayer({
|
|||||||
(!showStillWithoutActivity || isReEnabling) &&
|
(!showStillWithoutActivity || isReEnabling) &&
|
||||||
!liveReady && (
|
!liveReady && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
<ActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -489,7 +447,7 @@ export default function LivePlayer({
|
|||||||
|
|
||||||
{offline && inDashboard && (
|
{offline && inDashboard && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<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="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 className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||||
<div>{t("streamOffline.title")}</div>
|
<div>{t("streamOffline.title")}</div>
|
||||||
@@ -533,7 +491,7 @@ export default function LivePlayer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!cameraEnabled && (
|
{!cameraEnabled && (
|
||||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
<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">
|
<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" />
|
<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">
|
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
LivePlayerError,
|
LivePlayerError,
|
||||||
PlayerStatsType,
|
PlayerStatsType,
|
||||||
TwoWayTalkError,
|
TwoWayTalkError,
|
||||||
VideoResolutionType,
|
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||||
@@ -21,7 +20,6 @@ type WebRtcPlayerProps = {
|
|||||||
pip?: boolean;
|
pip?: boolean;
|
||||||
getStats?: boolean;
|
getStats?: boolean;
|
||||||
setStats?: (stats: PlayerStatsType) => void;
|
setStats?: (stats: PlayerStatsType) => void;
|
||||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
onError?: (error: LivePlayerError) => void;
|
onError?: (error: LivePlayerError) => void;
|
||||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||||
@@ -38,7 +36,6 @@ export default function WebRtcPlayer({
|
|||||||
pip = false,
|
pip = false,
|
||||||
getStats = false,
|
getStats = false,
|
||||||
setStats,
|
setStats,
|
||||||
setFullResolution,
|
|
||||||
onPlaying,
|
onPlaying,
|
||||||
onError,
|
onError,
|
||||||
onMicrophoneError,
|
onMicrophoneError,
|
||||||
@@ -345,12 +342,6 @@ export default function WebRtcPlayer({
|
|||||||
if (videoLoadTimeoutRef.current) {
|
if (videoLoadTimeoutRef.current) {
|
||||||
clearTimeout(videoLoadTimeoutRef.current);
|
clearTimeout(videoLoadTimeoutRef.current);
|
||||||
}
|
}
|
||||||
if (videoRef.current) {
|
|
||||||
setFullResolution?.({
|
|
||||||
width: videoRef.current.videoWidth,
|
|
||||||
height: videoRef.current.videoHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onPlaying?.();
|
onPlaying?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -340,8 +340,11 @@ export class AutoQualityGovernor {
|
|||||||
private triggerDownswitch(reason: DownswitchReason): boolean {
|
private triggerDownswitch(reason: DownswitchReason): boolean {
|
||||||
const handled = this.requestDownswitch(reason);
|
const handled = this.requestDownswitch(reason);
|
||||||
if (handled) {
|
if (handled) {
|
||||||
// the low stream starts with a clean record
|
// the low stream starts with a clean record, and the probe lets a
|
||||||
|
// recovered connection (or a wrong downswitch) return to full
|
||||||
|
// quality mid-chunk rather than at the next boundary
|
||||||
this.resetStallHistory();
|
this.resetStallHistory();
|
||||||
|
this.armUpswitchProbe();
|
||||||
}
|
}
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,9 +484,6 @@ export default function DynamicVideoPlayer({
|
|||||||
}
|
}
|
||||||
setAutoLowQuality(true);
|
setAutoLowQuality(true);
|
||||||
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
|
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
|
||||||
// so a recovered connection (or a wrong downswitch) returns to
|
|
||||||
// full quality mid-chunk rather than at the next boundary
|
|
||||||
governor.armUpswitchProbe();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
tryUpswitchRef.current = () => {
|
tryUpswitchRef.current = () => {
|
||||||
@@ -495,7 +492,7 @@ export default function DynamicVideoPlayer({
|
|||||||
setAutoLowReason(undefined);
|
setAutoLowReason(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resolvedQuality, subAvailable, governor]);
|
}, [resolvedQuality, subAvailable]);
|
||||||
|
|
||||||
// persisted across sessions so a device on a known-slow connection
|
// persisted across sessions so a device on a known-slow connection
|
||||||
// starts low instead of paying the first stall to find out
|
// starts low instead of paying the first stall to find out
|
||||||
@@ -616,7 +613,19 @@ export default function DynamicVideoPlayer({
|
|||||||
governor.sourceLoadStarted();
|
governor.sourceLoadStarted();
|
||||||
}, [source, isScrubbing, governor]);
|
}, [source, isScrubbing, governor]);
|
||||||
|
|
||||||
|
const lastChunkRef = useRef(timeRange);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// the seed effect picks the starting quality, so only a later chunk
|
||||||
|
// boundary reconsiders it
|
||||||
|
const lastChunk = lastChunkRef.current;
|
||||||
|
lastChunkRef.current = timeRange;
|
||||||
|
if (
|
||||||
|
lastChunk.after === timeRange.after &&
|
||||||
|
lastChunk.before === timeRange.before
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// a chunk boundary is where full quality may be retried, and a
|
// a chunk boundary is where full quality may be retried, and a
|
||||||
// natural point to persist what the governor has learned
|
// natural point to persist what the governor has learned
|
||||||
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
|
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export function evaluateStreamWebRTCAvailability(args: {
|
|||||||
/** Console detail for a reason decided by the global availability check. */
|
/** Console detail for a reason decided by the global availability check. */
|
||||||
function describeGlobalReason(
|
function describeGlobalReason(
|
||||||
reason: WebRTCUnavailableReason,
|
reason: WebRTCUnavailableReason,
|
||||||
testStream: string | undefined,
|
|
||||||
probeDetail: string | undefined,
|
probeDetail: string | undefined,
|
||||||
): string {
|
): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
@@ -88,7 +87,7 @@ function describeGlobalReason(
|
|||||||
case "not-configured":
|
case "not-configured":
|
||||||
return "No candidates or ice_servers are set under go2rtc.webrtc.";
|
return "No candidates or ice_servers are set under go2rtc.webrtc.";
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
|
return `The connectivity probe failed against ${probeDetail ?? "an unknown stream"}.`;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -98,9 +97,13 @@ function describeGlobalReason(
|
|||||||
let lastProbeSignature: string | null = null;
|
let lastProbeSignature: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Once-per-session: browser support, go2rtc config, and a live handshake probe.
|
* Browser support, go2rtc config, and a live handshake probe shared by the
|
||||||
|
* page. A failed probe is retried on a later mount or when the page becomes
|
||||||
|
* visible again once its cached result has expired.
|
||||||
*/
|
*/
|
||||||
export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
export function useWebRTCGloballyAvailable(
|
||||||
|
preferredStream?: string,
|
||||||
|
): GlobalAvailability {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [probe, setProbe] = useState<{
|
const [probe, setProbe] = useState<{
|
||||||
state: "pending" | "pass" | "fail";
|
state: "pending" | "pass" | "fail";
|
||||||
@@ -118,11 +121,19 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
|||||||
);
|
);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
// Representative restreamed stream to probe against.
|
// the stream being viewed is the one most likely to be online
|
||||||
const testStream = useMemo(() => {
|
const testStreams = useMemo(() => {
|
||||||
const streams = config?.go2rtc?.streams ?? {};
|
const streams = Object.keys(config?.go2rtc?.streams ?? {});
|
||||||
return Object.keys(streams)[0];
|
|
||||||
}, [config]);
|
if (!preferredStream || !streams.includes(preferredStream)) {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [preferredStream, ...streams.filter((s) => s !== preferredStream)];
|
||||||
|
}, [config, preferredStream]);
|
||||||
|
const testStreamsKey = testStreams.join("\n");
|
||||||
|
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
|
||||||
const iceServers = useMemo(
|
const iceServers = useMemo(
|
||||||
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
|
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
|
||||||
@@ -136,9 +147,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
|
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
|
||||||
iceServers,
|
iceServers,
|
||||||
testStream,
|
streams: Object.keys(config?.go2rtc?.streams ?? {}),
|
||||||
}),
|
}),
|
||||||
[config, iceServers, testStream],
|
[config, iceServers],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,11 +164,29 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
|||||||
}, [probeSignature]);
|
}, [probeSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!browserOk || !configured || !testStream) {
|
if (probe.state !== "fail") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
setRetryToken((token) => token + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
}, [probe.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const streams = testStreamsKey ? testStreamsKey.split("\n") : [];
|
||||||
|
|
||||||
|
if (!browserOk || !configured || streams.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
probeWebRTCAvailability(testStream, iceServers).then((result) => {
|
probeWebRTCAvailability(streams, iceServers).then((result) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProbe({
|
setProbe({
|
||||||
state: result.ok ? "pass" : "fail",
|
state: result.ok ? "pass" : "fail",
|
||||||
@@ -168,7 +197,7 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [browserOk, configured, testStream, iceServers]);
|
}, [browserOk, configured, testStreamsKey, iceServers, retryToken]);
|
||||||
|
|
||||||
const availability = useMemo<GlobalAvailability>(() => {
|
const availability = useMemo<GlobalAvailability>(() => {
|
||||||
if (!browserOk) {
|
if (!browserOk) {
|
||||||
@@ -196,9 +225,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
|||||||
logWebRTCUnavailable(
|
logWebRTCUnavailable(
|
||||||
undefined,
|
undefined,
|
||||||
reason,
|
reason,
|
||||||
describeGlobalReason(reason, testStream, probe.detail),
|
describeGlobalReason(reason, probe.detail),
|
||||||
);
|
);
|
||||||
}, [config, availability, testStream, probe.detail]);
|
}, [config, availability, probe.detail]);
|
||||||
|
|
||||||
return availability;
|
return availability;
|
||||||
}
|
}
|
||||||
@@ -208,7 +237,8 @@ export function useWebRTCAvailableForStream(
|
|||||||
metadata: LiveStreamMetadata | null | undefined,
|
metadata: LiveStreamMetadata | null | undefined,
|
||||||
streamName?: string,
|
streamName?: string,
|
||||||
): StreamAvailability {
|
): StreamAvailability {
|
||||||
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
|
const { globallyAvailable, globalReason } =
|
||||||
|
useWebRTCGloballyAvailable(streamName);
|
||||||
|
|
||||||
const availability = useMemo(
|
const availability = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -175,21 +175,6 @@ html {
|
|||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner
|
|
||||||
bracket on the real se handle (drop-shadow keeps it legible over footage). */
|
|
||||||
.grid-layout .react-resizable-handle-se::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-right: 2.5px solid rgba(233, 238, 246, 0.92);
|
|
||||||
border-bottom: 2.5px solid rgba(233, 238, 246, 0.92);
|
|
||||||
border-bottom-right-radius: 3px;
|
|
||||||
filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9));
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.react-grid-placeholder {
|
.react-grid-item.react-grid-placeholder {
|
||||||
border: 3px solid #a00000 !important;
|
border: 3px solid #a00000 !important;
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ export default function Events() {
|
|||||||
setMotionSearchDay(normalizedDay);
|
setMotionSearchDay(normalizedDay);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [notificationTab, setNotificationTab] = useState<TimelineType>();
|
const [notificationTab, setNotificationTab] =
|
||||||
|
useState<TimelineType>("timeline");
|
||||||
|
|
||||||
const getReviewDayBounds = useCallback((date: Date) => {
|
const getReviewDayBounds = useCallback((date: Date) => {
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
|
|||||||
@@ -54,12 +54,6 @@ export const TRANSFER_KEYS: TransferKey[] = [
|
|||||||
namespaced: true,
|
namespaced: true,
|
||||||
schema: z.boolean(),
|
schema: z.boolean(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "naturalAspectLayout",
|
|
||||||
section: "preferences",
|
|
||||||
namespaced: true,
|
|
||||||
schema: z.boolean(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "alertVideos",
|
key: "alertVideos",
|
||||||
section: "preferences",
|
section: "preferences",
|
||||||
@@ -188,22 +182,13 @@ const layoutItemSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
// a group whose dashboard has not been opened since upgrading still holds
|
|
||||||
// the pre-0.19 bare array, so both shapes reach the file
|
|
||||||
const storedLayoutSchema = z.union([
|
|
||||||
z.array(layoutItemSchema),
|
|
||||||
z
|
|
||||||
.object({ version: z.number(), layout: z.array(layoutItemSchema) })
|
|
||||||
.passthrough(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const uiSettingsFileSchema = z.object({
|
export const uiSettingsFileSchema = z.object({
|
||||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||||
version: z.number().int().positive(),
|
version: z.number().int().positive(),
|
||||||
exported_at: z.string(),
|
exported_at: z.string(),
|
||||||
frigate_version: z.string(),
|
frigate_version: z.string(),
|
||||||
sections: z.object({
|
sections: z.object({
|
||||||
layouts: z.record(z.string(), storedLayoutSchema),
|
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||||
streaming: allGroupsStreamingSettingsSchema,
|
streaming: allGroupsStreamingSettingsSchema,
|
||||||
preferences: z.record(z.string(), z.unknown()),
|
preferences: z.record(z.string(), z.unknown()),
|
||||||
}),
|
}),
|
||||||
@@ -219,8 +204,6 @@ export async function buildExportPayload(
|
|||||||
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
||||||
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
||||||
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
||||||
const naturalAspect =
|
|
||||||
(await readTransferable("naturalAspectLayout", true, username)) === true;
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
groupNames.map(async (group) => {
|
groupNames.map(async (group) => {
|
||||||
@@ -230,9 +213,7 @@ export async function buildExportPayload(
|
|||||||
username,
|
username,
|
||||||
);
|
);
|
||||||
|
|
||||||
// a group not opened since the mode changed still holds a layout from
|
if (value !== undefined) {
|
||||||
// the other mode, which the grid discards, so leave it out of the file
|
|
||||||
if (value !== undefined && layoutIsNatural(value) === naturalAspect) {
|
|
||||||
layouts[group] = value;
|
layouts[group] = value;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -403,30 +384,6 @@ export function summarizeImport(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare arrays are pre-masonry bucketed layouts
|
|
||||||
function layoutIsNatural(layout: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof layout === "object" &&
|
|
||||||
layout !== null &&
|
|
||||||
!Array.isArray(layout) &&
|
|
||||||
(layout as { naturalAspect?: unknown }).naturalAspect === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A layout only renders under the mode that built it, so importing layouts
|
|
||||||
// applies this mode too. Exports hold a single mode.
|
|
||||||
export function importedLayoutsNaturalAspect(
|
|
||||||
file: UiSettingsFile,
|
|
||||||
): boolean | null {
|
|
||||||
const layouts = Object.values(file.sections.layouts);
|
|
||||||
|
|
||||||
if (!layouts.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return layouts.some(layoutIsNatural);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||||
return (
|
return (
|
||||||
summary.layoutGroupCount > 0 ||
|
summary.layoutGroupCount > 0 ||
|
||||||
@@ -441,9 +398,6 @@ export async function applyImportPayload(
|
|||||||
username: string | undefined,
|
username: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const writes: Promise<void>[] = [];
|
const writes: Promise<void>[] = [];
|
||||||
const layoutsMode = sections.layouts
|
|
||||||
? importedLayoutsNaturalAspect(file)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (sections.layouts) {
|
if (sections.layouts) {
|
||||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||||
@@ -454,15 +408,6 @@ export async function applyImportPayload(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (layoutsMode !== null) {
|
|
||||||
writes.push(
|
|
||||||
setData(
|
|
||||||
getUserNamespacedKey("naturalAspectLayout", username),
|
|
||||||
layoutsMode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamingEntry = TRANSFER_KEYS.find(
|
const streamingEntry = TRANSFER_KEYS.find(
|
||||||
@@ -502,12 +447,6 @@ export async function applyImportPayload(
|
|||||||
if (sections.preferences) {
|
if (sections.preferences) {
|
||||||
validPreferenceEntries(file.sections.preferences).forEach(
|
validPreferenceEntries(file.sections.preferences).forEach(
|
||||||
({ entry, value }) => {
|
({ entry, value }) => {
|
||||||
// the layouts must win this key or they import into a mode that
|
|
||||||
// cannot display them
|
|
||||||
if (entry.key === "naturalAspectLayout" && layoutsMode !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writes.push(setData(storageKey(entry, username), value));
|
writes.push(setData(storageKey(entry, username), value));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a single real WebRTC handshake against go2rtc to verify that a
|
* Performs a real WebRTC handshake against go2rtc to verify that a media
|
||||||
* media connection can actually be established (validates candidates, port
|
* connection can actually be established (validates candidates, port 8555
|
||||||
* 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
|
* reachability, and STUN/TURN end-to-end). A success is cached for the page
|
||||||
* session via a module-level promise.
|
* session, a failure only for PROBE_FAILURE_TTL_MS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type WebRTCProbeResult = {
|
export type WebRTCProbeResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
// go2rtc could not open the stream's source, which says nothing about
|
||||||
|
// whether WebRTC itself can connect
|
||||||
|
streamError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let probePromise: Promise<WebRTCProbeResult> | null = null;
|
const PROBE_FAILURE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
// streams tried per probe when go2rtc refuses the earlier ones
|
||||||
|
const MAX_PROBE_STREAMS = 3;
|
||||||
|
|
||||||
|
let cachedProbe: {
|
||||||
|
promise: Promise<WebRTCProbeResult>;
|
||||||
|
failedAt?: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
function describeError(err: unknown): string {
|
function describeError(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
@@ -118,23 +129,67 @@ function runProbe(
|
|||||||
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
|
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
|
||||||
(err) => fail(`remote answer rejected: ${describeError(err)}`),
|
(err) => fail(`remote answer rejected: ${describeError(err)}`),
|
||||||
);
|
);
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
cleanup({ ok: false, streamError: true, detail: msg.value });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runProbes(
|
||||||
|
testStreams: string[],
|
||||||
|
iceServers: RTCIceServer[],
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<WebRTCProbeResult> {
|
||||||
|
let result: WebRTCProbeResult = { ok: false, detail: "no stream to probe" };
|
||||||
|
|
||||||
|
for (const stream of testStreams.slice(0, MAX_PROBE_STREAMS)) {
|
||||||
|
result = await runProbe(stream, iceServers, timeoutMs);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
result.detail = `stream '${stream}': ${result.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.streamError) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probes the streams in order, moving on only when go2rtc refuses one. */
|
||||||
export function probeWebRTCAvailability(
|
export function probeWebRTCAvailability(
|
||||||
testStream: string,
|
testStreams: string[],
|
||||||
iceServers: RTCIceServer[],
|
iceServers: RTCIceServer[],
|
||||||
timeoutMs: number = 5000,
|
timeoutMs: number = 5000,
|
||||||
): Promise<WebRTCProbeResult> {
|
): Promise<WebRTCProbeResult> {
|
||||||
if (!probePromise) {
|
if (
|
||||||
probePromise = runProbe(testStream, iceServers, timeoutMs);
|
cachedProbe &&
|
||||||
|
(cachedProbe.failedAt === undefined ||
|
||||||
|
Date.now() - cachedProbe.failedAt < PROBE_FAILURE_TTL_MS)
|
||||||
|
) {
|
||||||
|
return cachedProbe.promise;
|
||||||
}
|
}
|
||||||
return probePromise;
|
|
||||||
|
const entry: NonNullable<typeof cachedProbe> = {
|
||||||
|
promise: runProbes(testStreams, iceServers, timeoutMs)
|
||||||
|
.catch((err): WebRTCProbeResult => ({
|
||||||
|
ok: false,
|
||||||
|
detail: describeError(err),
|
||||||
|
}))
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) {
|
||||||
|
entry.failedAt = Date.now();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
cachedProbe = entry;
|
||||||
|
return entry.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears the cached probe result (e.g. when go2rtc config changes). */
|
/** Clears the cached probe result (e.g. when go2rtc config changes). */
|
||||||
export function resetWebRTCProbe(): void {
|
export function resetWebRTCProbe(): void {
|
||||||
probePromise = null;
|
cachedProbe = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ import {
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
LayoutItem,
|
LayoutItem,
|
||||||
ResponsiveGridLayout as Responsive,
|
ResponsiveGridLayout as Responsive,
|
||||||
} from "react-grid-layout";
|
} from "react-grid-layout";
|
||||||
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
import {
|
import {
|
||||||
@@ -29,11 +28,12 @@ import {
|
|||||||
StatsState,
|
StatsState,
|
||||||
VolumeState,
|
VolumeState,
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||||
import LivePlayer from "@/components/player/LivePlayer";
|
import LivePlayer from "@/components/player/LivePlayer";
|
||||||
import { IoClose } from "react-icons/io5";
|
import { IoClose } from "react-icons/io5";
|
||||||
@@ -52,43 +52,6 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
|||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w *
|
|
||||||
// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS
|
|
||||||
// also sets resize granularity: the aspect constraint derives height from
|
|
||||||
// width, making one column the smallest step in both axes.
|
|
||||||
const GRID_COLS = 96;
|
|
||||||
const TILE_BASE_W = 32;
|
|
||||||
const TILE_WIDE_W = 64;
|
|
||||||
const VERTICAL_RESOLUTION = 4;
|
|
||||||
const DEFAULT_ASPECT = 16 / 9;
|
|
||||||
// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities.
|
|
||||||
const TILE_ASPECT_WIDE = 32 / 9;
|
|
||||||
const TILE_ASPECT_TALL = 8 / 9;
|
|
||||||
|
|
||||||
// Cells quantize to whole rows/columns, so the card takes the camera's exact
|
|
||||||
// ratio and fits itself inside its cell. --ar and container-type live on the
|
|
||||||
// cell; min() picks whichever axis binds first.
|
|
||||||
const CARD_FIT =
|
|
||||||
"h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]";
|
|
||||||
|
|
||||||
// Stored coordinates are grid units, so bump this whenever GRID_COLS or
|
|
||||||
// VERTICAL_RESOLUTION changes in a released version.
|
|
||||||
const LAYOUT_VERSION = 2;
|
|
||||||
type PersistedLayout = {
|
|
||||||
version: number;
|
|
||||||
naturalAspect: boolean;
|
|
||||||
layout: Layout;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Without preventCollision, RGL shoves collided tiles down the page and never
|
|
||||||
// compacts them back.
|
|
||||||
const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true);
|
|
||||||
|
|
||||||
// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was
|
|
||||||
// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly.
|
|
||||||
const LEGACY_GRID_COLS = 12;
|
|
||||||
const LEGACY_TILE_ROWS = 4;
|
|
||||||
|
|
||||||
type DraggableGridLayoutProps = {
|
type DraggableGridLayoutProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
cameraGroup: string;
|
cameraGroup: string;
|
||||||
@@ -135,66 +98,6 @@ export default function DraggableGridLayout({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
|
|
||||||
const aspectRatios = useMemo(() => {
|
|
||||||
const map: { [key: string]: number } = {};
|
|
||||||
if (birdseyeConfig) {
|
|
||||||
map["birdseye"] =
|
|
||||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1);
|
|
||||||
}
|
|
||||||
cameras.forEach((camera) => {
|
|
||||||
map[camera.name] =
|
|
||||||
camera.detect.width / camera.detect.height || DEFAULT_ASPECT;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [cameras, birdseyeConfig]);
|
|
||||||
|
|
||||||
const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence(
|
|
||||||
"naturalAspectLayout",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// phones never reach this grid, and the setting is hidden there, so an
|
|
||||||
// imported or stale value must not take effect
|
|
||||||
const naturalAspectLayout = !isMobileOnly && (naturalAspectSetting ?? false);
|
|
||||||
|
|
||||||
// Bucketed mode snaps every camera to one of three tile shapes, matching the
|
|
||||||
// pre-masonry layout; the picture letterboxes inside its bucket.
|
|
||||||
const layoutAspects = useMemo(() => {
|
|
||||||
if (naturalAspectLayout) {
|
|
||||||
return aspectRatios;
|
|
||||||
}
|
|
||||||
const map: { [key: string]: number } = {};
|
|
||||||
Object.entries(aspectRatios).forEach(([name, ratio]) => {
|
|
||||||
map[name] =
|
|
||||||
ratio > ASPECT_WIDE_LAYOUT
|
|
||||||
? TILE_ASPECT_WIDE
|
|
||||||
: ratio < 1
|
|
||||||
? TILE_ASPECT_TALL
|
|
||||||
: DEFAULT_ASPECT;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [aspectRatios, naturalAspectLayout]);
|
|
||||||
|
|
||||||
// A live stream can be shaped differently than detect, so the card follows
|
|
||||||
// whatever is on screen and falls back to detect. Cells stay detect-sized, so
|
|
||||||
// only the card resizes.
|
|
||||||
const [liveAspects, setLiveAspects] = useState<{
|
|
||||||
[key: string]: number | undefined;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
const liveAspectHandlers = useMemo(() => {
|
|
||||||
const map: { [key: string]: (aspectRatio: number | undefined) => void } =
|
|
||||||
{};
|
|
||||||
cameras.forEach((camera) => {
|
|
||||||
map[camera.name] = (aspectRatio) =>
|
|
||||||
setLiveAspects((prev) =>
|
|
||||||
prev[camera.name] === aspectRatio
|
|
||||||
? prev
|
|
||||||
: { ...prev, [camera.name]: aspectRatio },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [cameras]);
|
|
||||||
|
|
||||||
// preferred live modes per camera
|
// preferred live modes per camera
|
||||||
|
|
||||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||||
@@ -212,31 +115,7 @@ export default function DraggableGridLayout({
|
|||||||
// grid layout
|
// grid layout
|
||||||
|
|
||||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||||
useUserPersistence<PersistedLayout>(`${cameraGroup}-draggable-layout`);
|
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||||
|
|
||||||
const readPersistedLayout = useCallback(
|
|
||||||
(stored: PersistedLayout | undefined): Layout | undefined => {
|
|
||||||
if (
|
|
||||||
!stored ||
|
|
||||||
stored.version !== LAYOUT_VERSION ||
|
|
||||||
!Array.isArray(stored.layout)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return stored.layout;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Strips per-item `constraints`, which are functions.
|
|
||||||
const toPersisted = useCallback(
|
|
||||||
(layout: Layout): PersistedLayout => ({
|
|
||||||
version: LAYOUT_VERSION,
|
|
||||||
naturalAspect: naturalAspectLayout,
|
|
||||||
layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })),
|
|
||||||
}),
|
|
||||||
[naturalAspectLayout],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [group] = useUserPersistedOverlayState(
|
const [group] = useUserPersistedOverlayState(
|
||||||
"cameraGroup",
|
"cameraGroup",
|
||||||
@@ -261,11 +140,11 @@ export default function DraggableGridLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setEditGroup(false);
|
setEditGroup(false);
|
||||||
// Keeps the camera-change effect from overwriting the layout we load next.
|
// Reset camera tracking state when group changes to prevent the camera-change
|
||||||
|
// effect from incorrectly overwriting the loaded layout
|
||||||
setCurrentCameras(undefined);
|
setCurrentCameras(undefined);
|
||||||
setCurrentIncludeBirdseye(undefined);
|
setCurrentIncludeBirdseye(undefined);
|
||||||
setCurrentGridLayout(undefined);
|
setCurrentGridLayout(undefined);
|
||||||
setCurrentNaturalAspect(undefined);
|
|
||||||
}, [cameraGroup, setIsEditMode]);
|
}, [cameraGroup, setIsEditMode]);
|
||||||
|
|
||||||
// camera state
|
// camera state
|
||||||
@@ -276,84 +155,21 @@ export default function DraggableGridLayout({
|
|||||||
const [currentGridLayout, setCurrentGridLayout] = useState<
|
const [currentGridLayout, setCurrentGridLayout] = useState<
|
||||||
Layout | undefined
|
Layout | undefined
|
||||||
>();
|
>();
|
||||||
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
|
||||||
|
|
||||||
const handleLayoutChange = useCallback(
|
const handleLayoutChange = useCallback(
|
||||||
(currentLayout: Layout) => {
|
(currentLayout: Layout) => {
|
||||||
if (
|
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
||||||
!isGridLayoutLoaded ||
|
|
||||||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGridLayout(toPersisted(currentLayout));
|
// save layout to idb
|
||||||
|
setGridLayout(currentLayout);
|
||||||
setShowCircles(true);
|
setShowCircles(true);
|
||||||
},
|
},
|
||||||
[
|
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||||
setGridLayout,
|
|
||||||
isGridLayoutLoaded,
|
|
||||||
gridLayout,
|
|
||||||
currentGridLayout,
|
|
||||||
readPersistedLayout,
|
|
||||||
toPersisted,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dimsFor = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const ratio = layoutAspects[name] ?? DEFAULT_ASPECT;
|
|
||||||
const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W;
|
|
||||||
const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio));
|
|
||||||
return { w, h };
|
|
||||||
},
|
|
||||||
[layoutAspects],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rescale a pre-masonry layout onto the current grid. Both axes scale by a
|
|
||||||
// constant, so tiles the user resized keep their size and their arrangement
|
|
||||||
// stays intact. Only meaningful in bucketed mode, where a tile still has the
|
|
||||||
// shape those coordinates assumed.
|
|
||||||
const convertLegacyLayout = useCallback(
|
|
||||||
(stored: unknown): Layout | undefined => {
|
|
||||||
if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const xScale = GRID_COLS / LEGACY_GRID_COLS;
|
|
||||||
const yScale =
|
|
||||||
Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) /
|
|
||||||
LEGACY_TILE_ROWS;
|
|
||||||
const converted: LayoutItem[] = [];
|
|
||||||
|
|
||||||
for (const item of stored) {
|
|
||||||
if (
|
|
||||||
!item ||
|
|
||||||
typeof item.i !== "string" ||
|
|
||||||
typeof item.x !== "number" ||
|
|
||||||
typeof item.y !== "number" ||
|
|
||||||
typeof item.w !== "number" ||
|
|
||||||
typeof item.h !== "number"
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS);
|
|
||||||
converted.push({
|
|
||||||
i: item.i,
|
|
||||||
x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w),
|
|
||||||
y: Math.max(0, Math.round(item.y * yScale)),
|
|
||||||
w,
|
|
||||||
h: Math.max(1, Math.round(item.h * yScale)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
},
|
|
||||||
[naturalAspectLayout],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateLayout = useCallback(
|
const generateLayout = useCallback(
|
||||||
(baseLayout: Layout | undefined): Layout | undefined => {
|
(baseLayout: Layout | undefined) => {
|
||||||
if (!isGridLayoutLoaded) {
|
if (!isGridLayoutLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -363,98 +179,91 @@ export default function DraggableGridLayout({
|
|||||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||||
: cameras.map((camera) => camera?.name || "");
|
: cameras.map((camera) => camera?.name || "");
|
||||||
|
|
||||||
const existing: LayoutItem[] = baseLayout
|
const optionsMap: LayoutItem[] = baseLayout
|
||||||
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||||
: [];
|
: [];
|
||||||
const placed = new Set(existing.map((layout) => layout.i));
|
|
||||||
|
|
||||||
const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
|
cameraNames.forEach((cameraName, index) => {
|
||||||
// Each column starts below every existing tile that overlaps it, so new
|
const existingLayout = optionsMap.find(
|
||||||
// cameras fill open columns without overlapping the user's tiles.
|
(layout) => layout.i === cameraName,
|
||||||
const colBottoms = Array.from({ length: tileColumns }, (_, c) =>
|
|
||||||
existing.reduce(
|
|
||||||
(max, layout) =>
|
|
||||||
layout.x < (c + 1) * TILE_BASE_W &&
|
|
||||||
layout.x + layout.w > c * TILE_BASE_W
|
|
||||||
? Math.max(max, layout.y + layout.h)
|
|
||||||
: max,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result: LayoutItem[] = [...existing];
|
// Skip if the camera already exists in the layout
|
||||||
|
if (existingLayout) {
|
||||||
cameraNames.forEach((name) => {
|
|
||||||
if (placed.has(name)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { w, h } = dimsFor(name);
|
|
||||||
|
|
||||||
if (w === TILE_BASE_W) {
|
let aspectRatio;
|
||||||
let col = 0;
|
let col;
|
||||||
for (let c = 1; c < tileColumns; c++) {
|
|
||||||
if (colBottoms[c] < colBottoms[col]) {
|
// Handle "birdseye" camera as a special case
|
||||||
col = c;
|
if (cameraName === "birdseye") {
|
||||||
}
|
aspectRatio =
|
||||||
}
|
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||||
result.push({
|
col = 0; // Set birdseye camera in the first column
|
||||||
i: name,
|
|
||||||
x: col * TILE_BASE_W,
|
|
||||||
y: colBottoms[col],
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
});
|
|
||||||
colBottoms[col] += h;
|
|
||||||
} else {
|
} else {
|
||||||
let pair = 0;
|
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||||
for (let c = 1; c + 1 < tileColumns; c++) {
|
aspectRatio =
|
||||||
if (
|
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||||
Math.max(colBottoms[c], colBottoms[c + 1]) <
|
col = index % 3; // Regular cameras distributed across columns
|
||||||
Math.max(colBottoms[pair], colBottoms[pair + 1])
|
|
||||||
) {
|
|
||||||
pair = c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate layout options based on aspect ratio
|
||||||
|
const columnsPerPlayer = 4;
|
||||||
|
let height;
|
||||||
|
let width;
|
||||||
|
|
||||||
|
if (aspectRatio < 1) {
|
||||||
|
// Portrait
|
||||||
|
height = 2 * columnsPerPlayer;
|
||||||
|
width = columnsPerPlayer;
|
||||||
|
} else if (aspectRatio > 2) {
|
||||||
|
// Wide
|
||||||
|
height = 1 * columnsPerPlayer;
|
||||||
|
width = 2 * columnsPerPlayer;
|
||||||
|
} else {
|
||||||
|
// Landscape
|
||||||
|
height = 1 * columnsPerPlayer;
|
||||||
|
width = columnsPerPlayer;
|
||||||
}
|
}
|
||||||
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
|
|
||||||
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
|
const options = {
|
||||||
colBottoms[pair] = y + h;
|
i: cameraName,
|
||||||
colBottoms[pair + 1] = y + h;
|
x: col * width,
|
||||||
}
|
y: 0, // don't set y, grid does automatically
|
||||||
|
w: width,
|
||||||
|
h: height,
|
||||||
|
};
|
||||||
|
|
||||||
|
optionsMap.push(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return optionsMap;
|
||||||
},
|
},
|
||||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGridLayoutLoaded) {
|
if (isGridLayoutLoaded) {
|
||||||
return;
|
if (gridLayout) {
|
||||||
}
|
// set current grid layout from loaded, possibly adding new cameras
|
||||||
|
const updatedLayout = generateLayout(gridLayout);
|
||||||
const saved = readPersistedLayout(gridLayout);
|
|
||||||
const converted = saved ? undefined : convertLegacyLayout(gridLayout);
|
|
||||||
const base = saved ?? converted;
|
|
||||||
|
|
||||||
if (base) {
|
|
||||||
const updatedLayout = generateLayout(base) ?? base;
|
|
||||||
setCurrentGridLayout(updatedLayout);
|
setCurrentGridLayout(updatedLayout);
|
||||||
if (converted || !isEqual(updatedLayout, base)) {
|
// Only save if cameras were added (layout changed)
|
||||||
setGridLayout(toPersisted(updatedLayout));
|
if (!isEqual(updatedLayout, gridLayout)) {
|
||||||
|
setGridLayout(updatedLayout);
|
||||||
}
|
}
|
||||||
|
// Set camera tracking state so the camera-change effect has a baseline
|
||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
setCurrentNaturalAspect(
|
|
||||||
converted ? naturalAspectLayout : gridLayout?.naturalAspect,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// empty or incompatible (pre-masonry) data
|
// idb is empty, set it with an initial layout
|
||||||
const newLayout = generateLayout(undefined) ?? [];
|
const newLayout = generateLayout(undefined);
|
||||||
setCurrentGridLayout(newLayout);
|
setCurrentGridLayout(newLayout);
|
||||||
setGridLayout(toPersisted(newLayout));
|
setGridLayout(newLayout);
|
||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
setCurrentNaturalAspect(naturalAspectLayout);
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
gridLayout,
|
gridLayout,
|
||||||
@@ -463,15 +272,12 @@ export default function DraggableGridLayout({
|
|||||||
generateLayout,
|
generateLayout,
|
||||||
cameras,
|
cameras,
|
||||||
includeBirdseye,
|
includeBirdseye,
|
||||||
naturalAspectLayout,
|
|
||||||
readPersistedLayout,
|
|
||||||
convertLegacyLayout,
|
|
||||||
toPersisted,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only for camera changes within a loaded group; undefined currentCameras
|
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||||
// means the load effect above has not run yet.
|
// Skip if currentCameras is undefined (means we just switched groups and
|
||||||
|
// the first useEffect hasn't run yet to set things up)
|
||||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -483,10 +289,10 @@ export default function DraggableGridLayout({
|
|||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
|
|
||||||
const updatedLayout =
|
// Regenerate layout based on current layout, adding any new cameras
|
||||||
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
const updatedLayout = generateLayout(currentGridLayout);
|
||||||
setCurrentGridLayout(updatedLayout);
|
setCurrentGridLayout(updatedLayout);
|
||||||
setGridLayout(toPersisted(updatedLayout));
|
setGridLayout(updatedLayout);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cameras,
|
cameras,
|
||||||
@@ -497,47 +303,39 @@ export default function DraggableGridLayout({
|
|||||||
generateLayout,
|
generateLayout,
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
toPersisted,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [marginValue, setMarginValue] = useState(16);
|
||||||
if (
|
|
||||||
!isNaturalAspectLoaded ||
|
|
||||||
currentNaturalAspect === undefined ||
|
|
||||||
currentNaturalAspect === naturalAspectLayout
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentNaturalAspect(naturalAspectLayout);
|
// calculate margin value for browsers that don't have default font size of 16px
|
||||||
const regenerated = generateLayout(undefined) ?? [];
|
useLayoutEffect(() => {
|
||||||
setCurrentGridLayout(regenerated);
|
const calculateRemValue = () => {
|
||||||
setGridLayout(toPersisted(regenerated));
|
const htmlElement = document.documentElement;
|
||||||
}, [
|
const fontSize = window.getComputedStyle(htmlElement).fontSize;
|
||||||
naturalAspectLayout,
|
setMarginValue(parseFloat(fontSize));
|
||||||
isNaturalAspectLoaded,
|
};
|
||||||
currentNaturalAspect,
|
|
||||||
generateLayout,
|
|
||||||
setGridLayout,
|
|
||||||
toPersisted,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
calculateRemValue();
|
||||||
|
|
||||||
// Commit-time measure: paints the first frame at the real width (no
|
|
||||||
// innerWidth flash), and the setState re-render is what lets
|
|
||||||
// useResizeObserver see a node mounted after the skeleton swap.
|
|
||||||
const [mountWidth, setMountWidth] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
gridContainerRef.current = node;
|
|
||||||
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [{ width: containerWidth, height: containerHeight }] =
|
const [{ width: containerWidth, height: containerHeight }] =
|
||||||
useResizeObserver(gridContainerRef);
|
useResizeObserver(gridContainerRef);
|
||||||
|
|
||||||
const availableWidth = containerWidth || mountWidth || 0;
|
const scrollBarWidth = useMemo(() => {
|
||||||
|
if (containerWidth && containerHeight && containerRef.current) {
|
||||||
|
return (
|
||||||
|
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [containerRef, containerHeight, containerWidth]);
|
||||||
|
|
||||||
|
const availableWidth = useMemo(
|
||||||
|
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
||||||
|
[containerWidth, scrollBarWidth],
|
||||||
|
);
|
||||||
|
|
||||||
const hasScrollbar = useMemo(() => {
|
const hasScrollbar = useMemo(() => {
|
||||||
if (containerHeight && containerRef.current) {
|
if (containerHeight && containerRef.current) {
|
||||||
@@ -548,10 +346,61 @@ export default function DraggableGridLayout({
|
|||||||
}, [containerRef, containerHeight]);
|
}, [containerRef, containerHeight]);
|
||||||
|
|
||||||
const cellHeight = useMemo(() => {
|
const cellHeight = useMemo(() => {
|
||||||
const width = availableWidth || window.innerWidth;
|
const aspectRatio = 16 / 9;
|
||||||
const columnWidth = width / GRID_COLS;
|
// subtract container margin, 1 camera takes up at least 4 rows
|
||||||
return columnWidth / VERTICAL_RESOLUTION;
|
// account for additional margin on bottom of each row
|
||||||
}, [availableWidth]);
|
return (
|
||||||
|
((availableWidth ?? window.innerWidth) - 2 * marginValue) /
|
||||||
|
12 /
|
||||||
|
aspectRatio -
|
||||||
|
marginValue +
|
||||||
|
marginValue / 4
|
||||||
|
);
|
||||||
|
}, [availableWidth, marginValue]);
|
||||||
|
|
||||||
|
const handleResize = (
|
||||||
|
_layout: Layout,
|
||||||
|
oldLayoutItem: LayoutItem | null,
|
||||||
|
layoutItem: LayoutItem | null,
|
||||||
|
placeholder: LayoutItem | null,
|
||||||
|
) => {
|
||||||
|
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
||||||
|
|
||||||
|
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
||||||
|
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
||||||
|
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
||||||
|
|
||||||
|
let newWidth, newHeight;
|
||||||
|
|
||||||
|
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
||||||
|
newHeight = Math.round(layoutItem.w / changeCoef);
|
||||||
|
newWidth = Math.round(newHeight * changeCoef);
|
||||||
|
} else {
|
||||||
|
newWidth = Math.round(layoutItem.h * changeCoef);
|
||||||
|
newHeight = Math.round(newWidth / changeCoef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dimensions maintain aspect ratio and fit within the grid
|
||||||
|
if (layoutItem.x + newWidth > 12) {
|
||||||
|
newWidth = 12 - layoutItem.x;
|
||||||
|
newHeight = Math.round(newWidth / changeCoef);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeCoef == 0.5) {
|
||||||
|
// portrait
|
||||||
|
newHeight = Math.ceil(newHeight / 2) * 2;
|
||||||
|
} else if (changeCoef == 2) {
|
||||||
|
// pano/wide
|
||||||
|
newHeight = Math.ceil(newHeight * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
newWidth = Math.round(newHeight * changeCoef);
|
||||||
|
|
||||||
|
layoutItem.w = newWidth;
|
||||||
|
layoutItem.h = newHeight;
|
||||||
|
placeholder.w = layoutItem.w;
|
||||||
|
placeholder.h = layoutItem.h;
|
||||||
|
};
|
||||||
|
|
||||||
// audio and stats states
|
// audio and stats states
|
||||||
|
|
||||||
@@ -654,19 +503,6 @@ export default function DraggableGridLayout({
|
|||||||
onSaveMuting(true);
|
onSaveMuting(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// RGL's per-item constraint derives height from width, holding each tile at
|
|
||||||
// its camera's aspect while resizing. Constraints are functions, so they live
|
|
||||||
// only on this render copy; toPersisted strips them.
|
|
||||||
const layoutWithConstraints = useMemo(() => {
|
|
||||||
if (!currentGridLayout) {
|
|
||||||
return [] as Layout;
|
|
||||||
}
|
|
||||||
return currentGridLayout.map((item) => ({
|
|
||||||
...item,
|
|
||||||
constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)],
|
|
||||||
}));
|
|
||||||
}, [currentGridLayout, layoutAspects]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
@@ -689,8 +525,8 @@ export default function DraggableGridLayout({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||||
ref={attachGridContainer}
|
ref={gridContainerRef}
|
||||||
>
|
>
|
||||||
<EditGroupDialog
|
<EditGroupDialog
|
||||||
open={editGroup}
|
open={editGroup}
|
||||||
@@ -700,36 +536,28 @@ export default function DraggableGridLayout({
|
|||||||
/>
|
/>
|
||||||
<Responsive
|
<Responsive
|
||||||
className="grid-layout"
|
className="grid-layout"
|
||||||
width={availableWidth || window.innerWidth}
|
width={availableWidth ?? window.innerWidth}
|
||||||
layouts={{
|
layouts={{
|
||||||
lg: layoutWithConstraints,
|
lg: currentGridLayout,
|
||||||
md: layoutWithConstraints,
|
md: currentGridLayout,
|
||||||
sm: layoutWithConstraints,
|
sm: currentGridLayout,
|
||||||
xs: layoutWithConstraints,
|
xs: currentGridLayout,
|
||||||
xxs: layoutWithConstraints,
|
xxs: currentGridLayout,
|
||||||
}}
|
}}
|
||||||
rowHeight={cellHeight}
|
rowHeight={cellHeight}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
cols={{
|
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
||||||
lg: GRID_COLS,
|
margin={[marginValue, marginValue]}
|
||||||
md: GRID_COLS,
|
|
||||||
sm: GRID_COLS,
|
|
||||||
xs: GRID_COLS,
|
|
||||||
xxs: GRID_COLS,
|
|
||||||
}}
|
|
||||||
margin={[0, 0]}
|
|
||||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
|
||||||
containerPadding={[0, isEditMode ? 6 : 3]}
|
containerPadding={[0, isEditMode ? 6 : 3]}
|
||||||
resizeConfig={{
|
resizeConfig={{
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
// se only: top/left handles fight the aspect constraint at a grid
|
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||||
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
|
||||||
handles: isEditMode ? ["se"] : [],
|
|
||||||
}}
|
}}
|
||||||
dragConfig={{
|
dragConfig={{
|
||||||
enabled: isEditMode,
|
enabled: isEditMode,
|
||||||
}}
|
}}
|
||||||
onDragStop={handleLayoutChange}
|
onDragStop={handleLayoutChange}
|
||||||
|
onResize={handleResize}
|
||||||
onResizeStart={() => setShowCircles(false)}
|
onResizeStart={() => setShowCircles(false)}
|
||||||
onResizeStop={handleLayoutChange}
|
onResizeStop={handleLayoutChange}
|
||||||
>
|
>
|
||||||
@@ -742,12 +570,22 @@ export default function DraggableGridLayout({
|
|||||||
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
birdseyeConfig={birdseyeConfig}
|
birdseyeConfig={birdseyeConfig}
|
||||||
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
|
||||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||||
onClick={() => onSelectCamera("birdseye")}
|
onClick={() => onSelectCamera("birdseye")}
|
||||||
></BirdseyeLivePlayerGridItem>
|
>
|
||||||
|
{isEditMode && showCircles && <CornerCircles />}
|
||||||
|
</BirdseyeLivePlayerGridItem>
|
||||||
)}
|
)}
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
|
let grow;
|
||||||
|
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||||
|
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||||
|
grow = `aspect-wide w-full`;
|
||||||
|
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
||||||
|
grow = `aspect-tall h-full`;
|
||||||
|
} else {
|
||||||
|
grow = "aspect-video";
|
||||||
|
}
|
||||||
const availableStreams = camera.live.streams || {};
|
const availableStreams = camera.live.streams || {};
|
||||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||||
|
|
||||||
@@ -776,14 +614,7 @@ export default function DraggableGridLayout({
|
|||||||
?.compatibilityMode || false;
|
?.compatibilityMode || false;
|
||||||
return (
|
return (
|
||||||
<GridLiveContextMenu
|
<GridLiveContextMenu
|
||||||
className={CARD_FIT}
|
className={grow}
|
||||||
aspectRatio={
|
|
||||||
(naturalAspectLayout
|
|
||||||
? liveAspects[camera.name]
|
|
||||||
: undefined) ??
|
|
||||||
layoutAspects[camera.name] ??
|
|
||||||
DEFAULT_ASPECT
|
|
||||||
}
|
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
camera={camera.name}
|
camera={camera.name}
|
||||||
streamName={streamName}
|
streamName={streamName}
|
||||||
@@ -822,8 +653,8 @@ export default function DraggableGridLayout({
|
|||||||
useWebGL={useWebGL}
|
useWebGL={useWebGL}
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full",
|
"rounded-lg bg-black md:rounded-2xl",
|
||||||
naturalAspectLayout ? "bg-background" : "bg-black",
|
grow,
|
||||||
isEditMode &&
|
isEditMode &&
|
||||||
showCircles &&
|
showCircles &&
|
||||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||||
@@ -844,8 +675,8 @@ export default function DraggableGridLayout({
|
|||||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||||
playAudio={audioStates[camera.name]}
|
playAudio={audioStates[camera.name]}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name]}
|
||||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
|
||||||
/>
|
/>
|
||||||
|
{isEditMode && showCircles && <CornerCircles />}
|
||||||
</GridLiveContextMenu>
|
</GridLiveContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -863,7 +694,6 @@ export default function DraggableGridLayout({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
data-testid="toggle-edit-layout"
|
|
||||||
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||||
@@ -932,6 +762,17 @@ export default function DraggableGridLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CornerCircles() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BirdseyeLivePlayerGridItemProps = {
|
type BirdseyeLivePlayerGridItemProps = {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -940,7 +781,6 @@ type BirdseyeLivePlayerGridItemProps = {
|
|||||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
birdseyeConfig: BirdseyeConfig;
|
birdseyeConfig: BirdseyeConfig;
|
||||||
aspectRatio: number;
|
|
||||||
liveMode: LivePlayerMode;
|
liveMode: LivePlayerMode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
@@ -958,7 +798,6 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
children,
|
children,
|
||||||
birdseyeConfig,
|
birdseyeConfig,
|
||||||
aspectRatio: cellAspect,
|
|
||||||
liveMode,
|
liveMode,
|
||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
@@ -967,8 +806,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center p-1 [container-type:size]"
|
style={{ ...style }}
|
||||||
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
@@ -976,7 +814,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<BirdseyeLivePlayer
|
<BirdseyeLivePlayer
|
||||||
className={cn(CARD_FIT, className)}
|
className={className}
|
||||||
birdseyeConfig={birdseyeConfig}
|
birdseyeConfig={birdseyeConfig}
|
||||||
liveMode={liveMode}
|
liveMode={liveMode}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -991,7 +829,6 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|||||||
type GridLiveContextMenuProps = {
|
type GridLiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
aspectRatio?: number;
|
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||||
@@ -1023,7 +860,6 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
aspectRatio: cameraAspect,
|
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onMouseUp,
|
onMouseUp,
|
||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
@@ -1051,8 +887,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center p-1 [container-type:size]"
|
style={{ ...style }}
|
||||||
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
|
|||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<BirdseyeLivePlayer
|
<BirdseyeLivePlayer
|
||||||
className={fullscreen ? "rounded-none" : ""}
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||||
birdseyeConfig={config.birdseye}
|
birdseyeConfig={config.birdseye}
|
||||||
liveMode={preferredLiveMode}
|
liveMode={preferredLiveMode}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import {
|
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||||
useWebRTCAvailableForStream,
|
|
||||||
useWebRTCGloballyAvailable,
|
|
||||||
} from "@/hooks/use-webrtc-availability";
|
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import {
|
import {
|
||||||
LivePlayerError,
|
LivePlayerError,
|
||||||
@@ -219,15 +216,16 @@ export default function LiveCameraView({
|
|||||||
);
|
);
|
||||||
const isWebRTCAvailable = webRTCAvailability.available;
|
const isWebRTCAvailable = webRTCAvailability.available;
|
||||||
|
|
||||||
// Two-way talk is the sendonly backchannel: global, not per-stream.
|
|
||||||
const { globallyAvailable: webRTCGloballyAvailable } =
|
|
||||||
useWebRTCGloballyAvailable();
|
|
||||||
|
|
||||||
// "checking" means the probe has not answered yet, and it re-enters that on
|
// "checking" means the probe has not answered yet, and it re-enters that on
|
||||||
// every mount, so treating it as unavailable downgrades the saved choice.
|
// every mount, so treating it as unavailable downgrades the saved choice.
|
||||||
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
|
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
|
||||||
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
|
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
|
||||||
|
|
||||||
|
// Two-way talk is a sendonly backchannel, so playback audio that WebRTC
|
||||||
|
// can't carry doesn't rule it out, but the stream's video must connect
|
||||||
|
const talkAvailable =
|
||||||
|
isWebRTCAvailable || webRTCAvailability.reason === "audio-codec";
|
||||||
|
|
||||||
// Resolves the saved preference without overwriting it. Transient error
|
// Resolves the saved preference without overwriting it. Transient error
|
||||||
// fallbacks layer on top in preferredLiveMode.
|
// fallbacks layer on top in preferredLiveMode.
|
||||||
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
|
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
|
||||||
@@ -398,6 +396,14 @@ export default function LiveCameraView({
|
|||||||
|
|
||||||
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
||||||
const [mic, setMic] = useState(false);
|
const [mic, setMic] = useState(false);
|
||||||
|
|
||||||
|
// the mic only connects through the WebRTC player, so it can't stay on
|
||||||
|
// for a stream that has lost it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!talkAvailable) {
|
||||||
|
setMic(false);
|
||||||
|
}
|
||||||
|
}, [talkAvailable]);
|
||||||
const [webRTC, setWebRTC] = useState(false);
|
const [webRTC, setWebRTC] = useState(false);
|
||||||
const [pip, setPip] = useState(false);
|
const [pip, setPip] = useState(false);
|
||||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||||
@@ -424,7 +430,7 @@ export default function LiveCameraView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const preferredLiveMode = useMemo(() => {
|
const preferredLiveMode = useMemo(() => {
|
||||||
if (mic && isWebRTCAvailable) {
|
if (mic && talkAvailable) {
|
||||||
return "webrtc";
|
return "webrtc";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +462,7 @@ export default function LiveCameraView({
|
|||||||
lowBandwidth,
|
lowBandwidth,
|
||||||
forceLowBandwidth,
|
forceLowBandwidth,
|
||||||
mic,
|
mic,
|
||||||
|
talkAvailable,
|
||||||
webRTC,
|
webRTC,
|
||||||
isRestreamed,
|
isRestreamed,
|
||||||
resolvedUserMode,
|
resolvedUserMode,
|
||||||
@@ -489,7 +496,7 @@ export default function LiveCameraView({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "t":
|
case "t":
|
||||||
if (supports2WayTalk) {
|
if (supports2WayTalk && talkAvailable) {
|
||||||
setMic(!mic);
|
setMic(!mic);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -737,7 +744,11 @@ export default function LiveCameraView({
|
|||||||
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
||||||
isActive={mic}
|
isActive={mic}
|
||||||
title={
|
title={
|
||||||
!webRTCGloballyAvailable
|
webRTCVerdictPending
|
||||||
|
? t("stream.technology.unavailable.checking", {
|
||||||
|
ns: "views/live",
|
||||||
|
})
|
||||||
|
: !talkAvailable
|
||||||
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
||||||
: mic
|
: mic
|
||||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||||
@@ -749,7 +760,7 @@ export default function LiveCameraView({
|
|||||||
setAudio(true);
|
setAudio(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
|
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||||
@@ -860,7 +871,7 @@ export default function LiveCameraView({
|
|||||||
)}
|
)}
|
||||||
<LivePlayer
|
<LivePlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={fullscreen ? "rounded-none" : ""}
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||||
windowVisible
|
windowVisible
|
||||||
showStillWithoutActivity={false}
|
showStillWithoutActivity={false}
|
||||||
alwaysShowCameraName={false}
|
alwaysShowCameraName={false}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function LiveDashboardView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] md:p-2"
|
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -609,7 +609,7 @@ export default function LiveDashboardView({
|
|||||||
<LivePlayer
|
<LivePlayer
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${grow} bg-black`}
|
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||||
windowVisible={
|
windowVisible={
|
||||||
windowVisible && visibleCameras.includes(camera.name)
|
windowVisible && visibleCameras.includes(camera.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter";
|
import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@@ -183,19 +182,9 @@ export function RecordingView({
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [savedTimelineType, setSavedTimelineType] =
|
const [timelineType, setTimelineType] = useOverlayState<TimelineType>(
|
||||||
useUserPersistence<TimelineType>("recordingTimelineType");
|
|
||||||
const [timelineType, setOverlayTimelineType] = useOverlayState<TimelineType>(
|
|
||||||
"timelineType",
|
"timelineType",
|
||||||
recording?.timelineType ?? savedTimelineType ?? "timeline",
|
recording?.timelineType ?? "timeline",
|
||||||
);
|
|
||||||
|
|
||||||
const setTimelineType = useCallback(
|
|
||||||
(value: TimelineType, replace?: boolean) => {
|
|
||||||
setOverlayTimelineType(value, replace);
|
|
||||||
setSavedTimelineType(value);
|
|
||||||
},
|
|
||||||
[setOverlayTimelineType, setSavedTimelineType],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const chunkedTimeRange = useMemo(
|
const chunkedTimeRange = useMemo(
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button, buttonVariants } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import {
|
import {
|
||||||
useUserPersistence,
|
useUserPersistence,
|
||||||
deleteUserNamespacedKey,
|
deleteUserNamespacedKey,
|
||||||
} from "@/hooks/use-user-persistence";
|
} from "@/hooks/use-user-persistence";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -34,17 +33,6 @@ import {
|
|||||||
CONTROL_COLUMN_CLASS_NAME,
|
CONTROL_COLUMN_CLASS_NAME,
|
||||||
} from "@/components/card/SettingsGroupCard";
|
} from "@/components/card/SettingsGroupCard";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
||||||
import {
|
import {
|
||||||
applyImportPayload,
|
applyImportPayload,
|
||||||
@@ -63,8 +51,6 @@ import {
|
|||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
||||||
|
|
||||||
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
|
|
||||||
|
|
||||||
type SwitchSettingRowProps = {
|
type SwitchSettingRowProps = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -335,10 +321,6 @@ export default function UiSettingsView() {
|
|||||||
"displayCameraNames",
|
"displayCameraNames",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const [naturalAspect, setNaturalAspect] = useUserPersistence(
|
|
||||||
"naturalAspectLayout",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||||
@@ -347,66 +329,6 @@ export default function UiSettingsView() {
|
|||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirmCopy = useCallback(
|
|
||||||
(target: ConfirmTarget) => {
|
|
||||||
// literal keys per branch: a template key would be invisible to
|
|
||||||
// npm run i18n:extract, which CI verifies
|
|
||||||
switch (target) {
|
|
||||||
case "layouts":
|
|
||||||
return {
|
|
||||||
title: t("general.storedLayouts.clearAll"),
|
|
||||||
description: t("general.storedLayouts.clearConfirm"),
|
|
||||||
action: t("button.clear", { ns: "common" }),
|
|
||||||
};
|
|
||||||
case "streaming":
|
|
||||||
return {
|
|
||||||
title: t("general.cameraGroupStreaming.clearAll"),
|
|
||||||
description: t("general.cameraGroupStreaming.clearConfirm"),
|
|
||||||
action: t("button.clear", { ns: "common" }),
|
|
||||||
};
|
|
||||||
case "naturalAspect":
|
|
||||||
return {
|
|
||||||
title: t("general.liveDashboard.naturalAspectLayout.label"),
|
|
||||||
description: t(
|
|
||||||
"general.liveDashboard.naturalAspectLayout.descNote",
|
|
||||||
),
|
|
||||||
action: naturalAspect
|
|
||||||
? t("button.disable", { ns: "common" })
|
|
||||||
: t("button.enable", { ns: "common" }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[naturalAspect, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
|
||||||
switch (pendingConfirm) {
|
|
||||||
case "layouts":
|
|
||||||
clearStoredLayouts();
|
|
||||||
break;
|
|
||||||
case "streaming":
|
|
||||||
clearStreamingSettings();
|
|
||||||
break;
|
|
||||||
case "naturalAspect":
|
|
||||||
// a layout only renders in the mode that built it
|
|
||||||
setNaturalAspect(!naturalAspect);
|
|
||||||
clearStoredLayouts();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingConfirm(null);
|
|
||||||
}, [
|
|
||||||
pendingConfirm,
|
|
||||||
clearStoredLayouts,
|
|
||||||
clearStreamingSettings,
|
|
||||||
naturalAspect,
|
|
||||||
setNaturalAspect,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const liveDashboardSwitchRows = [
|
const liveDashboardSwitchRows = [
|
||||||
{
|
{
|
||||||
id: "auto-live",
|
id: "auto-live",
|
||||||
@@ -429,18 +351,6 @@ export default function UiSettingsView() {
|
|||||||
checked: cameraNames,
|
checked: cameraNames,
|
||||||
onCheckedChange: setCameraName,
|
onCheckedChange: setCameraName,
|
||||||
},
|
},
|
||||||
// phones use the static grid, so tile sizing has nothing to affect there
|
|
||||||
...(isMobileOnly
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: "natural-aspect",
|
|
||||||
label: t("general.liveDashboard.naturalAspectLayout.label"),
|
|
||||||
description: t("general.liveDashboard.naturalAspectLayout.desc"),
|
|
||||||
checked: naturalAspect,
|
|
||||||
onCheckedChange: () => setPendingConfirm("naturalAspect"),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -509,7 +419,7 @@ export default function UiSettingsView() {
|
|||||||
id="stored-layouts-clear"
|
id="stored-layouts-clear"
|
||||||
aria-label={t("general.storedLayouts.clearAll")}
|
aria-label={t("general.storedLayouts.clearAll")}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
onClick={() => setPendingConfirm("layouts")}
|
onClick={clearStoredLayouts}
|
||||||
>
|
>
|
||||||
{t("general.storedLayouts.clearAll")}
|
{t("general.storedLayouts.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -525,7 +435,7 @@ export default function UiSettingsView() {
|
|||||||
id="camera-group-streaming-clear"
|
id="camera-group-streaming-clear"
|
||||||
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
onClick={() => setPendingConfirm("streaming")}
|
onClick={clearStreamingSettings}
|
||||||
>
|
>
|
||||||
{t("general.cameraGroupStreaming.clearAll")}
|
{t("general.cameraGroupStreaming.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -659,41 +569,9 @@ export default function UiSettingsView() {
|
|||||||
fileName={pendingImport.name}
|
fileName={pendingImport.name}
|
||||||
file={pendingImport.file}
|
file={pendingImport.file}
|
||||||
summary={pendingImport.summary}
|
summary={pendingImport.summary}
|
||||||
currentNaturalAspect={naturalAspect ?? false}
|
|
||||||
onConfirm={handleImportConfirm}
|
onConfirm={handleImportConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={pendingConfirm != null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setPendingConfirm(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).title}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).description}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{t("button.cancel", { ns: "common" })}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
>
|
|
||||||
{pendingConfirm && confirmCopy(pendingConfirm).action}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user