Compare commits

..
2 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub f9a37d4f4f remember last selected history sidebar tab (#24488)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
2026-09-27 17:21:46 -06:00
Josh HawkinsandGitHub 0977fa5e15 Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
2026-09-27 17:09:31 -06:00
49 changed files with 1840 additions and 1023 deletions
-5
View File
@@ -393,11 +393,6 @@ 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
-1
View File
@@ -51,7 +51,6 @@ 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
+7 -1
View File
@@ -63,6 +63,7 @@ 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__)
@@ -680,10 +681,15 @@ 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(resolve_coverage(camera_name, start_ts, end_ts)), null_audio_glitches(intervals, main_audio, sub_audio),
stream_preference, stream_preference,
) )
-7
View File
@@ -123,7 +123,6 @@ 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]:
@@ -149,11 +148,6 @@ 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)
@@ -184,7 +178,6 @@ 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"
+22 -90
View File
@@ -42,8 +42,6 @@ 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,
) )
@@ -86,20 +84,10 @@ 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).
@@ -388,18 +376,15 @@ 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 and glitch nulling the Delegates to the same coverage resolution the manifest builder
manifest builder uses, so what we plan around and what nginx-vod uses, so what we plan around and what nginx-vod emits agree by
emits agree by construction. Returns the spans (each [row, start, construction. Returns the spans (each [row, start, end, is_main]),
end, is_main]), the known video codecs, and whether audio survives the known video codecs, and whether audio survives the range.
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 = null_audio_glitches( intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
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),
@@ -448,57 +433,17 @@ 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._planned_stream_runs() runs = self._stream_runs(spans)
# 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.
@@ -895,8 +840,6 @@ 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
@@ -1044,13 +987,9 @@ class RecordingExporter(threading.Thread):
if duration_ms <= 0: if duration_ms <= 0:
continue continue
# a staged window's keyframe lead-in opens its chapter, with title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
# frames captured that long before the window timespec="seconds"
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"
@@ -1189,12 +1128,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 # reconcile and every chapter offset lines up with the merged
recordings = ( # timeline the staged files reproduce
self._staged_chapter_windows() recordings = [
if self.chapters not in (None, ChaptersEnum.none) _ChapterWindow(span_start, span_end)
else [] for _row, span_start, span_end, _is_main in self._merged_spans()
) ]
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"
@@ -1210,18 +1149,11 @@ 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:
# an unstaged auto range resolves to at most one stream run, and # never mix streams in one playlist; use main when available
# its rows are the ones the chapters describe. Main rows the # and fall back to sub for expired-main history
# manifest drops (glitches, slivers at the edges of a sub range) recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
# 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
)
# never mix streams in one playlist; fall back to sub for if not recordings:
# 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 = []
+3 -40
View File
@@ -490,17 +490,9 @@ class RecordingMaintainer(threading.Thread):
) )
reviews = reviews_by_camera[camera] reviews = reviews_by_camera[camera]
# probes run concurrently, but each segment's start chains off the tasks.extend(
# previous segment's end, so starts resolve in segment order [self.validate_and_move_segment(camera, reviews, r) for r in recordings]
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:
@@ -558,33 +550,12 @@ 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, self, camera: str, reviews: Any, recording: dict[str, Any]
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"]
@@ -646,9 +617,6 @@ 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
) )
@@ -686,11 +654,6 @@ 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
-11
View File
@@ -256,17 +256,6 @@ 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()
+4 -7
View File
@@ -111,18 +111,15 @@ 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 -> device -> index into that type's temperatures detector_type_indices: dict[str, int] = {}
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
# temperatures are per physical unit, so a repeated device # Keep track of the index for each detector type to match temperatures correctly
# ("hailo:PCIe#2", see runner_names) shares its unit's reading current_index = detector_type_indices.get(detector_type, 0)
device = name.partition("#")[0] detector_type_indices[detector_type] = current_index + 1
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 -41
View File
@@ -1,10 +1,8 @@
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 MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL from frigate.const import 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
@@ -92,44 +90,6 @@ 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 ##################################################
#################################################################################################################### ####################################################################################################################
-1
View File
@@ -26,7 +26,6 @@ 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)
-39
View File
@@ -1,39 +0,0 @@
"""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()
-50
View File
@@ -543,56 +543,6 @@ 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."""
-11
View File
@@ -288,17 +288,6 @@ 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:
+3 -6
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import sys import sys
import unittest import unittest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import 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,11 +48,8 @@ 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
# The requestor is real when another test imported the maintainer.validate_and_move_segment = MagicMock()
# 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,6 +1,5 @@
"""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
@@ -533,59 +532,6 @@ 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)
+1 -7
View File
@@ -14,10 +14,6 @@ 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
@@ -128,9 +124,7 @@ class ModelDownloader:
logger.info(f"Downloading model file from: {url}") logger.info(f"Downloading model file from: {url}")
try: try:
with requests.get( with requests.get(url, stream=True, allow_redirects=True) as r:
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):
+7 -7
View File
@@ -213,19 +213,17 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
) )
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]: def null_audio_glitches(
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. Every consumer of hole the span builder skips like any recording gap.
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
@@ -451,7 +449,9 @@ 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.
""" """
nulled = null_audio_glitches(intervals) main_audio = stream_has_audio(intervals, main=True)
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),
+204
View File
@@ -0,0 +1,204 @@
/**
* 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>;
}
+5
View File
@@ -45,6 +45,11 @@ 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" });
@@ -0,0 +1,185 @@
/**
* 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);
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* 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,23 +217,6 @@ 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,
}) => { }) => {
@@ -265,13 +248,15 @@ 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: "plus://abc123", path: "/config/model_cache/abc123",
plus: PLUS_MODEL, plus: PLUS_MODEL,
}, },
], ],
@@ -317,40 +302,6 @@ 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,7 +13,26 @@ 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";
const OUTDOOR_LAYOUT = [ // the shape DraggableGridLayout writes
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 },
]; ];
@@ -159,6 +178,160 @@ 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");
@@ -190,12 +363,15 @@ 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();
await expect(frigateApp.page.getByText(/patio/)).toBeVisible(); // patio is layout-only, so its warning follows the layouts section
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(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
@@ -206,6 +382,7 @@ 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
@@ -235,7 +412,39 @@ 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(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : 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,
); );
}); });
@@ -325,7 +534,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(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
+1
View File
@@ -131,6 +131,7 @@
"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",
+13 -3
View File
@@ -167,6 +167,11 @@
"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."
@@ -175,12 +180,14 @@
"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",
@@ -195,6 +202,9 @@
"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)",
@@ -2053,7 +2063,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 that can't be shared, such as a Coral, can only be used by one model." "unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
}, },
"tabs": { "tabs": {
"plus": "Frigate+", "plus": "Frigate+",
+1 -1
View File
@@ -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 /> <ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -7,7 +7,6 @@
*/ */
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";
@@ -353,17 +352,6 @@ 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.
*/ */
@@ -372,21 +360,6 @@ 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,8 +21,7 @@ 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 // device strings already taken by another model, mapped to that model's scene
// 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;
@@ -85,11 +84,8 @@ export function HardwarePicker({
return; return;
} }
// start with the first unit no other model has taken, falling back to // start with the first unit no other model has taken
// a taken one when the hardware can be shared const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
const free =
entry.units.find((unit) => !claimedElsewhere[unit.device]) ??
(entry.unlimited ? entry.units[0] : undefined);
if (!free) { if (!free) {
onChange([]); onChange([]);
@@ -188,9 +184,7 @@ export function HardwarePicker({
{t("detectionModels.hardware.unitsDescription")} {t("detectionModels.hardware.unitsDescription")}
</p> </p>
{selected.units.map((unit) => { {selected.units.map((unit) => {
const claimedBy = selected.unlimited const claimedBy = claimedElsewhere[unit.device];
? undefined
: claimedElsewhere[unit.device];
return ( return (
<div <div
@@ -38,7 +38,9 @@ function plusModelId(path: unknown): string | undefined {
type ModelSourcePickerProps = { type ModelSourcePickerProps = {
path: unknown; path: unknown;
// Frigate+ metadata the backend attaches to a saved model // Frigate+ metadata the backend attaches to a saved model, and the only
// 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,23 +169,20 @@ export function ModelsField(props: FieldProps) {
[savedModels], [savedModels],
); );
// a model serves the cameras naming its scene, and like the backend, the // a model serves the cameras naming its scene, plus every camera that names
// "all" model also serves every camera whose scene has no model of its own // no scene at all when it is the "all" model
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 ?? "all"; const cameraScene = camera?.detect?.scene;
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all"; return cameraScene ? cameraScene === scene : scene === "all";
return servedBy === (scene ?? "all");
}).length; }).length;
}, },
[cameras, models], [cameras],
); );
const claimedByOtherModels = useCallback( const claimedByOtherModels = useCallback(
@@ -317,9 +314,7 @@ export function ModelsField(props: FieldProps) {
); );
return ( return (
// keyed by scene, which is unique per model, so deleting a card <Card key={`${baseId}-${index}`} className="w-full">
// 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 rounded-lg bg-gradient-to-b from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full 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 rounded-lg bg-gradient-to-t from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
lowerClassName, lowerClassName,
)} )}
/> />
@@ -10,10 +10,12 @@ 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 { LuTriangleAlert } from "react-icons/lu"; import { LuFileJson, LuInfo, 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,
@@ -25,6 +27,7 @@ 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>;
}; };
@@ -34,6 +37,7 @@ 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"]);
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
const available = useMemo( const available = useMemo(
() => ({ () => ({
layouts: summary.layoutGroupCount > 0, // phones use the static grid, so a saved grid layout has nothing to
// 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,
}), }),
@@ -90,6 +96,16 @@ 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);
@@ -113,17 +129,23 @@ export default function ImportUiSettingsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-0.5"> <div className="space-y-4">
<p className="break-all text-base text-primary-variant">{fileName}</p> <div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
<p className="text-sm text-muted-foreground"> <LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-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-3"> <div className="space-y-2.5">
<FilterSwitch <FilterSwitch
label={t("general.backupRestore.importDialog.layouts", { label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount, count: summary.layoutGroupCount,
@@ -156,6 +178,28 @@ 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">
@@ -180,6 +224,7 @@ export default function ImportUiSettingsDialog({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
</div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -198,7 +243,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 /> <ActivityIndicator className="size-4" />
<span>{t("general.backupRestore.importDialog.confirm")}</span> <span>{t("general.backupRestore.importDialog.confirm")}</span>
</div> </div>
) : ( ) : (
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
}: LivePlayerProps) { }: LivePlayerProps) {
let player; let player;
if (liveMode == "webrtc") { if (liveMode == "webrtc") {
player = ( player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
<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 = ( player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
<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">
@@ -55,7 +43,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 rounded-lg md:rounded-2xl" className="flex size-full justify-center overflow-hidden"
camera="birdseye" camera="birdseye"
width={birdseyeConfig.width} width={birdseyeConfig.width}
height={birdseyeConfig.height} height={birdseyeConfig.height}
@@ -65,22 +53,31 @@ export default function BirdseyeLivePlayer({
/> />
); );
} else { } else {
player = <ActivityIndicator />; player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
} }
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"relative flex w-full cursor-pointer justify-center", "relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
className, className,
)} )}
onClick={onClick} onClick={onClick}
> >
<ImageShadowOverlay <div
upperClassName="md:rounded-2xl" className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
lowerClassName="md:rounded-2xl" style={
/> {
"--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>
+13 -51
View File
@@ -26,7 +26,6 @@ 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,
@@ -149,42 +148,6 @@ 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) => {
@@ -262,7 +225,6 @@ 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;
@@ -274,7 +236,7 @@ export default function HlsVideoPlayer({
// Base HLS configuration // Base HLS configuration
const hlsConfig: Partial<HlsConfig> = { const hlsConfig: Partial<HlsConfig> = {
maxBufferLength: bufferLengthRef.current ?? DEFAULT_MAX_BUFFER_LENGTH_S, maxBufferLength: bufferLength ?? 10,
maxBufferSize: 20 * 1000 * 1000, maxBufferSize: 20 * 1000 * 1000,
startPosition: currentSource.startPosition, startPosition: currentSource.startPosition,
}; };
@@ -307,18 +269,10 @@ 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) { if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) {
// 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 (mediaRecoveryBudgetRef.current > 0) { if (!isCodecError && mediaRecoveryBudgetRef.current > 0) {
mediaRecoveryBudgetRef.current -= 1; mediaRecoveryBudgetRef.current -= 1;
hls.recoverMediaError(); hls.recoverMediaError();
} }
@@ -354,7 +308,7 @@ export default function HlsVideoPlayer({
hlsRef.current.destroy(); hlsRef.current.destroy();
} }
}; };
}, [videoRef, hlsRef, useHlsCompat, currentSource]); }, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]);
// state handling // state handling
@@ -760,7 +714,15 @@ export default function HlsVideoPlayer({
} }
} }
reportPlaybackFailure(mediaError.code, mediaError.message); toast.error(
t("toast.error.playRecordingsFailed", {
code: mediaError.code,
message: mediaError.message,
}),
{
position: "top-center",
},
);
}} }}
/> />
</div> </div>
+56 -14
View File
@@ -54,6 +54,7 @@ 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({
@@ -80,6 +81,7 @@ export default function LivePlayer({
onError, onError,
onMicrophoneError, onMicrophoneError,
onResetLiveMode, onResetLiveMode,
onLiveAspectChange,
}: LivePlayerProps) { }: LivePlayerProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player"]);
@@ -127,6 +129,37 @@ 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);
@@ -262,11 +295,12 @@ export default function LivePlayer({
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
key={"webrtc_" + key} key={"webrtc_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full ${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}
@@ -282,7 +316,7 @@ export default function LivePlayer({
player = ( player = (
<MSEPlayer <MSEPlayer
key={"mse_" + key} key={"mse_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full ${liveReady ? "" : "hidden"}`}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
audioEnabled={playAudio} audioEnabled={playAudio}
@@ -292,7 +326,7 @@ export default function LivePlayer({
setStats={setStats} setStats={setStats}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
pip={pip} pip={pip}
setFullResolution={setFullResolution} setFullResolution={handleFullResolution}
onError={onError} onError={onError}
/> />
); );
@@ -308,7 +342,7 @@ export default function LivePlayer({
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
key={"jsmpeg_" + key} key={"jsmpeg_" + key}
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl" className="flex justify-center overflow-hidden"
camera={cameraConfig.name} camera={cameraConfig.name}
width={cameraConfig.detect.width} width={cameraConfig.detect.width}
height={cameraConfig.detect.height} height={cameraConfig.detect.height}
@@ -325,7 +359,9 @@ export default function LivePlayer({
player = null; player = null;
} }
} else { } else {
player = <ActivityIndicator />; player = (
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
);
} }
return ( return (
@@ -340,10 +376,12 @@ export default function LivePlayer({
}} }}
data-camera={cameraConfig.name} data-camera={cameraConfig.name}
className={cn( className={cn(
"relative flex w-full cursor-pointer justify-center outline", // the card owns the corner: overflow-hidden clips the stream, the still
// 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)
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl" ? "shadow-severity_alert outline-[3px] outline-severity_alert"
: "outline-0 outline-background", : "outline-0 outline-background",
"transition-all duration-500", "transition-all duration-500",
className, className,
@@ -357,10 +395,14 @@ export default function LivePlayer({
> >
{cameraEnabled && {cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<ImageShadowOverlay <div
upperClassName="md:rounded-2xl" className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
lowerClassName="md:rounded-2xl" style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
/> >
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
<ImageShadowOverlay />
</div>
</div>
)} )}
{player} {player}
{cameraEnabled && {cameraEnabled &&
@@ -368,7 +410,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 /> <ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
</div> </div>
)} )}
@@ -447,7 +489,7 @@ export default function LivePlayer({
{offline && inDashboard && ( {offline && inDashboard && (
<> <>
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" /> <div className="absolute inset-0 bg-black/50" />
<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>
@@ -491,7 +533,7 @@ export default function LivePlayer({
)} )}
{!cameraEnabled && ( {!cameraEnabled && (
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt"> <div className="relative flex h-full w-full items-center justify-center 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,6 +3,7 @@ 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";
@@ -20,6 +21,7 @@ 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;
@@ -36,6 +38,7 @@ export default function WebRtcPlayer({
pip = false, pip = false,
getStats = false, getStats = false,
setStats, setStats,
setFullResolution,
onPlaying, onPlaying,
onError, onError,
onMicrophoneError, onMicrophoneError,
@@ -342,6 +345,12 @@ 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,11 +340,8 @@ 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, and the probe lets a // the low stream starts with a clean record
// 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,6 +484,9 @@ 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 = () => {
@@ -492,7 +495,7 @@ export default function DynamicVideoPlayer({
setAutoLowReason(undefined); setAutoLowReason(undefined);
} }
}; };
}, [resolvedQuality, subAvailable]); }, [resolvedQuality, subAvailable, governor]);
// 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
@@ -613,19 +616,7 @@ 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());
+17 -47
View File
@@ -79,6 +79,7 @@ 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) {
@@ -87,7 +88,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 failed against ${probeDetail ?? "an unknown stream"}.`; return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
default: default:
return ""; return "";
} }
@@ -97,13 +98,9 @@ function describeGlobalReason(
let lastProbeSignature: string | null = null; let lastProbeSignature: string | null = null;
/** /**
* Browser support, go2rtc config, and a live handshake probe shared by the * Once-per-session: browser support, go2rtc config, and a live handshake probe.
* 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( export function useWebRTCGloballyAvailable(): GlobalAvailability {
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";
@@ -121,19 +118,11 @@ export function useWebRTCGloballyAvailable(
); );
}, [config]); }, [config]);
// the stream being viewed is the one most likely to be online // Representative restreamed stream to probe against.
const testStreams = useMemo(() => { const testStream = useMemo(() => {
const streams = Object.keys(config?.go2rtc?.streams ?? {}); const streams = config?.go2rtc?.streams ?? {};
return Object.keys(streams)[0];
if (!preferredStream || !streams.includes(preferredStream)) { }, [config]);
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),
@@ -147,9 +136,9 @@ export function useWebRTCGloballyAvailable(
JSON.stringify({ JSON.stringify({
candidates: config?.go2rtc?.webrtc?.candidates ?? [], candidates: config?.go2rtc?.webrtc?.candidates ?? [],
iceServers, iceServers,
streams: Object.keys(config?.go2rtc?.streams ?? {}), testStream,
}), }),
[config, iceServers], [config, iceServers, testStream],
); );
useEffect(() => { useEffect(() => {
@@ -164,29 +153,11 @@ export function useWebRTCGloballyAvailable(
}, [probeSignature]); }, [probeSignature]);
useEffect(() => { useEffect(() => {
if (probe.state !== "fail") { if (!browserOk || !configured || !testStream) {
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(streams, iceServers).then((result) => { probeWebRTCAvailability(testStream, iceServers).then((result) => {
if (!cancelled) { if (!cancelled) {
setProbe({ setProbe({
state: result.ok ? "pass" : "fail", state: result.ok ? "pass" : "fail",
@@ -197,7 +168,7 @@ export function useWebRTCGloballyAvailable(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [browserOk, configured, testStreamsKey, iceServers, retryToken]); }, [browserOk, configured, testStream, iceServers]);
const availability = useMemo<GlobalAvailability>(() => { const availability = useMemo<GlobalAvailability>(() => {
if (!browserOk) { if (!browserOk) {
@@ -225,9 +196,9 @@ export function useWebRTCGloballyAvailable(
logWebRTCUnavailable( logWebRTCUnavailable(
undefined, undefined,
reason, reason,
describeGlobalReason(reason, probe.detail), describeGlobalReason(reason, testStream, probe.detail),
); );
}, [config, availability, probe.detail]); }, [config, availability, testStream, probe.detail]);
return availability; return availability;
} }
@@ -237,8 +208,7 @@ export function useWebRTCAvailableForStream(
metadata: LiveStreamMetadata | null | undefined, metadata: LiveStreamMetadata | null | undefined,
streamName?: string, streamName?: string,
): StreamAvailability { ): StreamAvailability {
const { globallyAvailable, globalReason } = const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
useWebRTCGloballyAvailable(streamName);
const availability = useMemo( const availability = useMemo(
() => () =>
+15
View File
@@ -175,6 +175,21 @@ 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;
+1 -2
View File
@@ -132,8 +132,7 @@ export default function Events() {
setMotionSearchDay(normalizedDay); setMotionSearchDay(normalizedDay);
}, []); }, []);
const [notificationTab, setNotificationTab] = const [notificationTab, setNotificationTab] = useState<TimelineType>();
useState<TimelineType>("timeline");
const getReviewDayBounds = useCallback((date: Date) => { const getReviewDayBounds = useCallback((date: Date) => {
const now = Date.now() / 1000; const now = Date.now() / 1000;
+63 -2
View File
@@ -54,6 +54,12 @@ 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",
@@ -182,13 +188,22 @@ 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(), z.array(layoutItemSchema)), layouts: z.record(z.string(), storedLayoutSchema),
streaming: allGroupsStreamingSettingsSchema, streaming: allGroupsStreamingSettingsSchema,
preferences: z.record(z.string(), z.unknown()), preferences: z.record(z.string(), z.unknown()),
}), }),
@@ -204,6 +219,8 @@ 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) => {
@@ -213,7 +230,9 @@ export async function buildExportPayload(
username, username,
); );
if (value !== undefined) { // a group not opened since the mode changed still holds a layout from
// 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;
} }
}), }),
@@ -384,6 +403,30 @@ 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 ||
@@ -398,6 +441,9 @@ 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]) => {
@@ -408,6 +454,15 @@ export async function applyImportPayload(
), ),
); );
}); });
if (layoutsMode !== null) {
writes.push(
setData(
getUserNamespacedKey("naturalAspectLayout", username),
layoutsMode,
),
);
}
} }
const streamingEntry = TRANSFER_KEYS.find( const streamingEntry = TRANSFER_KEYS.find(
@@ -447,6 +502,12 @@ 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));
}, },
); );
+10 -65
View File
@@ -1,29 +1,18 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
/** /**
* Performs a real WebRTC handshake against go2rtc to verify that a media * Performs a single real WebRTC handshake against go2rtc to verify that a
* connection can actually be established (validates candidates, port 8555 * media connection can actually be established (validates candidates, port
* reachability, and STUN/TURN end-to-end). A success is cached for the page * 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
* session, a failure only for PROBE_FAILURE_TTL_MS. * session via a module-level promise.
*/ */
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;
}; };
const PROBE_FAILURE_TTL_MS = 30_000; let probePromise: Promise<WebRTCProbeResult> | null = null;
// 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);
@@ -129,67 +118,23 @@ 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(
testStreams: string[], testStream: string,
iceServers: RTCIceServer[], iceServers: RTCIceServer[],
timeoutMs: number = 5000, timeoutMs: number = 5000,
): Promise<WebRTCProbeResult> { ): Promise<WebRTCProbeResult> {
if ( if (!probePromise) {
cachedProbe && probePromise = runProbe(testStream, iceServers, timeoutMs);
(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 {
cachedProbe = null; probePromise = null;
} }
+366 -201
View File
@@ -8,16 +8,17 @@ 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 {
@@ -28,12 +29,11 @@ import {
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { 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 } from "react-device-detect"; import { isDesktop, isMobile, isMobileOnly } 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,6 +52,43 @@ 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;
@@ -98,6 +135,66 @@ 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);
@@ -115,7 +212,31 @@ export default function DraggableGridLayout({
// grid layout // grid layout
const [gridLayout, setGridLayout, isGridLayoutLoaded] = const [gridLayout, setGridLayout, isGridLayoutLoaded] =
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`); useUserPersistence<PersistedLayout>(`${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",
@@ -140,11 +261,11 @@ export default function DraggableGridLayout({
useEffect(() => { useEffect(() => {
setIsEditMode(false); setIsEditMode(false);
setEditGroup(false); setEditGroup(false);
// Reset camera tracking state when group changes to prevent the camera-change // Keeps the camera-change effect from overwriting the layout we load next.
// 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
@@ -155,21 +276,84 @@ 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 (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { if (
!isGridLayoutLoaded ||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
) {
return; return;
} }
// save layout to idb setGridLayout(toPersisted(currentLayout));
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) => { (baseLayout: Layout | undefined): Layout | undefined => {
if (!isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
return; return;
} }
@@ -179,91 +363,98 @@ 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 optionsMap: LayoutItem[] = baseLayout const existing: 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));
cameraNames.forEach((cameraName, index) => { const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
const existingLayout = optionsMap.find( // Each column starts below every existing tile that overlaps it, so new
(layout) => layout.i === cameraName, // cameras fill open columns without overlapping the user's tiles.
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,
),
); );
// Skip if the camera already exists in the layout const result: LayoutItem[] = [...existing];
if (existingLayout) {
cameraNames.forEach((name) => {
if (placed.has(name)) {
return; return;
} }
const { w, h } = dimsFor(name);
let aspectRatio; if (w === TILE_BASE_W) {
let col; let col = 0;
for (let c = 1; c < tileColumns; c++) {
// Handle "birdseye" camera as a special case if (colBottoms[c] < colBottoms[col]) {
if (cameraName === "birdseye") { col = c;
aspectRatio =
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
col = 0; // Set birdseye camera in the first column
} else {
const camera = cameras.find((cam) => cam.name === cameraName);
aspectRatio =
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
col = index % 3; // Regular cameras distributed across columns
} }
// 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;
} }
result.push({
const options = { i: name,
i: cameraName, x: col * TILE_BASE_W,
x: col * width, y: colBottoms[col],
y: 0, // don't set y, grid does automatically w,
w: width, h,
h: height, });
}; colBottoms[col] += h;
} else {
optionsMap.push(options); let pair = 0;
for (let c = 1; c + 1 < tileColumns; c++) {
if (
Math.max(colBottoms[c], colBottoms[c + 1]) <
Math.max(colBottoms[pair], colBottoms[pair + 1])
) {
pair = c;
}
}
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
colBottoms[pair] = y + h;
colBottoms[pair + 1] = y + h;
}
}); });
return optionsMap; return result;
}, },
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig], [cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
); );
useEffect(() => { useEffect(() => {
if (isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
if (gridLayout) { return;
// 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);
// Only save if cameras were added (layout changed) if (converted || !isEqual(updatedLayout, base)) {
if (!isEqual(updatedLayout, gridLayout)) { setGridLayout(toPersisted(updatedLayout));
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 {
// idb is empty, set it with an initial layout // empty or incompatible (pre-masonry) data
const newLayout = generateLayout(undefined); const newLayout = generateLayout(undefined) ?? [];
setCurrentGridLayout(newLayout); setCurrentGridLayout(newLayout);
setGridLayout(newLayout); setGridLayout(toPersisted(newLayout));
setCurrentCameras(cameras); setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye); setCurrentIncludeBirdseye(includeBirdseye);
} setCurrentNaturalAspect(naturalAspectLayout);
} }
}, [ }, [
gridLayout, gridLayout,
@@ -272,12 +463,15 @@ export default function DraggableGridLayout({
generateLayout, generateLayout,
cameras, cameras,
includeBirdseye, includeBirdseye,
naturalAspectLayout,
readPersistedLayout,
convertLegacyLayout,
toPersisted,
]); ]);
useEffect(() => { useEffect(() => {
// Only regenerate layout when cameras change WITHIN an already-loaded group // Only for camera changes within a loaded group; undefined currentCameras
// Skip if currentCameras is undefined (means we just switched groups and // means the load effect above has not run yet.
// the first useEffect hasn't run yet to set things up)
if (!isGridLayoutLoaded || currentCameras === undefined) { if (!isGridLayoutLoaded || currentCameras === undefined) {
return; return;
} }
@@ -289,10 +483,10 @@ export default function DraggableGridLayout({
setCurrentCameras(cameras); setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye); setCurrentIncludeBirdseye(includeBirdseye);
// Regenerate layout based on current layout, adding any new cameras const updatedLayout =
const updatedLayout = generateLayout(currentGridLayout); generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
setCurrentGridLayout(updatedLayout); setCurrentGridLayout(updatedLayout);
setGridLayout(updatedLayout); setGridLayout(toPersisted(updatedLayout));
} }
}, [ }, [
cameras, cameras,
@@ -303,39 +497,47 @@ export default function DraggableGridLayout({
generateLayout, generateLayout,
setGridLayout, setGridLayout,
isGridLayoutLoaded, isGridLayoutLoaded,
toPersisted,
]); ]);
const [marginValue, setMarginValue] = useState(16); useEffect(() => {
if (
!isNaturalAspectLoaded ||
currentNaturalAspect === undefined ||
currentNaturalAspect === naturalAspectLayout
) {
return;
}
// calculate margin value for browsers that don't have default font size of 16px setCurrentNaturalAspect(naturalAspectLayout);
useLayoutEffect(() => { const regenerated = generateLayout(undefined) ?? [];
const calculateRemValue = () => { setCurrentGridLayout(regenerated);
const htmlElement = document.documentElement; setGridLayout(toPersisted(regenerated));
const fontSize = window.getComputedStyle(htmlElement).fontSize; }, [
setMarginValue(parseFloat(fontSize)); naturalAspectLayout,
}; isNaturalAspectLoaded,
currentNaturalAspect,
generateLayout,
setGridLayout,
toPersisted,
]);
calculateRemValue(); const gridContainerRef = useRef<HTMLDivElement | null>(null);
// 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 scrollBarWidth = useMemo(() => { const availableWidth = containerWidth || mountWidth || 0;
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) {
@@ -346,61 +548,10 @@ export default function DraggableGridLayout({
}, [containerRef, containerHeight]); }, [containerRef, containerHeight]);
const cellHeight = useMemo(() => { const cellHeight = useMemo(() => {
const aspectRatio = 16 / 9; const width = availableWidth || window.innerWidth;
// subtract container margin, 1 camera takes up at least 4 rows const columnWidth = width / GRID_COLS;
// account for additional margin on bottom of each row return columnWidth / VERTICAL_RESOLUTION;
return ( }, [availableWidth]);
((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
@@ -503,6 +654,19 @@ 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} />
@@ -525,8 +689,8 @@ export default function DraggableGridLayout({
</div> </div>
) : ( ) : (
<div <div
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8" className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
ref={gridContainerRef} ref={attachGridContainer}
> >
<EditGroupDialog <EditGroupDialog
open={editGroup} open={editGroup}
@@ -536,28 +700,36 @@ export default function DraggableGridLayout({
/> />
<Responsive <Responsive
className="grid-layout" className="grid-layout"
width={availableWidth ?? window.innerWidth} width={availableWidth || window.innerWidth}
layouts={{ layouts={{
lg: currentGridLayout, lg: layoutWithConstraints,
md: currentGridLayout, md: layoutWithConstraints,
sm: currentGridLayout, sm: layoutWithConstraints,
xs: currentGridLayout, xs: layoutWithConstraints,
xxs: currentGridLayout, xxs: layoutWithConstraints,
}} }}
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={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }} cols={{
margin={[marginValue, marginValue]} lg: GRID_COLS,
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,
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [], // se only: top/left handles fight the aspect constraint at a grid
// 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}
> >
@@ -570,22 +742,12 @@ 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] || "";
@@ -614,7 +776,14 @@ export default function DraggableGridLayout({
?.compatibilityMode || false; ?.compatibilityMode || false;
return ( return (
<GridLiveContextMenu <GridLiveContextMenu
className={grow} className={CARD_FIT}
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}
@@ -653,8 +822,8 @@ export default function DraggableGridLayout({
useWebGL={useWebGL} useWebGL={useWebGL}
cameraRef={cameraRef} cameraRef={cameraRef}
className={cn( className={cn(
"rounded-lg bg-black md:rounded-2xl", "size-full",
grow, naturalAspectLayout ? "bg-background" : "bg-black",
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",
@@ -675,8 +844,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>
); );
})} })}
@@ -694,6 +863,7 @@ 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)
@@ -762,17 +932,6 @@ 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;
@@ -781,6 +940,7 @@ 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;
}; };
@@ -798,6 +958,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
onTouchEnd, onTouchEnd,
children, children,
birdseyeConfig, birdseyeConfig,
aspectRatio: cellAspect,
liveMode, liveMode,
onClick, onClick,
...props ...props
@@ -806,7 +967,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
) => { ) => {
return ( return (
<div <div
style={{ ...style }} className="flex items-center justify-center p-1 [container-type:size]"
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
ref={ref} ref={ref}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
@@ -814,7 +976,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
{...props} {...props}
> >
<BirdseyeLivePlayer <BirdseyeLivePlayer
className={className} className={cn(CARD_FIT, className)}
birdseyeConfig={birdseyeConfig} birdseyeConfig={birdseyeConfig}
liveMode={liveMode} liveMode={liveMode}
onClick={onClick} onClick={onClick}
@@ -829,6 +991,7 @@ 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>;
@@ -860,6 +1023,7 @@ const GridLiveContextMenu = React.forwardRef<
{ {
className, className,
style, style,
aspectRatio: cameraAspect,
onMouseDown, onMouseDown,
onMouseUp, onMouseUp,
onTouchEnd, onTouchEnd,
@@ -887,7 +1051,8 @@ const GridLiveContextMenu = React.forwardRef<
) => { ) => {
return ( return (
<div <div
style={{ ...style }} className="flex items-center justify-center p-1 [container-type:size]"
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
ref={ref} ref={ref}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
+1 -1
View File
@@ -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}
+13 -24
View File
@@ -27,7 +27,10 @@ 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 { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability"; import {
useWebRTCAvailableForStream,
useWebRTCGloballyAvailable,
} from "@/hooks/use-webrtc-availability";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { import {
LivePlayerError, LivePlayerError,
@@ -216,16 +219,15 @@ 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>(() => {
@@ -396,14 +398,6 @@ 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);
@@ -430,7 +424,7 @@ export default function LiveCameraView({
}); });
const preferredLiveMode = useMemo(() => { const preferredLiveMode = useMemo(() => {
if (mic && talkAvailable) { if (mic && isWebRTCAvailable) {
return "webrtc"; return "webrtc";
} }
@@ -462,7 +456,6 @@ export default function LiveCameraView({
lowBandwidth, lowBandwidth,
forceLowBandwidth, forceLowBandwidth,
mic, mic,
talkAvailable,
webRTC, webRTC,
isRestreamed, isRestreamed,
resolvedUserMode, resolvedUserMode,
@@ -496,7 +489,7 @@ export default function LiveCameraView({
} }
break; break;
case "t": case "t":
if (supports2WayTalk && talkAvailable) { if (supports2WayTalk) {
setMic(!mic); setMic(!mic);
return true; return true;
} }
@@ -744,11 +737,7 @@ export default function LiveCameraView({
Icon={mic ? FaMicrophone : FaMicrophoneSlash} Icon={mic ? FaMicrophone : FaMicrophoneSlash}
isActive={mic} isActive={mic}
title={ title={
webRTCVerdictPending !webRTCGloballyAvailable
? 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" })
@@ -760,7 +749,7 @@ export default function LiveCameraView({
setAudio(true); setAudio(true);
} }
}} }}
disabled={!cameraEnabled || debug || !talkAvailable} disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
/> />
)} )}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( {supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@@ -871,7 +860,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}
+2 -2
View File
@@ -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 md:p-2" className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] 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} rounded-lg bg-black md:rounded-2xl`} className={`${grow} bg-black`}
windowVisible={ windowVisible={
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
+13 -2
View File
@@ -14,6 +14,7 @@ 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";
@@ -182,9 +183,19 @@ export function RecordingView({
false, false,
); );
const [timelineType, setTimelineType] = useOverlayState<TimelineType>( const [savedTimelineType, setSavedTimelineType] =
useUserPersistence<TimelineType>("recordingTimelineType");
const [timelineType, setOverlayTimelineType] = useOverlayState<TimelineType>(
"timelineType", "timelineType",
recording?.timelineType ?? "timeline", recording?.timelineType ?? savedTimelineType ?? "timeline",
);
const setTimelineType = useCallback(
(value: TimelineType, replace?: boolean) => {
setOverlayTimelineType(value, replace);
setSavedTimelineType(value);
},
[setOverlayTimelineType, setSavedTimelineType],
); );
const chunkedTimeRange = useMemo( const chunkedTimeRange = useMemo(
+125 -3
View File
@@ -10,13 +10,14 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../../components/ui/button"; import { Button, buttonVariants } 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,
@@ -33,6 +34,17 @@ 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,
@@ -51,6 +63,8 @@ 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;
@@ -321,6 +335,10 @@ 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);
@@ -329,6 +347,66 @@ 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",
@@ -351,6 +429,18 @@ 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 (
@@ -419,7 +509,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={clearStoredLayouts} onClick={() => setPendingConfirm("layouts")}
> >
{t("general.storedLayouts.clearAll")} {t("general.storedLayouts.clearAll")}
</Button> </Button>
@@ -435,7 +525,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={clearStreamingSettings} onClick={() => setPendingConfirm("streaming")}
> >
{t("general.cameraGroupStreaming.clearAll")} {t("general.cameraGroupStreaming.clearAll")}
</Button> </Button>
@@ -569,9 +659,41 @@ 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>
); );
} }