mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-07 14:04:10 +03:00
Compare commits
21 Commits
115cc0a5b4
...
0a7b2c6a2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7b2c6a2b | ||
|
|
6f851c3a06 | ||
|
|
a8c7de6498 | ||
|
|
1356405a2e | ||
|
|
d22b6eab04 | ||
|
|
413cc9d6da | ||
|
|
11f9683e6a | ||
|
|
7689b2647b | ||
|
|
4e634c93f1 | ||
|
|
f6e992c78a | ||
|
|
c23f7a96ff | ||
|
|
7bcd195eb1 | ||
|
|
d89f945400 | ||
|
|
3c0ee60cf6 | ||
|
|
854de0f32e | ||
|
|
0362d8f20b | ||
|
|
50da6d8405 | ||
|
|
3f1e3ec6f3 | ||
|
|
aa09132dfd | ||
|
|
24766ce427 | ||
|
|
97b29d177a |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -15,7 +15,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.9
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
jobs:
|
||||
amd64_build:
|
||||
|
||||
@ -15,7 +15,7 @@ ARG AMDGPU
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1/ubuntu/jammy/amdgpu-install_7.1.70100-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
||||
default = "gfx900"
|
||||
}
|
||||
variable "ROCM" {
|
||||
default = "7.1.0"
|
||||
default = "7.1.1"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -157,3 +157,19 @@ Only one `speech` event may be transcribed at a time. Frigate does not automatic
|
||||
:::
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
|
||||
#### FAQ
|
||||
|
||||
1. Why doesn't Frigate automatically transcribe all `speech` events?
|
||||
|
||||
Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. That’s a significant amount of complexity for a feature that, in most real-world environments, would mostly just churn through low-value noise.
|
||||
|
||||
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
||||
|
||||
If you hear speech that’s actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||
|
||||
2. Why don't you save live transcription text and use that for `speech` events?
|
||||
|
||||
There’s no guarantee that a `speech` event is even created from the exact audio that went through the transcription model. Live transcription and `speech` event creation are **separate, asynchronous processes**. Even when both are correctly configured, trying to align the **precise start and end time of a speech event** with whatever audio the model happened to be processing at that moment is unreliable.
|
||||
|
||||
Automatically persisting that data would often result in **misaligned, partial, or irrelevant transcripts**, while still incurring all of the CPU, storage, and privacy costs of transcription. That’s why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event.
|
||||
|
||||
@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
if self.inference_speed:
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def _should_save_image(
|
||||
self, camera: str, detected_state: str, score: float = 1.0
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if we should save the image for training.
|
||||
Save when:
|
||||
- State is changing or being verified (regardless of score)
|
||||
- Score is less than 100% (even if state matches, useful for training)
|
||||
Don't save when:
|
||||
- State is stable (matches current_state) AND score is 100%
|
||||
"""
|
||||
if camera not in self.state_history:
|
||||
# First detection for this camera, save it
|
||||
return True
|
||||
|
||||
verification = self.state_history[camera]
|
||||
current_state = verification.get("current_state")
|
||||
pending_state = verification.get("pending_state")
|
||||
|
||||
# Save if there's a pending state change being verified
|
||||
if pending_state is not None:
|
||||
return True
|
||||
|
||||
# Save if the detected state differs from the current verified state
|
||||
# (state is changing)
|
||||
if current_state is not None and detected_state != current_state:
|
||||
return True
|
||||
|
||||
# If score is less than 100%, save even if state matches
|
||||
# (useful for training to improve confidence)
|
||||
if score < 1.0:
|
||||
return True
|
||||
|
||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||
return False
|
||||
|
||||
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||
"""
|
||||
Verify state change requires 3 consecutive identical states before publishing.
|
||||
@ -212,14 +248,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
return
|
||||
|
||||
if self.interpreter is None:
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
"unknown",
|
||||
0.0,
|
||||
)
|
||||
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||
if self._should_save_image(camera, "unknown", 0.0):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
"unknown",
|
||||
0.0,
|
||||
)
|
||||
return
|
||||
|
||||
input = np.expand_dims(resized_frame, axis=0)
|
||||
@ -236,14 +274,17 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
self.labelmap[best_id],
|
||||
score,
|
||||
)
|
||||
detected_state = self.labelmap[best_id]
|
||||
|
||||
if self._should_save_image(camera, detected_state, score):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
detected_state,
|
||||
score,
|
||||
)
|
||||
|
||||
if score < self.model_config.threshold:
|
||||
logger.debug(
|
||||
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
detected_state = self.labelmap[best_id]
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
|
||||
if verified_state is not None:
|
||||
|
||||
@ -190,7 +190,11 @@ class OnvifController:
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
@ -381,7 +385,10 @@ class OnvifController:
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["video_source_token"] is not None:
|
||||
if (
|
||||
self.cams[camera_name]["video_source_token"] is not None
|
||||
and imaging is not None
|
||||
):
|
||||
try:
|
||||
imaging_capabilities = await imaging.GetImagingSettings(
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
@ -421,6 +428,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||
@ -648,6 +656,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" not in self.cams[camera_name]["features"]
|
||||
or not self.cams[camera_name]["video_source_token"]
|
||||
or self.cams[camera_name]["imaging"] is None
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
@ -124,45 +124,50 @@ def capture_frames(
|
||||
config_subscriber.check_for_updates()
|
||||
return config.enabled
|
||||
|
||||
while not stop_event.is_set():
|
||||
if not get_enabled_state():
|
||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||
break
|
||||
|
||||
fps.value = frame_rate.eps()
|
||||
skipped_fps.value = skipped_eps.eps()
|
||||
current_frame.value = datetime.now().timestamp()
|
||||
frame_name = f"{config.name}_frame{frame_index}"
|
||||
frame_buffer = frame_manager.write(frame_name)
|
||||
try:
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception:
|
||||
# shutdown has been initiated
|
||||
if stop_event.is_set():
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if not get_enabled_state():
|
||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||
break
|
||||
|
||||
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.")
|
||||
fps.value = frame_rate.eps()
|
||||
skipped_fps.value = skipped_eps.eps()
|
||||
current_frame.value = datetime.now().timestamp()
|
||||
frame_name = f"{config.name}_frame{frame_index}"
|
||||
frame_buffer = frame_manager.write(frame_name)
|
||||
try:
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception:
|
||||
# shutdown has been initiated
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
if ffmpeg_process.poll() is not None:
|
||||
logger.error(
|
||||
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
|
||||
f"{config.name}: Unable to read frames from ffmpeg process."
|
||||
)
|
||||
break
|
||||
|
||||
continue
|
||||
if ffmpeg_process.poll() is not None:
|
||||
logger.error(
|
||||
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
|
||||
)
|
||||
break
|
||||
|
||||
frame_rate.update()
|
||||
continue
|
||||
|
||||
# don't lock the queue to check, just try since it should rarely be full
|
||||
try:
|
||||
# add to the queue
|
||||
frame_queue.put((frame_name, current_frame.value), False)
|
||||
frame_manager.close(frame_name)
|
||||
except queue.Full:
|
||||
# if the queue is full, skip this frame
|
||||
skipped_eps.update()
|
||||
frame_rate.update()
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
# don't lock the queue to check, just try since it should rarely be full
|
||||
try:
|
||||
# add to the queue
|
||||
frame_queue.put((frame_name, current_frame.value), False)
|
||||
frame_manager.close(frame_name)
|
||||
except queue.Full:
|
||||
# if the queue is full, skip this frame
|
||||
skipped_eps.update()
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
finally:
|
||||
config_subscriber.stop()
|
||||
|
||||
|
||||
class CameraWatchdog(threading.Thread):
|
||||
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
|
||||
else:
|
||||
self.ffmpeg_detect_process.wait()
|
||||
|
||||
# Wait for old capture thread to fully exit before starting a new one
|
||||
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||
self.logger.info("Waiting for capture thread to exit...")
|
||||
self.capture_thread.join(timeout=5)
|
||||
|
||||
if self.capture_thread.is_alive():
|
||||
self.logger.warning(
|
||||
f"Capture thread for {self.config.name} did not exit in time"
|
||||
)
|
||||
|
||||
self.logger.error(
|
||||
"The following ffmpeg logs include the last 100 lines prior to exit."
|
||||
)
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"masksAndZones": "Editor masky a zón - Frigate",
|
||||
"motionTuner": "Ladění detekce pohybu - Frigate",
|
||||
"object": "Ladění - Frigate",
|
||||
"general": "Obecné nastavení - Frigate",
|
||||
"general": "Nastavení rozhraní- Frigate",
|
||||
"frigatePlus": "Frigate+ nastavení - Frigate",
|
||||
"enrichments": "Nastavení obohacení - Frigate",
|
||||
"cameraManagement": "Správa kamer - Frigate",
|
||||
|
||||
@ -481,7 +481,7 @@
|
||||
"clickety_clack": "Klappergeräuschen",
|
||||
"rumble": "Grollen",
|
||||
"plop": "plumpsen",
|
||||
"hum": "brummen",
|
||||
"hum": "Brummen",
|
||||
"zing": "Schwung",
|
||||
"boing": "ferderndes Geräusch",
|
||||
"crunch": "knirschendes",
|
||||
|
||||
@ -11,6 +11,6 @@
|
||||
},
|
||||
"user": "Benutzername",
|
||||
"password": "Kennwort",
|
||||
"firstTimeLogin": "Versuchen Sie sich zum ersten Mal anzumelden? Die Anmeldedaten sind in den Frigate-Logs aufgeführt."
|
||||
"firstTimeLogin": "Ist dies der erste Loginversuch? Die Zugangsdaten werden in den Frigate Logs angezeigt."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"documentTitle": "Klassifikation Modelle",
|
||||
"documentTitle": "Klassifikationsmodelle",
|
||||
"details": {
|
||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Klassifizierungssicherheit aller Erkennungen dieses Objekts wieder."
|
||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Konfidenz aller Erkennungen dieses Objekts wieder."
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Lösche Klassifizierungsbilder",
|
||||
"deleteClassificationAttempts": "Lösche klassifizierte Bilder",
|
||||
"renameCategory": "Klasse umbenennen",
|
||||
"deleteCategory": "Klasse löschen",
|
||||
"deleteImages": "Bilder löschen",
|
||||
@ -14,18 +14,18 @@
|
||||
"editModel": "Modell bearbeiten"
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Modell werden trainiert",
|
||||
"trainingInProgress": "Modell wird gerade trainiert",
|
||||
"noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.",
|
||||
"noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.",
|
||||
"modelNotReady": "Modell ist nicht bereit zum Training"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Gelöschte Klasse",
|
||||
"deletedCategory": "Klasse gelöscht",
|
||||
"deletedImage": "Bilder gelöscht",
|
||||
"deletedModel_one": "{{count}} Model erfolgreich gelöscht",
|
||||
"deletedModel_one": "{{count}} Modell erfolgreich gelöscht",
|
||||
"deletedModel_other": "{{count}} Modelle erfolgreich gelöscht",
|
||||
"categorizedImage": "Bild erfolgreich klassifiziert",
|
||||
"categorizedImage": "Erfolgreich klassifizierte Bilder",
|
||||
"trainedModel": "Modell erfolgreich trainiert.",
|
||||
"trainingModel": "Modelltraining erfolgreich gestartet.",
|
||||
"updatedModel": "Modellkonfiguration erfolgreich aktualisiert",
|
||||
@ -33,7 +33,7 @@
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Löschen der Klasse fehlgeschlagen: {{errorMessage}}",
|
||||
"deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}",
|
||||
"trainingFailedToStart": "Modelltraining konnte nicht gestartet werden: {{errorMessage}}",
|
||||
"updateModelFailed": "Aktualisierung des Modells fehlgeschlagen: {{errorMessage}}",
|
||||
@ -152,7 +152,7 @@
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Wählen sie alle Bilder mit: {{className}}",
|
||||
"selectImagesDescription": "Klicken Sie auf die Bilder, um sie auszuwählen. Klicken Sie auf „Weiter“, wenn Sie mit diesem Kurs fertig sind.",
|
||||
"allImagesRequired_one": "Bitte klassifizieren Sie alle Bilder. {{count}} Bilder verbleiben.",
|
||||
"allImagesRequired_one": "Bitte klassifizieren Sie alle Bilder. {{count}} Bild verbleibend.",
|
||||
"allImagesRequired_other": "Bitte klassifizieren Sie alle Bilder. {{count}} Bilder verbleiben.",
|
||||
"generating": {
|
||||
"title": "Beispielbilder generieren",
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
"deleteFace": "Lösche Gesicht"
|
||||
},
|
||||
"train": {
|
||||
"title": "Aktuelle Erkennungen",
|
||||
"title": "Kürzliche Erkennungen",
|
||||
"aria": "Wähle aktuelle Erkennungen",
|
||||
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung"
|
||||
},
|
||||
|
||||
@ -3,16 +3,16 @@
|
||||
"default": "Einstellungen - Frigate",
|
||||
"authentication": "Authentifizierungseinstellungen – Frigate",
|
||||
"camera": "Kameraeinstellungen - Frigate",
|
||||
"masksAndZones": "Masken- und Zonen-Editor – Frigate",
|
||||
"masksAndZones": "Masken- und Zoneneditor – Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "UI Einstellungen – Frigate",
|
||||
"frigatePlus": "Frigate+ Einstellungen – Frigate",
|
||||
"classification": "Klassifizierungseinstellungen – Frigate",
|
||||
"motionTuner": "Bewegungserkennungs-Optimierer – Frigate",
|
||||
"notifications": "Benachrichtigungs-Einstellungen",
|
||||
"notifications": "Benachrichtigungseinstellungen",
|
||||
"enrichments": "Erweiterte Statistiken - Frigate",
|
||||
"cameraManagement": "Kameras verwalten - Frigate",
|
||||
"cameraReview": "Kamera Einstellungen prüfen - Frigate"
|
||||
"cameraReview": "Kameraeinstellungen prüfen - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"ui": "Benutzeroberfläche",
|
||||
@ -695,13 +695,13 @@
|
||||
"semanticSearch": {
|
||||
"reindexNow": {
|
||||
"confirmDesc": "Sind Sie sicher, dass Sie alle verfolgten Objekteinbettungen neu indizieren wollen? Dieser Prozess läuft im Hintergrund, kann aber Ihre CPU auslasten und eine gewisse Zeit in Anspruch nehmen. Sie können den Fortschritt auf der Seite Explore verfolgen.",
|
||||
"label": "Jetzt neu indizien",
|
||||
"label": "Jetzt neu indizieren",
|
||||
"desc": "Bei der Neuindizierung werden die Einbettungen für alle verfolgten Objekte neu generiert. Dieser Prozess läuft im Hintergrund und kann je nach Anzahl der verfolgten Objekte Ihre CPU auslasten und eine gewisse Zeit in Anspruch nehmen.",
|
||||
"confirmTitle": "Neuinszenierung bestätigen",
|
||||
"confirmTitle": "Neuindizierung bestätigen",
|
||||
"confirmButton": "Neuindizierung",
|
||||
"success": "Die Neuindizierung wurde erfolgreich gestartet.",
|
||||
"alreadyInProgress": "Die Neuindizierung ist bereits im Gange.",
|
||||
"error": "Neuindizierung konnte nicht gestartet werden: {{errorMessage}}"
|
||||
"error": "Die Neuindizierung konnte nicht gestartet werden: {{errorMessage}}"
|
||||
},
|
||||
"modelSize": {
|
||||
"small": {
|
||||
@ -724,7 +724,7 @@
|
||||
"desc": "Die Gesichtserkennung ermöglicht es, Personen Namen zuzuweisen, und wenn ihr Gesicht erkannt wird, ordnet Frigate den Namen der Person als Untertitel zu. Diese Informationen sind in der Benutzeroberfläche, den Filtern und in den Benachrichtigungen enthalten.",
|
||||
"readTheDocumentation": "Lies die Dokumentation",
|
||||
"modelSize": {
|
||||
"label": "Modell Größe",
|
||||
"label": "Modellgröße",
|
||||
"desc": "Die Größe des für die Gesichtserkennung verwendeten Modells.",
|
||||
"small": {
|
||||
"title": "klein",
|
||||
@ -960,7 +960,7 @@
|
||||
},
|
||||
"step1": {
|
||||
"description": "Geben Sie Ihre Kameradaten ein und wählen Sie, ob Sie die Kamera automatisch erkennen lassen oder die Marke manuell auswählen möchten.",
|
||||
"cameraName": "Kamera-Name",
|
||||
"cameraName": "Kameraname",
|
||||
"cameraNamePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
|
||||
"host": "Host/IP Adresse",
|
||||
"port": "Port",
|
||||
@ -969,8 +969,8 @@
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "Optional",
|
||||
"selectTransport": "Transport-Protokoll auswählen",
|
||||
"cameraBrand": "Kamera-Hersteller",
|
||||
"selectBrand": "Wähle die Kamera-Hersteller für die URL-Vorlage aus",
|
||||
"cameraBrand": "Kamerahersteller",
|
||||
"selectBrand": "Wähle die Kamerahersteller für die URL-Vorlage aus",
|
||||
"customUrl": "Benutzerdefinierte Stream-URL",
|
||||
"brandInformation": "Hersteller Information",
|
||||
"brandUrlFormat": "Für Kameras mit RTSP URL nutze folgendes Format: {{exampleUrl}}",
|
||||
@ -983,11 +983,11 @@
|
||||
"noSnapshot": "Es kann kein Snapshot aus dem konfigurierten Stream abgerufen werden."
|
||||
},
|
||||
"errors": {
|
||||
"brandOrCustomUrlRequired": "Wählen Sie entweder einen Kamera-Hersteller mit Host/IP aus oder wählen Sie „Andere“ mit einer benutzerdefinierten URL",
|
||||
"nameRequired": "Kamera-Name benötigt",
|
||||
"nameLength": "Kamera-Name darf höchsten 64 Zeichen lang sein",
|
||||
"invalidCharacters": "Kamera-Name enthält ungültige Zeichen",
|
||||
"nameExists": "Kamera-Name existiert bereits",
|
||||
"brandOrCustomUrlRequired": "Wählen Sie entweder einen Kamerahersteller mit Host/IP aus oder wählen Sie „Andere“ mit einer benutzerdefinierten URL",
|
||||
"nameRequired": "Der Kameraname wird benötigt",
|
||||
"nameLength": "Der Kameraname darf höchsten 64 Zeichen lang sein",
|
||||
"invalidCharacters": "Der Kameraname enthält ungültige Zeichen",
|
||||
"nameExists": "Der Kameraname existiert bereits",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten."
|
||||
},
|
||||
@ -999,7 +999,7 @@
|
||||
"connectionSettings": "Verbindungseinstellungen",
|
||||
"detectionMethod": "Stream Erkennungsmethode",
|
||||
"onvifPort": "ONVIF Port",
|
||||
"probeMode": "Sondenkamera",
|
||||
"probeMode": "Untersuche Kamera",
|
||||
"detectionMethodDescription": "Suchen Sie die Kamera mit ONVIF (sofern unterstützt), um die URLs der Kamerastreams zu finden, oder wählen Sie manuell die Kameramarke aus, um vordefinierte URLs zu verwenden. Um eine benutzerdefinierte RTSP-URL einzugeben, wählen Sie die manuelle Methode und dann „Andere“.",
|
||||
"onvifPortDescription": "Bei Kameras, die ONVIF unterstützen, ist dies in der Regel 80 oder 8080.",
|
||||
"useDigestAuth": "Digest-Authentifizierung verwenden",
|
||||
@ -1027,7 +1027,7 @@
|
||||
},
|
||||
"testStream": "Verbindung testen",
|
||||
"testSuccess": "Verbindung erfolgreich getestet!",
|
||||
"testFailed": "Verbindungstest fehlgeschlagen. Bitte überprüfen sie Eingabe und versuchen sie es wieder.",
|
||||
"testFailed": "Verbindungstest fehlgeschlagen. Bitte überprüfen Sie ihre Eingaben und versuchen Sie es erneut.",
|
||||
"testFailedTitle": "Test fehlgeschlagen",
|
||||
"connected": "Verbunden",
|
||||
"notConnected": "Nicht verbunden",
|
||||
@ -1051,11 +1051,11 @@
|
||||
"probingMetadata": "Metadaten der Kamera werden überprüft...",
|
||||
"fetchingSnapshot": "Kamera-Schnappschuss wird abgerufen..."
|
||||
},
|
||||
"probeFailed": "Fehler beim Testen der Kamera: {{error}}",
|
||||
"probingDevice": "Prüfung Gerät...",
|
||||
"probeSuccessful": "Sonde erfolgreich",
|
||||
"probeError": "Sondenfehler",
|
||||
"probeNoSuccess": "Sonde erfolglos",
|
||||
"probeFailed": "Fehler beim Untersuchen der Kamera: {{error}}",
|
||||
"probingDevice": "Untersuche Gerät...",
|
||||
"probeSuccessful": "Erkennung erfolgreich",
|
||||
"probeError": "Erkennungsfehler",
|
||||
"probeNoSuccess": "Erkennung fehlgeschlagen",
|
||||
"deviceInfo": "Geräteinformationen",
|
||||
"manufacturer": "Hersteller",
|
||||
"model": "Modell",
|
||||
@ -1065,12 +1065,12 @@
|
||||
"autotrackingSupport": "Unterstützung für Autoverfolgung",
|
||||
"presets": "Voreinstellung",
|
||||
"rtspCandidates": "RTSP Kandidaten",
|
||||
"rtspCandidatesDescription": "Die folgenden RTSP-URLs wurden bei der Kameraprobe gefunden. Testen Sie die Verbindung, um die Stream-Metadaten anzuzeigen.",
|
||||
"rtspCandidatesDescription": "Die folgenden RTSP-URLs wurden bei der Kameraerkennung gefunden. Testen Sie die Verbindung, um die Stream-Metadaten anzuzeigen.",
|
||||
"noRtspCandidates": "Es wurden keine RTSP-URLs von der Kamera gefunden. Möglicherweise sind Ihre Anmeldedaten falsch oder die Kamera unterstützt ONVIF oder die Methode zum Abrufen von RTSP-URLs nicht. Gehen Sie zurück und geben Sie die RTSP-URL manuell ein.",
|
||||
"candidateStreamTitle": "Kandidate {{number}}",
|
||||
"useCandidate": "Verwenden",
|
||||
"uriCopy": "Kopieren",
|
||||
"uriCopied": "ULR in Zwischenablage kopiert",
|
||||
"uriCopied": "URI in die Zwischenablage kopiert",
|
||||
"testConnection": "Test Verbindung",
|
||||
"toggleUriView": "Klicken Sie hier, um die vollständige URI zu sehen",
|
||||
"errors": {
|
||||
@ -1117,7 +1117,7 @@
|
||||
}
|
||||
},
|
||||
"streamsTitle": "Kamera Stream",
|
||||
"addStream": "Hizufügen Stream",
|
||||
"addStream": "Stream hinzufügen",
|
||||
"addAnotherStream": "weiteren Stream hinzufügen",
|
||||
"streamUrl": "Stream URL",
|
||||
"streamUrlPlaceholder": "rtsp://benutzername:passwort@host:port/path",
|
||||
@ -1130,7 +1130,7 @@
|
||||
"quality": "Qualität",
|
||||
"selectQuality": "Wähle Qualität",
|
||||
"roleLabels": {
|
||||
"detect": "Objekt Erkennung",
|
||||
"detect": "Objekterkennung",
|
||||
"record": "Aufnahme",
|
||||
"audio": "Ton"
|
||||
},
|
||||
@ -1139,7 +1139,7 @@
|
||||
"testFailed": "Verbindungstest fehlgeschlagen",
|
||||
"testFailedTitle": "Test fehlgeschlagen",
|
||||
"connected": "Verbunden",
|
||||
"notConnected": "nicht Verbunden",
|
||||
"notConnected": "nicht verbunden",
|
||||
"featuresTitle": "Funktionen",
|
||||
"go2rtc": "Verbindungen zur Kamera reduzieren",
|
||||
"detectRoleWarning": "Mindestens ein Stream muss die Rolle „detect“ haben, um fortfahren zu können.",
|
||||
@ -1176,7 +1176,7 @@
|
||||
"ffmpegModuleDescription": "Wenn der Stream nach mehreren Versuchen nicht geladen wird, versuchen Sie, diese Option zu aktivieren. Wenn diese Option aktiviert ist, verwendet Frigate das ffmpeg-Modul mit go2rtc. Dies kann zu einer besseren Kompatibilität mit einigen Kamerastreams führen.",
|
||||
"none": "keiner",
|
||||
"error": "Fehler",
|
||||
"streamValidated": "Steeam {{number}} Erfolgreich validiert",
|
||||
"streamValidated": "Steam {{number}} erfolgreich validiert",
|
||||
"streamValidationFailed": "Stream {{number}} Validierung fehlgeschlagen",
|
||||
"saveAndApply": "Neue Kamera speichern",
|
||||
"saveError": "Ungültige Konfiguration. Bitte überprüfen Sie Ihre Einstellungen.",
|
||||
@ -1216,9 +1216,9 @@
|
||||
"add": "Kamera hinzufügen",
|
||||
"edit": "Kamera bearbeiten",
|
||||
"description": "Konfiguriere die Kameraeinstellungen, einschließlich Streams und Rollen.",
|
||||
"name": "Kamera-Name",
|
||||
"nameRequired": "Kamera-Name benötigt",
|
||||
"nameLength": "Kamera-Name darf maximal 64 Zeichen lang sein.",
|
||||
"name": "Kameraname",
|
||||
"nameRequired": "Kameraname benötigt",
|
||||
"nameLength": "Kameraname darf maximal 64 Zeichen lang sein.",
|
||||
"namePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
|
||||
"enabled": "Aktiviert",
|
||||
"ffmpeg": {
|
||||
@ -1253,14 +1253,14 @@
|
||||
"desc": "Generative KI Review Beschreibungen für diese Kamera vorübergehend aktivieren/deaktivieren. Wenn diese Option deaktiviert ist, werden für die Review Elemente dieser Kamera keine KI-generierten Beschreibungen angefordert."
|
||||
},
|
||||
"review": {
|
||||
"title": "Review",
|
||||
"title": "Überprüfung",
|
||||
"desc": "Aktivieren/deaktivieren Sie vorübergehend Warnmeldungen und Erkennungen für diese Kamera, bis Frigate neu gestartet wird. Wenn diese Funktion deaktiviert ist, werden keine neuen Überprüfungselemente generiert. ",
|
||||
"alerts": "Warnungen ",
|
||||
"detections": "Erkennungen "
|
||||
},
|
||||
"reviewClassification": {
|
||||
"title": "Bewertungsklassifizierung",
|
||||
"desc": "Frigate kategorisiert zu überprüfende Elemente als Warnmeldungen und Erkennungen. Standardmäßig werden alle Objekte vom Typ <em>person</em> und <em>car</em> als Warnmeldungen betrachtet. Sie können die Kategorisierung der zu überprüfenden Elemente verfeinern, indem Sie die erforderlichen Zonen für sie konfigurieren.",
|
||||
"desc": "Frigate kategorisiert zu überprüfende Elemente als Warnmeldungen und Erkennungen. Standardmäßig werden alle Objekte vom Typ <em>Person</em> und <em>Auto</em> als Warnmeldungen betrachtet. Sie können die Kategorisierung der zu überprüfenden Elemente verfeinern, indem Sie die erforderlichen Zonen für sie konfigurieren.",
|
||||
"noDefinedZones": "Für diese Kamera sind keine Zonen definiert.",
|
||||
"objectAlertsTips": "Alle {{alertsLabels}}-Objekte auf {{cameraName}} werden als Warnmeldungen angezeigt.",
|
||||
"zoneObjectAlertsTips": "Alle {{alertsLabels}}-Objekte, die in {{zone}} auf {{cameraName}} erkannt wurden, werden als Warnmeldungen angezeigt.",
|
||||
|
||||
@ -124,7 +124,7 @@
|
||||
"twoWayTalk": {
|
||||
"tips.documentation": "Leer la documentación ",
|
||||
"available": "La conversación bidireccional está disponible para esta transmisión",
|
||||
"unavailable": "La conversación bidireccional está disponible para esta transmisión",
|
||||
"unavailable": "La conversación bidireccional no está disponible para esta transmisión",
|
||||
"tips": "Tu dispositivo debe soportar la función y WebRTC debe estar configurado para la conversación bidireccional."
|
||||
},
|
||||
"lowBandwidth": {
|
||||
|
||||
@ -152,7 +152,12 @@
|
||||
"generateSuccess": "Génération des images d'exemple réussie",
|
||||
"allImagesRequired_one": "Veuillez classifier toutes les images. {{count}} image restante.",
|
||||
"allImagesRequired_many": "Veuillez classifier toutes les images. {{count}} images restantes.",
|
||||
"allImagesRequired_other": "Veuillez classifier toutes les images. {{count}} images restantes."
|
||||
"allImagesRequired_other": "Veuillez classifier toutes les images. {{count}} images restantes.",
|
||||
"modelCreated": "Modèle créé avec succès. Utilisez la vue Classifications récentes pour ajouter des images pour les états manquants, puis entraînez le modèle.",
|
||||
"missingStatesWarning": {
|
||||
"title": "Exemples d'états manquants",
|
||||
"description": "Vous n'avez pas sélectionné d'exemples pour tous les états. L'entraînement ne pourra débuter que lorsque chaque état disposera d'images. Continuez, puis utilisez la vue Classifications récentes pour classer les images manquantes et lancer l'entraînement."
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
|
||||
@ -56,5 +56,8 @@
|
||||
"clickToSeek": "Cliquez pour atteindre ce moment."
|
||||
},
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière"
|
||||
"zoomOut": "Zoom arrière",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Nécessite une revue",
|
||||
"securityConcern": "Problème de sécurité"
|
||||
}
|
||||
|
||||
@ -57,7 +57,8 @@
|
||||
"failed": "Misslyckades med att starta exporten: {{error}}",
|
||||
"endTimeMustAfterStartTime": "Sluttiden måste vara efter starttiden",
|
||||
"noVaildTimeSelected": "Inget giltigt tidsintervall valt"
|
||||
}
|
||||
},
|
||||
"view": "Visa"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Spara export",
|
||||
|
||||
@ -112,7 +112,7 @@
|
||||
"oven": "Ugn",
|
||||
"blender": "Blandare",
|
||||
"book": "Bok",
|
||||
"waste_bin": "Papperskorg",
|
||||
"waste_bin": "Soptunna",
|
||||
"license_plate": "Nummerplåt",
|
||||
"toothbrush": "Tandborste",
|
||||
"ups": "UPS",
|
||||
|
||||
@ -148,7 +148,8 @@
|
||||
},
|
||||
"generateSuccess": "Exempelbilder har genererats",
|
||||
"allImagesRequired_one": "Vänligen klassificera alla bilder. {{count}} bild återstår.",
|
||||
"allImagesRequired_other": "Vänligen klassificera alla bilder. {{count}} bilder återstår."
|
||||
"allImagesRequired_other": "Vänligen klassificera alla bilder. {{count}} bilder återstår.",
|
||||
"modelCreated": "Modellen har skapats. Använd vyn Senaste klassificeringar för att lägga till bilder för saknade tillstånd och träna sedan modellen."
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
|
||||
@ -55,5 +55,8 @@
|
||||
"clickToSeek": "Klicka för att söka till den här tiden"
|
||||
},
|
||||
"zoomIn": "Zooma in",
|
||||
"zoomOut": "Zooma ut"
|
||||
"zoomOut": "Zooma ut",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Behöver granskas",
|
||||
"securityConcern": "Säkerhetsproblem"
|
||||
}
|
||||
|
||||
@ -261,7 +261,8 @@
|
||||
"header": {
|
||||
"zones": "Zoner",
|
||||
"ratio": "Förhållandet",
|
||||
"area": "Område"
|
||||
"area": "Område",
|
||||
"score": "Resultat"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@ -64,7 +64,8 @@
|
||||
"failed": "导出失败:{{error}}",
|
||||
"endTimeMustAfterStartTime": "结束时间必须在开始时间之后",
|
||||
"noVaildTimeSelected": "未选择有效的时间范围"
|
||||
}
|
||||
},
|
||||
"view": "查看"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "保存导出",
|
||||
|
||||
@ -144,7 +144,12 @@
|
||||
"classifyFailed": "图片分类失败:{{error}}"
|
||||
},
|
||||
"generateSuccess": "样本图片生成成功",
|
||||
"allImagesRequired_other": "请对所有图片进行分类。还有 {{count}} 张图片需要分类。"
|
||||
"allImagesRequired_other": "请对所有图片进行分类。还有 {{count}} 张图片需要分类。",
|
||||
"modelCreated": "模型创建成功。请在“最近分类”页面为缺失的状态添加图片,然后训练模型。",
|
||||
"missingStatesWarning": {
|
||||
"title": "缺失状态示例",
|
||||
"description": "你尚未为所有状态选择示例。在所有状态都有图片数据之前,模型将不能训练。继续后,请使用“最近分类”视图为缺少图片的状态分类添加图片,然后再训练模型。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
|
||||
@ -56,5 +56,8 @@
|
||||
"clickToSeek": "点击从该时间进行寻找"
|
||||
},
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
"zoomOut": "缩小",
|
||||
"normalActivity": "正常",
|
||||
"needsReview": "需要核查",
|
||||
"securityConcern": "安全隐患"
|
||||
}
|
||||
|
||||
@ -97,14 +97,14 @@
|
||||
},
|
||||
"tips": {
|
||||
"mismatch_other": "检测到 {{count}} 个不可用的目标,并已包含在此核查项中。这些目标可能未达到警报或检测标准,或者已被清理/删除。",
|
||||
"hasMissingObjects": "如果希望 Frigate 保存 <em>{{objects}}</em> 标签的追踪目标,请调整您的配置。"
|
||||
"hasMissingObjects": "如果希望 Frigate 保存 <em>{{objects}}</em> 标签的追踪目标,请调整您的配置"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。",
|
||||
"updatedSublabel": "成功更新子标签。",
|
||||
"updatedLPR": "成功更新车牌。",
|
||||
"audioTranscription": "成功请求音频转录。"
|
||||
"audioTranscription": "成功请求音频转录。根据你运行 Frigate 的服务器速度,转录可能需要一些时间才能完成。"
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}",
|
||||
@ -259,7 +259,8 @@
|
||||
"header": {
|
||||
"zones": "区",
|
||||
"ratio": "占比",
|
||||
"area": "坐标区域"
|
||||
"area": "坐标区域",
|
||||
"score": "分数"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@ -174,7 +174,11 @@
|
||||
"noCameras": {
|
||||
"title": "未设置摄像头",
|
||||
"description": "准备开始连接摄像头至 Frigate 。",
|
||||
"buttonText": "添加摄像头"
|
||||
"buttonText": "添加摄像头",
|
||||
"restricted": {
|
||||
"title": "无可用摄像头",
|
||||
"description": "你没有权限查看此分组中的任何摄像头。"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"takeSnapshot": "下载即时快照",
|
||||
|
||||
@ -76,7 +76,12 @@
|
||||
}
|
||||
},
|
||||
"npuMemory": "NPU内存",
|
||||
"npuUsage": "NPU使用率"
|
||||
"npuUsage": "NPU使用率",
|
||||
"intelGpuWarning": {
|
||||
"title": "Intel GPU 处于警告状态",
|
||||
"message": "GPU 状态不可用",
|
||||
"description": "这是 Intel 的 GPU 状态报告工具(intel_gpu_top)的已知问题:该工具会失效并反复返回 GPU 使用率为 0%,即使在硬件加速和目标检测已在 (i)GPU 上正常运行的情况下也是如此,这并不是 Frigate 的 bug。你可以通过重启主机来临时修复该问题,并确认 GPU 正常工作。该问题并不会影响性能。"
|
||||
}
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "其他进程",
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button } from "../ui/button";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,7 +24,7 @@ export default function DebugCameraImage({
|
||||
}: DebugCameraImageProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [options, setOptions] = usePersistence<Options>(
|
||||
const [options, setOptions] = useUserPersistence<Options>(
|
||||
`${cameraConfig?.name}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isDesktop, isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
@ -112,7 +112,7 @@ export function AnimatedEventCard({
|
||||
|
||||
// image behavior
|
||||
|
||||
const [alertVideos, _, alertVideosLoaded] = usePersistence(
|
||||
const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
|
||||
"alertVideos",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPlus, LuX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
type ClassificationModelEditDialogProps = {
|
||||
@ -240,15 +240,61 @@ export default function ClassificationModelEditDialog({
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
// State model - update classes
|
||||
// Note: For state models, updating classes requires renaming categories
|
||||
// which is handled through the dataset API, not the config API
|
||||
// We'll need to implement this by calling the rename endpoint for each class
|
||||
// For now, we just show a message that this requires retraining
|
||||
const stateData = data as StateFormData;
|
||||
const newClasses = stateData.classes.filter(
|
||||
(c) => c.trim().length > 0,
|
||||
);
|
||||
const oldClasses = dataset?.categories
|
||||
? Object.keys(dataset.categories).filter((key) => key !== "none")
|
||||
: [];
|
||||
|
||||
toast.info(t("edit.stateClassesInfo"), {
|
||||
position: "top-center",
|
||||
});
|
||||
const renameMap = new Map<string, string>();
|
||||
const maxLength = Math.max(oldClasses.length, newClasses.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const oldClass = oldClasses[i];
|
||||
const newClass = newClasses[i];
|
||||
|
||||
if (oldClass && newClass && oldClass !== newClass) {
|
||||
renameMap.set(oldClass, newClass);
|
||||
}
|
||||
}
|
||||
|
||||
const renamePromises = Array.from(renameMap.entries()).map(
|
||||
async ([oldName, newName]) => {
|
||||
try {
|
||||
await axios.put(
|
||||
`/classification/${model.name}/dataset/${oldName}/rename`,
|
||||
{
|
||||
new_category: newName,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
throw new Error(
|
||||
`Failed to rename ${oldName} to ${newName}: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (renamePromises.length > 0) {
|
||||
await Promise.all(renamePromises);
|
||||
await mutate(`classification/${model.name}/dataset`);
|
||||
toast.success(t("toast.success.updatedModel"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.info(t("edit.stateClassesInfo"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
@ -256,8 +302,10 @@ export default function ClassificationModelEditDialog({
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
error.message ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
@ -268,7 +316,7 @@ export default function ClassificationModelEditDialog({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[isObjectModel, model, t, onSuccess, onClose],
|
||||
[isObjectModel, model, dataset, t, onSuccess, onClose],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
@ -57,7 +56,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
@ -79,6 +78,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
|
||||
type CameraGroupSelectorProps = {
|
||||
className?: string;
|
||||
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
[timeoutId],
|
||||
);
|
||||
|
||||
// groups
|
||||
// groups - use user-namespaced key for persistence to avoid cross-user conflicts
|
||||
|
||||
const [group, setGroup, , deleteGroup] = usePersistedOverlayState(
|
||||
const [group, setGroup, , deleteGroup] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
@ -276,7 +276,7 @@ function NewGroupDialog({
|
||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [, , , deleteGridLayout] = usePersistence(
|
||||
const [, , , deleteGridLayout] = useUserPersistence(
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { SaveSearchDialog } from "./SaveSearchDialog";
|
||||
import { DeleteSearchDialog } from "./DeleteSearchDialog";
|
||||
import {
|
||||
@ -128,9 +128,8 @@ export default function InputWithTags({
|
||||
|
||||
// TODO: search history from browser storage
|
||||
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
||||
SavedSearchQuery[]
|
||||
>("frigate-search-history");
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] =
|
||||
useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
|
||||
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
@ -68,6 +69,7 @@ type LiveContextMenuProps = {
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
children?: ReactNode;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
export default function LiveContextMenu({
|
||||
className,
|
||||
@ -88,6 +90,7 @@ export default function LiveContextMenu({
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
children,
|
||||
streamMetadata,
|
||||
}: LiveContextMenuProps) {
|
||||
const { t } = useTranslation("views/live");
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@ -558,6 +561,7 @@ export default function LiveContextMenu({
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { type DayButtonProps, TZDate } from "react-day-picker";
|
||||
import { LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
|
||||
}: ReviewActivityCalendarProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useTimezone(config);
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: TimezoneAwareCalendarProps) {
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
|
||||
@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
|
||||
|
||||
const [tallCamera, setTallCamera] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [muted, setMuted] = usePersistence("hlsPlayerMuted", true);
|
||||
const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true);
|
||||
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
|
||||
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [playbackRate, setPlaybackRate] = useOverlayState(
|
||||
"playbackRate",
|
||||
defaultPlaybackRate ?? 1,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
@ -72,7 +72,10 @@ function MSEPlayer({
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||
const [fallbackTimeout] = useUserPersistence<number>(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
type CameraStreamingDialogProps = {
|
||||
camera: string;
|
||||
groupStreamingSettings: GroupStreamingSettings;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
setGroupStreamingSettings: React.Dispatch<
|
||||
React.SetStateAction<GroupStreamingSettings>
|
||||
>;
|
||||
@ -48,6 +49,7 @@ type CameraStreamingDialogProps = {
|
||||
export function CameraStreamingDialog({
|
||||
camera,
|
||||
groupStreamingSettings,
|
||||
streamMetadata,
|
||||
setGroupStreamingSettings,
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
@ -76,12 +78,7 @@ export function CameraStreamingDialog({
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
|
||||
|
||||
const supportsAudioOutput = useMemo(() => {
|
||||
if (!cameraMetadata) {
|
||||
|
||||
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
@ -58,7 +58,7 @@ export default function DetailStream({
|
||||
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
|
||||
"detailStreamActiveExpanded",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
useContext,
|
||||
} from "react";
|
||||
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
|
||||
type StreamingSettingsContextType = {
|
||||
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
||||
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
|
||||
persistedGroupStreamingSettings,
|
||||
setPersistedGroupStreamingSettings,
|
||||
isPersistedStreamingSettingsLoaded,
|
||||
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (isPersistedStreamingSettingsLoaded) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// Get comma-separated list of restreamed stream names for SWR key
|
||||
const restreamedStreamsKey = useMemo(() => {
|
||||
if (!cameras || !config) return null;
|
||||
// Compute which streams need metadata (restreamed streams only)
|
||||
const restreamedStreamNames = useMemo(() => {
|
||||
if (!cameras || !config) return [];
|
||||
|
||||
const streamNames = new Set<string>();
|
||||
cameras.forEach((camera) => {
|
||||
@ -32,56 +32,13 @@ export default function useCameraLiveMode(
|
||||
}
|
||||
});
|
||||
|
||||
return streamNames.size > 0
|
||||
? Array.from(streamNames).sort().join(",")
|
||||
: null;
|
||||
return Array.from(streamNames);
|
||||
}, [cameras, config, activeStreams]);
|
||||
|
||||
const streamsFetcher = useCallback(async (key: string) => {
|
||||
const streamNames = key.split(",");
|
||||
|
||||
const metadataPromises = streamNames.map(async (streamName) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||
{
|
||||
priority: "low",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { streamName, data };
|
||||
}
|
||||
return { streamName, data: null };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch metadata for ${streamName}:`, error);
|
||||
return { streamName, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(metadataPromises);
|
||||
|
||||
const metadata: { [key: string]: LiveStreamMetadata } = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value.data) {
|
||||
metadata[result.value.streamName] = result.value.data;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: allStreamMetadata = {} } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(restreamedStreamsKey, streamsFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
// Fetch stream metadata with deferred loading (doesn't block initial render)
|
||||
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
|
||||
|
||||
// Compute live mode states
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
@ -122,10 +79,10 @@ export default function useCameraLiveMode(
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
|
||||
// check each stream for audio support
|
||||
// Check each stream for audio support
|
||||
if (isRestreamed) {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const metadata = allStreamMetadata?.[streamName];
|
||||
const metadata = streamMetadata[streamName];
|
||||
newSupportsAudioOutputStates[streamName] = {
|
||||
supportsAudio: metadata
|
||||
? metadata.producers.find(
|
||||
@ -150,7 +107,7 @@ export default function useCameraLiveMode(
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
setIsRestreamedStates(newIsRestreamedStates);
|
||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||
}, [cameras, config, windowVisible, allStreamMetadata]);
|
||||
}, [cameras, config, windowVisible, streamMetadata]);
|
||||
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
@ -180,5 +137,6 @@ export default function useCameraLiveMode(
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
90
web/src/hooks/use-deferred-stream-metadata.ts
Normal file
90
web/src/hooks/use-deferred-stream-metadata.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const DEFER_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Hook that fetches go2rtc stream metadata with deferred loading.
|
||||
*
|
||||
* Metadata fetching is delayed to prevent blocking initial page load
|
||||
* and camera image requests.
|
||||
*
|
||||
* @param streamNames - Array of stream names to fetch metadata for
|
||||
* @returns Object containing stream metadata keyed by stream name
|
||||
*/
|
||||
export default function useDeferredStreamMetadata(streamNames: string[]) {
|
||||
const [fetchEnabled, setFetchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setFetchEnabled(true);
|
||||
}, DEFER_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const swrKey = useMemo(() => {
|
||||
if (!fetchEnabled || streamNames.length === 0) return null;
|
||||
// Use spread to avoid mutating the original array
|
||||
return `deferred-streams:${[...streamNames].sort().join(",")}`;
|
||||
}, [fetchEnabled, streamNames]);
|
||||
|
||||
const fetcher = useCallback(async (key: string) => {
|
||||
// Extract stream names from key (remove prefix)
|
||||
const names = key.replace("deferred-streams:", "").split(",");
|
||||
|
||||
const promises = names.map(async (streamName) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||
{
|
||||
priority: "low",
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { streamName, data };
|
||||
}
|
||||
return { streamName, data: null };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch metadata for ${streamName}:`, error);
|
||||
}
|
||||
return { streamName, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
const metadata: { [key: string]: LiveStreamMetadata } = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value.data) {
|
||||
metadata[result.value.streamName] = result.value.data;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: metadata = {} } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(swrKey, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { usePersistence } from "./use-persistence";
|
||||
import { useUserPersistence } from "./use-user-persistence";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
export function useOverlayState<S>(
|
||||
key: string,
|
||||
@ -79,6 +81,60 @@ export function usePersistedOverlayState<S extends string>(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like usePersistedOverlayState, but namespaces the persistence key by username.
|
||||
* This ensures different users on the same browser don't share state.
|
||||
* Automatically migrates data from legacy (non-namespaced) keys on first use.
|
||||
*/
|
||||
export function useUserPersistedOverlayState<S extends string>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): [
|
||||
S | undefined,
|
||||
(value: S | undefined, replace?: boolean) => void,
|
||||
boolean,
|
||||
() => void,
|
||||
] {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value from URL state
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
() => location.state && location.state[key],
|
||||
[location, key],
|
||||
);
|
||||
|
||||
// saved value from previous session (user-namespaced with migration)
|
||||
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
|
||||
useUserPersistence<S>(key, overlayStateValue);
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
setPersistedValue(value);
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(location.pathname, { state: newLocationState, replace });
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, currentLocationState, navigate, setPersistedValue],
|
||||
);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setOverlayStateValue, false, deletePersistedValue];
|
||||
}
|
||||
|
||||
return [
|
||||
overlayStateValue ?? persistedValue ?? defaultValue,
|
||||
setOverlayStateValue,
|
||||
loaded,
|
||||
deletePersistedValue,
|
||||
];
|
||||
}
|
||||
|
||||
export function useHashState<S extends string>(): [
|
||||
S | undefined,
|
||||
(value: S) => void,
|
||||
|
||||
199
web/src/hooks/use-user-persistence.ts
Normal file
199
web/src/hooks/use-user-persistence.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback, useContext, useRef } from "react";
|
||||
import { get as getData, set as setData, del as delData } from "idb-keyval";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
type useUserPersistenceReturn<S> = [
|
||||
value: S | undefined,
|
||||
setValue: (value: S | undefined) => void,
|
||||
loaded: boolean,
|
||||
deleteValue: () => void,
|
||||
];
|
||||
|
||||
// Key used to track which keys have been migrated to prevent re-reading old keys
|
||||
const MIGRATED_KEYS_STORAGE_KEY = "frigate-migrated-user-keys";
|
||||
|
||||
/**
|
||||
* Compute the user-namespaced key for a given base key and username.
|
||||
*/
|
||||
export function getUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): string {
|
||||
const isAuthenticated = username && username !== "anonymous";
|
||||
return isAuthenticated ? `${key}:${username}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user-namespaced key from storage.
|
||||
* This is useful for clearing user-specific data from settings pages.
|
||||
*/
|
||||
export async function deleteUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const namespacedKey = getUserNamespacedKey(key, username);
|
||||
await delData(namespacedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of keys that have already been migrated for a specific user.
|
||||
*/
|
||||
async function getMigratedKeys(username: string): Promise<Set<string>> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
return new Set(allMigrated[username] || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a key as migrated for a specific user.
|
||||
*/
|
||||
async function markKeyAsMigrated(username: string, key: string): Promise<void> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
const userMigrated = new Set(allMigrated[username] || []);
|
||||
userMigrated.add(key);
|
||||
allMigrated[username] = Array.from(userMigrated);
|
||||
await setData(MIGRATED_KEYS_STORAGE_KEY, allMigrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for user-namespaced persistence with automatic migration from legacy keys.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Namespaces storage keys by username to isolate per-user preferences
|
||||
* 2. Automatically migrates data from legacy (non-namespaced) keys on first use
|
||||
* 3. Tracks migrated keys to prevent re-reading stale data after migration
|
||||
* 4. Waits for auth to load before returning values to prevent race conditions
|
||||
*
|
||||
* @param key - The base key name (will be namespaced with username)
|
||||
* @param defaultValue - Default value if no persisted value exists
|
||||
*/
|
||||
export function useUserPersistence<S>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): useUserPersistenceReturn<S> {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [value, setInternalValue] = useState<S | undefined>(defaultValue);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const migrationAttemptedRef = useRef(false);
|
||||
|
||||
// Compute the user-namespaced key
|
||||
const username = auth?.user?.username;
|
||||
const isAuthenticated =
|
||||
username && username !== "anonymous" && !auth.isLoading;
|
||||
const namespacedKey = isAuthenticated ? `${key}:${username}` : key;
|
||||
|
||||
// Track the key that was used when loading to prevent cross-key writes
|
||||
const loadedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue: S | undefined) => {
|
||||
// Only allow writes if we've loaded for this key
|
||||
// This prevents stale callbacks from writing to the wrong key
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
setInternalValue(newValue);
|
||||
async function update() {
|
||||
await setData(namespacedKey, newValue);
|
||||
}
|
||||
update();
|
||||
},
|
||||
[namespacedKey],
|
||||
);
|
||||
|
||||
const deleteValue = useCallback(async () => {
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
await delData(namespacedKey);
|
||||
setInternalValue(defaultValue);
|
||||
}, [namespacedKey, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load until auth is resolved
|
||||
if (auth.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state when key changes - this prevents stale writes
|
||||
loadedKeyRef.current = null;
|
||||
migrationAttemptedRef.current = false;
|
||||
setLoaded(false);
|
||||
|
||||
async function loadWithMigration() {
|
||||
// For authenticated users, check if we need to migrate from legacy key
|
||||
if (isAuthenticated && username && !migrationAttemptedRef.current) {
|
||||
migrationAttemptedRef.current = true;
|
||||
|
||||
const migratedKeys = await getMigratedKeys(username);
|
||||
|
||||
// Check if we already have data in the namespaced key
|
||||
const existingNamespacedValue = await getData<S>(namespacedKey);
|
||||
|
||||
if (typeof existingNamespacedValue !== "undefined") {
|
||||
// Already have namespaced data, use it
|
||||
setInternalValue(existingNamespacedValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this key has already been migrated (even if value was deleted)
|
||||
if (migratedKeys.has(key)) {
|
||||
// Already migrated, don't read from legacy key
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to migrate from legacy key
|
||||
const legacyValue = await getData<S>(key);
|
||||
if (typeof legacyValue !== "undefined") {
|
||||
// Migrate: copy to namespaced key, delete legacy key, mark as migrated
|
||||
await setData(namespacedKey, legacyValue);
|
||||
await delData(key);
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(legacyValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No legacy value, just mark as migrated so we don't check again
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For unauthenticated users or after migration check, just load normally
|
||||
const storedValue = await getData<S>(namespacedKey);
|
||||
if (typeof storedValue !== "undefined") {
|
||||
setInternalValue(storedValue);
|
||||
} else {
|
||||
setInternalValue(defaultValue);
|
||||
}
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
loadWithMigration();
|
||||
}, [
|
||||
auth.isLoading,
|
||||
isAuthenticated,
|
||||
username,
|
||||
key,
|
||||
namespacedKey,
|
||||
defaultValue,
|
||||
]);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setValue, false, deleteValue];
|
||||
}
|
||||
|
||||
return [value, setValue, loaded, deleteValue];
|
||||
}
|
||||
@ -3,7 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import {
|
||||
@ -42,7 +42,10 @@ export default function Events() {
|
||||
"alert",
|
||||
);
|
||||
|
||||
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
|
||||
const [showReviewed, setShowReviewed] = useUserPersistence(
|
||||
"showReviewed",
|
||||
false,
|
||||
);
|
||||
|
||||
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
||||
"recording",
|
||||
|
||||
@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
|
||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
|
||||
import { ModelState } from "@/types/ws";
|
||||
@ -47,7 +47,10 @@ export default function Explore() {
|
||||
|
||||
// grid
|
||||
|
||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||
const [columnCount, setColumnCount] = useUserPersistence(
|
||||
"exploreGridColumns",
|
||||
4,
|
||||
);
|
||||
const gridColumns = useMemo(() => {
|
||||
if (isMobileOnly) {
|
||||
return 2;
|
||||
@ -57,7 +60,7 @@ export default function Explore() {
|
||||
|
||||
// default layout
|
||||
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
|
||||
"exploreDefaultView",
|
||||
"summary",
|
||||
);
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
useHashState,
|
||||
usePersistedOverlayState,
|
||||
useSearchEffect,
|
||||
} from "@/hooks/use-overlay-state";
|
||||
import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
@ -24,7 +21,7 @@ function Live() {
|
||||
// selection
|
||||
|
||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState(
|
||||
const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
BirdseyeConfig,
|
||||
@ -24,6 +24,7 @@ import "react-resizable/css/styles.css";
|
||||
import {
|
||||
AudioState,
|
||||
LivePlayerMode,
|
||||
LiveStreamMetadata,
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
@ -39,7 +40,7 @@ import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -47,7 +48,6 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -65,6 +65,16 @@ type DraggableGridLayoutProps = {
|
||||
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
preferredLiveModes: { [key: string]: LivePlayerMode };
|
||||
setPreferredLiveModes: React.Dispatch<
|
||||
React.SetStateAction<{ [key: string]: LivePlayerMode }>
|
||||
>;
|
||||
resetPreferredLiveMode: (cameraName: string) => void;
|
||||
isRestreamedStates: { [key: string]: boolean };
|
||||
supportsAudioOutputStates: {
|
||||
[key: string]: { supportsAudio: boolean; cameraName: string };
|
||||
};
|
||||
streamMetadata: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
export default function DraggableGridLayout({
|
||||
cameras,
|
||||
@ -79,6 +89,12 @@ export default function DraggableGridLayout({
|
||||
setIsEditMode,
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
}: DraggableGridLayoutProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -86,8 +102,8 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
@ -98,42 +114,18 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
// grid layout
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
|
||||
Layout[]
|
||||
>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const [group] = usePersistedOverlayState("cameraGroup", "default" as string);
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!config) {
|
||||
@ -153,6 +145,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@ -176,104 +173,120 @@ export default function DraggableGridLayout({
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(() => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: Layout[] = currentGridLayout
|
||||
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout[] | undefined) => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
let aspectRatio;
|
||||
let col;
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
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
|
||||
}
|
||||
const optionsMap: Layout[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
let aspectRatio;
|
||||
let col;
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
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
|
||||
}
|
||||
|
||||
return optionsMap;
|
||||
}, [
|
||||
cameras,
|
||||
isGridLayoutLoaded,
|
||||
currentGridLayout,
|
||||
includeBirdseye,
|
||||
birdseyeConfig,
|
||||
]);
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded
|
||||
setCurrentGridLayout(gridLayout);
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
setGridLayout(generateLayout());
|
||||
const newLayout = generateLayout(undefined);
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isEditMode,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(cameras, currentCameras) ||
|
||||
includeBirdseye !== currentIncludeBirdseye
|
||||
@ -281,15 +294,17 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// set new grid layout in idb
|
||||
setGridLayout(generateLayout());
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
currentCameras,
|
||||
currentIncludeBirdseye,
|
||||
setCurrentGridLayout,
|
||||
currentGridLayout,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
@ -624,6 +639,7 @@ export default function DraggableGridLayout({
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
config={config}
|
||||
streamMetadata={streamMetadata}
|
||||
>
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
@ -838,6 +854,7 @@ type GridLiveContextMenuProps = {
|
||||
unmuteAll: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
|
||||
const GridLiveContextMenu = React.forwardRef<
|
||||
@ -868,6 +885,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
unmuteAll,
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
streamMetadata,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@ -899,6 +917,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
config={config}
|
||||
streamMetadata={streamMetadata}
|
||||
>
|
||||
{children}
|
||||
</LiveContextMenu>
|
||||
|
||||
@ -101,7 +101,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import axios from "axios";
|
||||
@ -146,7 +146,7 @@ export default function LiveCameraView({
|
||||
|
||||
// supported features
|
||||
|
||||
const [streamName, setStreamName] = usePersistence<string>(
|
||||
const [streamName, setStreamName] = useUserPersistence<string>(
|
||||
`${camera.name}-stream`,
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
@ -279,7 +279,7 @@ export default function LiveCameraView({
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
|
||||
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
||||
`${camera.name}-background-play`,
|
||||
false,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
CameraConfig,
|
||||
@ -78,7 +78,7 @@ export default function LiveDashboardView({
|
||||
|
||||
// layout
|
||||
|
||||
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
|
||||
const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
|
||||
"live-layout",
|
||||
isDesktop ? "grid" : "list",
|
||||
);
|
||||
@ -211,8 +211,8 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
@ -265,6 +265,7 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
@ -650,6 +651,12 @@ export default function LiveDashboardView({
|
||||
setIsEditMode={setIsEditMode}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
preferredLiveModes={preferredLiveModes}
|
||||
setPreferredLiveModes={setPreferredLiveModes}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
isRestreamedStates={isRestreamedStates}
|
||||
supportsAudioOutputStates={supportsAudioOutputStates}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -478,33 +478,32 @@ export default function AuthenticationView({
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.username !== "admin" &&
|
||||
user.username !== "viewer" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username);
|
||||
setSelectedUserRole(
|
||||
user.role || "viewer",
|
||||
);
|
||||
setShowRoleChange(true);
|
||||
}}
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username);
|
||||
setSelectedUserRole(
|
||||
user.role || "viewer",
|
||||
);
|
||||
setShowRoleChange(true);
|
||||
}}
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -104,7 +104,7 @@ export default function ObjectSettingsView({
|
||||
},
|
||||
];
|
||||
|
||||
const [options, setOptions, optionsLoaded] = usePersistence<Options>(
|
||||
const [options, setOptions, optionsLoaded] = useUserPersistence<Options>(
|
||||
`${selectedCamera}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useContext, useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { del as delData } from "idb-keyval";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
useUserPersistence,
|
||||
deleteUserNamespacedKey,
|
||||
} from "@/hooks/use-user-persistence";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import {
|
||||
Select,
|
||||
@ -19,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "../../components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
export default function UiSettingsView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
const clearStoredLayouts = useCallback(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||
await delData(`${value[0]}-draggable-layout`)
|
||||
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t("general.toast.success.clearStoredLayout", {
|
||||
@ -56,14 +62,14 @@ export default function UiSettingsView() {
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
const clearStreamingSettings = useCallback(async () => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await delData(`streaming-settings`)
|
||||
await deleteUserNamespacedKey(`streaming-settings`, username)
|
||||
.then(() => {
|
||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||
position: "top-center",
|
||||
@ -83,7 +89,7 @@ export default function UiSettingsView() {
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.general");
|
||||
@ -91,15 +97,15 @@ export default function UiSettingsView() {
|
||||
|
||||
// settings
|
||||
|
||||
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = usePersistence(
|
||||
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = useUserPersistence(
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user