Compare commits

..

21 Commits

Author SHA1 Message Date
Weblate (bot)
0a7b2c6a2b
Merge 6f851c3a06 into aa09132dfd 2025-12-01 17:05:52 +00:00
Hosted Weblate
6f851c3a06
Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (72 of 72 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (52 of 52 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (116 of 116 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (48 of 48 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: OverTheHillsAndFarAway <prosjektx@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nb_NO/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
2025-12-01 17:05:45 +00:00
Hosted Weblate
a8c7de6498
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (92 of 92 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (119 of 119 strings)

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/zh_Hans/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-system
2025-12-01 17:05:44 +00:00
Hosted Weblate
1356405a2e
Translated using Weblate (Slovak)
Currently translated at 100.0% (116 of 116 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (214 of 214 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (52 of 52 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jakub K <klacanjakub0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/sk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/sk/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2025-12-01 17:05:44 +00:00
Hosted Weblate
d22b6eab04
Translated using Weblate (Swedish)
Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Swedish)

Currently translated at 98.3% (117 of 119 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (55 of 55 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kristian Johansson <knmjohansson@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/sv/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:43 +00:00
Hosted Weblate
413cc9d6da
Translated using Weblate (French)
Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (French)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (French)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (French)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Apocoloquintose <bertrand.moreux@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/fr/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:42 +00:00
Hosted Weblate
11f9683e6a
Translated using Weblate (Spanish)
Currently translated at 90.2% (83 of 92 strings)

Co-authored-by: Hernán Rossetto <hmronline@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/es/
Translation: Frigate NVR/views-live
2025-12-01 17:05:41 +00:00
Hosted Weblate
7689b2647b
Translated using Weblate (Dutch)
Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marijn <168113859+Marijn0@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/nl/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:40 +00:00
Hosted Weblate
4e634c93f1
Translated using Weblate (Italian)
Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (55 of 55 strings)

Co-authored-by: Gringo <ita.translations@tiscali.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/it/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:39 +00:00
Hosted Weblate
f6e992c78a
Translated using Weblate (Polish)
Currently translated at 92.9% (119 of 128 strings)

Translated using Weblate (Polish)

Currently translated at 98.1% (210 of 214 strings)

Translated using Weblate (Polish)

Currently translated at 85.4% (546 of 639 strings)

Translated using Weblate (Polish)

Currently translated at 95.0% (38 of 40 strings)

Translated using Weblate (Polish)

Currently translated at 83.5% (107 of 128 strings)

Translated using Weblate (Polish)

Currently translated at 98.0% (51 of 52 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (92 of 92 strings)

Translated using Weblate (Polish)

Currently translated at 37.8% (45 of 119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: piesu <dogiiee@proton.me>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/pl/
Translation: Frigate NVR/common
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2025-12-01 17:05:39 +00:00
Hosted Weblate
c23f7a96ff
Translated using Weblate (Czech)
Currently translated at 63.2% (404 of 639 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Martin Brož <code@martin-broz.cz>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/cs/
Translation: Frigate NVR/views-settings
2025-12-01 17:05:38 +00:00
Hosted Weblate
7bcd195eb1
Translated using Weblate (Catalan)
Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (55 of 55 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/ca/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:37 +00:00
Hosted Weblate
d89f945400
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/uk/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:36 +00:00
Hosted Weblate
3c0ee60cf6
Translated using Weblate (Bulgarian)
Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Bulgarian)

Currently translated at 9.0% (5 of 55 strings)

Translated using Weblate (Bulgarian)

Currently translated at 0.7% (5 of 639 strings)

Translated using Weblate (Bulgarian)

Currently translated at 15.3% (2 of 13 strings)

Translated using Weblate (Bulgarian)

Currently translated at 31.5% (29 of 92 strings)

Translated using Weblate (Bulgarian)

Currently translated at 2.3% (3 of 128 strings)

Translated using Weblate (Bulgarian)

Currently translated at 20.0% (2 of 10 strings)

Translated using Weblate (Bulgarian)

Currently translated at 9.6% (5 of 52 strings)

Translated using Weblate (Bulgarian)

Currently translated at 22.5% (9 of 40 strings)

Translated using Weblate (Bulgarian)

Currently translated at 20.0% (2 of 10 strings)

Translated using Weblate (Bulgarian)

Currently translated at 6.2% (3 of 48 strings)

Translated using Weblate (Bulgarian)

Currently translated at 0.8% (1 of 119 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Bulgarian)

Currently translated at 2.3% (3 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Skye Fox <mardymcfly1985@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-icons/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-input/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-configeditor/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/bg/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/bg/
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-icons
Translation: Frigate NVR/components-input
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-configeditor
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2025-12-01 17:05:35 +00:00
Hosted Weblate
854de0f32e
Translated using Weblate (Romanian)
Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (128 of 128 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/ro/
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
2025-12-01 17:05:35 +00:00
Hosted Weblate
0362d8f20b
Translated using Weblate (German)
Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (German)

Currently translated at 100.0% (52 of 52 strings)

Translated using Weblate (German)

Currently translated at 99.8% (638 of 639 strings)

Translated using Weblate (German)

Currently translated at 99.8% (638 of 639 strings)

Translated using Weblate (German)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (German)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (German)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (German)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (German)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (German)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (German)

Currently translated at 100.0% (214 of 214 strings)

Translated using Weblate (German)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (German)

Currently translated at 100.0% (92 of 92 strings)

Translated using Weblate (German)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (German)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (German)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (German)

Currently translated at 99.5% (213 of 214 strings)

Translated using Weblate (German)

Currently translated at 99.5% (213 of 214 strings)

Translated using Weblate (German)

Currently translated at 83.5% (534 of 639 strings)

Translated using Weblate (German)

Currently translated at 93.8% (470 of 501 strings)

Translated using Weblate (German)

Currently translated at 98.9% (91 of 92 strings)

Translated using Weblate (German)

Currently translated at 100.0% (52 of 52 strings)

Translated using Weblate (German)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (German)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (German)

Currently translated at 100.0% (128 of 128 strings)

Translated using Weblate (German)

Currently translated at 100.0% (116 of 116 strings)

Translated using Weblate (German)

Currently translated at 100.0% (116 of 116 strings)

Translated using Weblate (German)

Currently translated at 34.4% (40 of 116 strings)

Translated using Weblate (German)

Currently translated at 94.8% (37 of 39 strings)

Translated using Weblate (German)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (German)

Currently translated at 78.0% (499 of 639 strings)

Translated using Weblate (German)

Currently translated at 98.4% (126 of 128 strings)

Translated using Weblate (German)

Currently translated at 29.3% (34 of 116 strings)

Translated using Weblate (German)

Currently translated at 96.0% (123 of 128 strings)

Translated using Weblate (German)

Currently translated at 78.0% (499 of 639 strings)

Co-authored-by: Fuxle <moritz.hofmann2005@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sebastian Sie <sebastian.neuplanitz@googlemail.com>
Co-authored-by: jmtatsch <julian@tatsch.it>
Co-authored-by: mvdberge <micha.vordemberge@christmann.info>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/de/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2025-12-01 17:05:34 +00:00
Hosted Weblate
50da6d8405
Translated using Weblate (Portuguese (Brazil))
Currently translated at 29.3% (34 of 116 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Machado <machado.jm4@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pt_BR/
Translation: Frigate NVR/views-classificationmodel
2025-12-01 17:05:33 +00:00
Hosted Weblate
3f1e3ec6f3
Translated using Weblate (Turkish)
Currently translated at 98.5% (211 of 214 strings)

Translated using Weblate (Turkish)

Currently translated at 66.3% (77 of 116 strings)

Translated using Weblate (Turkish)

Currently translated at 63.7% (74 of 116 strings)

Translated using Weblate (Turkish)

Currently translated at 97.6% (209 of 214 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (55 of 55 strings)

Translated using Weblate (Turkish)

Currently translated at 94.5% (121 of 128 strings)

Translated using Weblate (Turkish)

Currently translated at 93.7% (120 of 128 strings)

Translated using Weblate (Turkish)

Currently translated at 94.5% (87 of 92 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (Turkish)

Currently translated at 58.9% (377 of 639 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (52 of 52 strings)

Co-authored-by: Emircanos <emircan368@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/tr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/tr/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2025-12-01 17:05:32 +00:00
Nicolas Mowen
aa09132dfd
Update ROCm to 7.1.1 (#21113)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Update ROCm to 7.1.1

* testing for build

* Fix

* remove debug
2025-12-01 08:07:35 -07:00
Josh Hawkins
24766ce427
Use user-namespaced keys for idb persistence (#21110)
* add new hooks

* use new hooks for user based keys

* fix layout race condition
2025-12-01 07:59:54 -06:00
Nicolas Mowen
97b29d177a
Miscellaneous Fixes (#21072)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Implement renaming in model editing dialog

* add transcription faq

* remove incorrect constraint for viewer as username

should be able to change anyone's role other than admin

* Don't save redundant state changes

* prevent crash when a camera doesn't support onvif imaging service required for focus support

* Fine tune behavior

* Stop redundant go2rtc stream metadata requests and defer audio information to allow bandwidth for image requests

* Improve cleanup logic for capture process

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-30 06:54:42 -06:00
52 changed files with 882 additions and 381 deletions

View File

@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
PYTHON_VERSION: 3.9 PYTHON_VERSION: 3.11
jobs: jobs:
amd64_build: amd64_build:

View File

@ -15,7 +15,7 @@ ARG AMDGPU
RUN apt update -qq && \ RUN apt update -qq && \
apt install -y wget gpg && \ 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 install -y ./rocm.deb && \
apt update && \ apt update && \
apt install -qq -y rocm apt install -qq -y rocm

View File

@ -2,7 +2,7 @@ variable "AMDGPU" {
default = "gfx900" default = "gfx900"
} }
variable "ROCM" { variable "ROCM" {
default = "7.1.0" default = "7.1.1"
} }
variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE_GFX_VERSION" {
default = "" default = ""

View File

@ -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. 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. Thats 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 thats 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?
Theres 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. Thats why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event.

View File

@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
if self.inference_speed: if self.inference_speed:
self.inference_speed.update(duration) 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: def verify_state_change(self, camera: str, detected_state: str) -> str | None:
""" """
Verify state change requires 3 consecutive identical states before publishing. Verify state change requires 3 consecutive identical states before publishing.
@ -212,14 +248,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
return return
if self.interpreter is None: if self.interpreter is None:
write_classification_attempt( # When interpreter is None, always save (score is 0.0, which is < 1.0)
self.train_dir, if self._should_save_image(camera, "unknown", 0.0):
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), write_classification_attempt(
"none-none", self.train_dir,
now, cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"unknown", "none-none",
0.0, now,
) "unknown",
0.0,
)
return return
input = np.expand_dims(resized_frame, axis=0) input = np.expand_dims(resized_frame, axis=0)
@ -236,14 +274,17 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
score = round(probs[best_id], 2) score = round(probs[best_id], 2)
self.__update_metrics(datetime.datetime.now().timestamp() - now) self.__update_metrics(datetime.datetime.now().timestamp() - now)
write_classification_attempt( detected_state = self.labelmap[best_id]
self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), if self._should_save_image(camera, detected_state, score):
"none-none", write_classification_attempt(
now, self.train_dir,
self.labelmap[best_id], cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
score, "none-none",
) now,
detected_state,
score,
)
if score < self.model_config.threshold: if score < self.model_config.threshold:
logger.debug( logger.debug(
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
) )
return return
detected_state = self.labelmap[best_id]
verified_state = self.verify_state_change(camera, detected_state) verified_state = self.verify_state_change(camera, detected_state)
if verified_state is not None: if verified_state is not None:

View File

@ -190,7 +190,11 @@ class OnvifController:
ptz: ONVIFService = await onvif.create_ptz_service() ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz 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 self.cams[camera_name]["imaging"] = imaging
try: try:
video_sources = await media.GetVideoSources() video_sources = await media.GetVideoSources()
@ -381,7 +385,10 @@ class OnvifController:
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}" 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: try:
imaging_capabilities = await imaging.GetImagingSettings( imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]} {"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
@ -421,6 +428,7 @@ class OnvifController:
if ( if (
"focus" in self.cams[camera_name]["features"] "focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"] and self.cams[camera_name]["video_source_token"]
and self.cams[camera_name]["imaging"] is not None
): ):
try: try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop") stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
@ -648,6 +656,7 @@ class OnvifController:
if ( if (
"focus" not in self.cams[camera_name]["features"] "focus" not in self.cams[camera_name]["features"]
or not self.cams[camera_name]["video_source_token"] 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.") logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return return

View File

@ -124,45 +124,50 @@ def capture_frames(
config_subscriber.check_for_updates() config_subscriber.check_for_updates()
return config.enabled return config.enabled
while not stop_event.is_set(): try:
if not get_enabled_state(): while not stop_event.is_set():
logger.debug(f"Stopping capture thread for disabled {config.name}") if not get_enabled_state():
break logger.debug(f"Stopping capture thread for disabled {config.name}")
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 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( 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 frame_rate.update()
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 # 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): class CameraWatchdog(threading.Thread):
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
else: else:
self.ffmpeg_detect_process.wait() 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( self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit." "The following ffmpeg logs include the last 100 lines prior to exit."
) )

View File

@ -8,7 +8,7 @@
"masksAndZones": "Editor masky a zón - Frigate", "masksAndZones": "Editor masky a zón - Frigate",
"motionTuner": "Ladění detekce pohybu - Frigate", "motionTuner": "Ladění detekce pohybu - Frigate",
"object": "Ladění - Frigate", "object": "Ladění - Frigate",
"general": "Obecné nastavení - Frigate", "general": "Nastavení rozhraní- Frigate",
"frigatePlus": "Frigate+ nastavení - Frigate", "frigatePlus": "Frigate+ nastavení - Frigate",
"enrichments": "Nastavení obohacení - Frigate", "enrichments": "Nastavení obohacení - Frigate",
"cameraManagement": "Správa kamer - Frigate", "cameraManagement": "Správa kamer - Frigate",

View File

@ -481,7 +481,7 @@
"clickety_clack": "Klappergeräuschen", "clickety_clack": "Klappergeräuschen",
"rumble": "Grollen", "rumble": "Grollen",
"plop": "plumpsen", "plop": "plumpsen",
"hum": "brummen", "hum": "Brummen",
"zing": "Schwung", "zing": "Schwung",
"boing": "ferderndes Geräusch", "boing": "ferderndes Geräusch",
"crunch": "knirschendes", "crunch": "knirschendes",

View File

@ -11,6 +11,6 @@
}, },
"user": "Benutzername", "user": "Benutzername",
"password": "Kennwort", "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."
} }
} }

View File

@ -1,10 +1,10 @@
{ {
"documentTitle": "Klassifikation Modelle", "documentTitle": "Klassifikationsmodelle",
"details": { "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": { "button": {
"deleteClassificationAttempts": "Lösche Klassifizierungsbilder", "deleteClassificationAttempts": "Lösche klassifizierte Bilder",
"renameCategory": "Klasse umbenennen", "renameCategory": "Klasse umbenennen",
"deleteCategory": "Klasse löschen", "deleteCategory": "Klasse löschen",
"deleteImages": "Bilder löschen", "deleteImages": "Bilder löschen",
@ -14,18 +14,18 @@
"editModel": "Modell bearbeiten" "editModel": "Modell bearbeiten"
}, },
"tooltip": { "tooltip": {
"trainingInProgress": "Modell werden trainiert", "trainingInProgress": "Modell wird gerade trainiert",
"noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.", "noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.",
"noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.", "noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.",
"modelNotReady": "Modell ist nicht bereit zum Training" "modelNotReady": "Modell ist nicht bereit zum Training"
}, },
"toast": { "toast": {
"success": { "success": {
"deletedCategory": "Gelöschte Klasse", "deletedCategory": "Klasse gelöscht",
"deletedImage": "Bilder 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", "deletedModel_other": "{{count}} Modelle erfolgreich gelöscht",
"categorizedImage": "Bild erfolgreich klassifiziert", "categorizedImage": "Erfolgreich klassifizierte Bilder",
"trainedModel": "Modell erfolgreich trainiert.", "trainedModel": "Modell erfolgreich trainiert.",
"trainingModel": "Modelltraining erfolgreich gestartet.", "trainingModel": "Modelltraining erfolgreich gestartet.",
"updatedModel": "Modellkonfiguration erfolgreich aktualisiert", "updatedModel": "Modellkonfiguration erfolgreich aktualisiert",
@ -33,7 +33,7 @@
}, },
"error": { "error": {
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}", "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}}", "deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}",
"trainingFailedToStart": "Modelltraining konnte nicht gestartet werden: {{errorMessage}}", "trainingFailedToStart": "Modelltraining konnte nicht gestartet werden: {{errorMessage}}",
"updateModelFailed": "Aktualisierung des Modells fehlgeschlagen: {{errorMessage}}", "updateModelFailed": "Aktualisierung des Modells fehlgeschlagen: {{errorMessage}}",
@ -152,7 +152,7 @@
"step3": { "step3": {
"selectImagesPrompt": "Wählen sie alle Bilder mit: {{className}}", "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.", "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.", "allImagesRequired_other": "Bitte klassifizieren Sie alle Bilder. {{count}} Bilder verbleiben.",
"generating": { "generating": {
"title": "Beispielbilder generieren", "title": "Beispielbilder generieren",

View File

@ -44,7 +44,7 @@
"deleteFace": "Lösche Gesicht" "deleteFace": "Lösche Gesicht"
}, },
"train": { "train": {
"title": "Aktuelle Erkennungen", "title": "Kürzliche Erkennungen",
"aria": "Wähle aktuelle Erkennungen", "aria": "Wähle aktuelle Erkennungen",
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung" "empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung"
}, },

View File

@ -3,16 +3,16 @@
"default": "Einstellungen - Frigate", "default": "Einstellungen - Frigate",
"authentication": "Authentifizierungseinstellungen Frigate", "authentication": "Authentifizierungseinstellungen Frigate",
"camera": "Kameraeinstellungen - Frigate", "camera": "Kameraeinstellungen - Frigate",
"masksAndZones": "Masken- und Zonen-Editor Frigate", "masksAndZones": "Masken- und Zoneneditor Frigate",
"object": "Debug - Frigate", "object": "Debug - Frigate",
"general": "UI Einstellungen Frigate", "general": "UI Einstellungen Frigate",
"frigatePlus": "Frigate+ Einstellungen Frigate", "frigatePlus": "Frigate+ Einstellungen Frigate",
"classification": "Klassifizierungseinstellungen Frigate", "classification": "Klassifizierungseinstellungen Frigate",
"motionTuner": "Bewegungserkennungs-Optimierer Frigate", "motionTuner": "Bewegungserkennungs-Optimierer Frigate",
"notifications": "Benachrichtigungs-Einstellungen", "notifications": "Benachrichtigungseinstellungen",
"enrichments": "Erweiterte Statistiken - Frigate", "enrichments": "Erweiterte Statistiken - Frigate",
"cameraManagement": "Kameras verwalten - Frigate", "cameraManagement": "Kameras verwalten - Frigate",
"cameraReview": "Kamera Einstellungen prüfen - Frigate" "cameraReview": "Kameraeinstellungen prüfen - Frigate"
}, },
"menu": { "menu": {
"ui": "Benutzeroberfläche", "ui": "Benutzeroberfläche",
@ -695,13 +695,13 @@
"semanticSearch": { "semanticSearch": {
"reindexNow": { "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.", "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.", "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", "confirmButton": "Neuindizierung",
"success": "Die Neuindizierung wurde erfolgreich gestartet.", "success": "Die Neuindizierung wurde erfolgreich gestartet.",
"alreadyInProgress": "Die Neuindizierung ist bereits im Gange.", "alreadyInProgress": "Die Neuindizierung ist bereits im Gange.",
"error": "Neuindizierung konnte nicht gestartet werden: {{errorMessage}}" "error": "Die Neuindizierung konnte nicht gestartet werden: {{errorMessage}}"
}, },
"modelSize": { "modelSize": {
"small": { "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.", "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", "readTheDocumentation": "Lies die Dokumentation",
"modelSize": { "modelSize": {
"label": "Modell Größe", "label": "Modellgröße",
"desc": "Die Größe des für die Gesichtserkennung verwendeten Modells.", "desc": "Die Größe des für die Gesichtserkennung verwendeten Modells.",
"small": { "small": {
"title": "klein", "title": "klein",
@ -960,7 +960,7 @@
}, },
"step1": { "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.", "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", "cameraNamePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
"host": "Host/IP Adresse", "host": "Host/IP Adresse",
"port": "Port", "port": "Port",
@ -969,8 +969,8 @@
"password": "Passwort", "password": "Passwort",
"passwordPlaceholder": "Optional", "passwordPlaceholder": "Optional",
"selectTransport": "Transport-Protokoll auswählen", "selectTransport": "Transport-Protokoll auswählen",
"cameraBrand": "Kamera-Hersteller", "cameraBrand": "Kamerahersteller",
"selectBrand": "Wähle die Kamera-Hersteller für die URL-Vorlage aus", "selectBrand": "Wähle die Kamerahersteller für die URL-Vorlage aus",
"customUrl": "Benutzerdefinierte Stream-URL", "customUrl": "Benutzerdefinierte Stream-URL",
"brandInformation": "Hersteller Information", "brandInformation": "Hersteller Information",
"brandUrlFormat": "Für Kameras mit RTSP URL nutze folgendes Format: {{exampleUrl}}", "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." "noSnapshot": "Es kann kein Snapshot aus dem konfigurierten Stream abgerufen werden."
}, },
"errors": { "errors": {
"brandOrCustomUrlRequired": "Wählen Sie entweder einen Kamera-Hersteller mit Host/IP aus oder wählen Sie „Andere“ mit einer benutzerdefinierten URL", "brandOrCustomUrlRequired": "Wählen Sie entweder einen Kamerahersteller mit Host/IP aus oder wählen Sie „Andere“ mit einer benutzerdefinierten URL",
"nameRequired": "Kamera-Name benötigt", "nameRequired": "Der Kameraname wird benötigt",
"nameLength": "Kamera-Name darf höchsten 64 Zeichen lang sein", "nameLength": "Der Kameraname darf höchsten 64 Zeichen lang sein",
"invalidCharacters": "Kamera-Name enthält ungültige Zeichen", "invalidCharacters": "Der Kameraname enthält ungültige Zeichen",
"nameExists": "Kamera-Name existiert bereits", "nameExists": "Der Kameraname existiert bereits",
"brands": { "brands": {
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten." "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", "connectionSettings": "Verbindungseinstellungen",
"detectionMethod": "Stream Erkennungsmethode", "detectionMethod": "Stream Erkennungsmethode",
"onvifPort": "ONVIF Port", "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“.", "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.", "onvifPortDescription": "Bei Kameras, die ONVIF unterstützen, ist dies in der Regel 80 oder 8080.",
"useDigestAuth": "Digest-Authentifizierung verwenden", "useDigestAuth": "Digest-Authentifizierung verwenden",
@ -1027,7 +1027,7 @@
}, },
"testStream": "Verbindung testen", "testStream": "Verbindung testen",
"testSuccess": "Verbindung erfolgreich getestet!", "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", "testFailedTitle": "Test fehlgeschlagen",
"connected": "Verbunden", "connected": "Verbunden",
"notConnected": "Nicht verbunden", "notConnected": "Nicht verbunden",
@ -1051,11 +1051,11 @@
"probingMetadata": "Metadaten der Kamera werden überprüft...", "probingMetadata": "Metadaten der Kamera werden überprüft...",
"fetchingSnapshot": "Kamera-Schnappschuss wird abgerufen..." "fetchingSnapshot": "Kamera-Schnappschuss wird abgerufen..."
}, },
"probeFailed": "Fehler beim Testen der Kamera: {{error}}", "probeFailed": "Fehler beim Untersuchen der Kamera: {{error}}",
"probingDevice": "Prüfung Gerät...", "probingDevice": "Untersuche Gerät...",
"probeSuccessful": "Sonde erfolgreich", "probeSuccessful": "Erkennung erfolgreich",
"probeError": "Sondenfehler", "probeError": "Erkennungsfehler",
"probeNoSuccess": "Sonde erfolglos", "probeNoSuccess": "Erkennung fehlgeschlagen",
"deviceInfo": "Geräteinformationen", "deviceInfo": "Geräteinformationen",
"manufacturer": "Hersteller", "manufacturer": "Hersteller",
"model": "Modell", "model": "Modell",
@ -1065,12 +1065,12 @@
"autotrackingSupport": "Unterstützung für Autoverfolgung", "autotrackingSupport": "Unterstützung für Autoverfolgung",
"presets": "Voreinstellung", "presets": "Voreinstellung",
"rtspCandidates": "RTSP Kandidaten", "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.", "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}}", "candidateStreamTitle": "Kandidate {{number}}",
"useCandidate": "Verwenden", "useCandidate": "Verwenden",
"uriCopy": "Kopieren", "uriCopy": "Kopieren",
"uriCopied": "ULR in Zwischenablage kopiert", "uriCopied": "URI in die Zwischenablage kopiert",
"testConnection": "Test Verbindung", "testConnection": "Test Verbindung",
"toggleUriView": "Klicken Sie hier, um die vollständige URI zu sehen", "toggleUriView": "Klicken Sie hier, um die vollständige URI zu sehen",
"errors": { "errors": {
@ -1117,7 +1117,7 @@
} }
}, },
"streamsTitle": "Kamera Stream", "streamsTitle": "Kamera Stream",
"addStream": "Hizufügen Stream", "addStream": "Stream hinzufügen",
"addAnotherStream": "weiteren Stream hinzufügen", "addAnotherStream": "weiteren Stream hinzufügen",
"streamUrl": "Stream URL", "streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://benutzername:passwort@host:port/path", "streamUrlPlaceholder": "rtsp://benutzername:passwort@host:port/path",
@ -1130,7 +1130,7 @@
"quality": "Qualität", "quality": "Qualität",
"selectQuality": "Wähle Qualität", "selectQuality": "Wähle Qualität",
"roleLabels": { "roleLabels": {
"detect": "Objekt Erkennung", "detect": "Objekterkennung",
"record": "Aufnahme", "record": "Aufnahme",
"audio": "Ton" "audio": "Ton"
}, },
@ -1139,7 +1139,7 @@
"testFailed": "Verbindungstest fehlgeschlagen", "testFailed": "Verbindungstest fehlgeschlagen",
"testFailedTitle": "Test fehlgeschlagen", "testFailedTitle": "Test fehlgeschlagen",
"connected": "Verbunden", "connected": "Verbunden",
"notConnected": "nicht Verbunden", "notConnected": "nicht verbunden",
"featuresTitle": "Funktionen", "featuresTitle": "Funktionen",
"go2rtc": "Verbindungen zur Kamera reduzieren", "go2rtc": "Verbindungen zur Kamera reduzieren",
"detectRoleWarning": "Mindestens ein Stream muss die Rolle „detect“ haben, um fortfahren zu können.", "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.", "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", "none": "keiner",
"error": "Fehler", "error": "Fehler",
"streamValidated": "Steeam {{number}} Erfolgreich validiert", "streamValidated": "Steam {{number}} erfolgreich validiert",
"streamValidationFailed": "Stream {{number}} Validierung fehlgeschlagen", "streamValidationFailed": "Stream {{number}} Validierung fehlgeschlagen",
"saveAndApply": "Neue Kamera speichern", "saveAndApply": "Neue Kamera speichern",
"saveError": "Ungültige Konfiguration. Bitte überprüfen Sie Ihre Einstellungen.", "saveError": "Ungültige Konfiguration. Bitte überprüfen Sie Ihre Einstellungen.",
@ -1216,9 +1216,9 @@
"add": "Kamera hinzufügen", "add": "Kamera hinzufügen",
"edit": "Kamera bearbeiten", "edit": "Kamera bearbeiten",
"description": "Konfiguriere die Kameraeinstellungen, einschließlich Streams und Rollen.", "description": "Konfiguriere die Kameraeinstellungen, einschließlich Streams und Rollen.",
"name": "Kamera-Name", "name": "Kameraname",
"nameRequired": "Kamera-Name benötigt", "nameRequired": "Kameraname benötigt",
"nameLength": "Kamera-Name darf maximal 64 Zeichen lang sein.", "nameLength": "Kameraname darf maximal 64 Zeichen lang sein.",
"namePlaceholder": "z.B. vordere_tür oder Hof Übersicht", "namePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
"enabled": "Aktiviert", "enabled": "Aktiviert",
"ffmpeg": { "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." "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": { "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. ", "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 ", "alerts": "Warnungen ",
"detections": "Erkennungen " "detections": "Erkennungen "
}, },
"reviewClassification": { "reviewClassification": {
"title": "Bewertungsklassifizierung", "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.", "noDefinedZones": "Für diese Kamera sind keine Zonen definiert.",
"objectAlertsTips": "Alle {{alertsLabels}}-Objekte auf {{cameraName}} werden als Warnmeldungen angezeigt.", "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.", "zoneObjectAlertsTips": "Alle {{alertsLabels}}-Objekte, die in {{zone}} auf {{cameraName}} erkannt wurden, werden als Warnmeldungen angezeigt.",

View File

@ -124,7 +124,7 @@
"twoWayTalk": { "twoWayTalk": {
"tips.documentation": "Leer la documentación ", "tips.documentation": "Leer la documentación ",
"available": "La conversación bidireccional está disponible para esta transmisió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." "tips": "Tu dispositivo debe soportar la función y WebRTC debe estar configurado para la conversación bidireccional."
}, },
"lowBandwidth": { "lowBandwidth": {

View File

@ -152,7 +152,12 @@
"generateSuccess": "Génération des images d'exemple réussie", "generateSuccess": "Génération des images d'exemple réussie",
"allImagesRequired_one": "Veuillez classifier toutes les images. {{count}} image restante.", "allImagesRequired_one": "Veuillez classifier toutes les images. {{count}} image restante.",
"allImagesRequired_many": "Veuillez classifier toutes les images. {{count}} images restantes.", "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": { "deleteModel": {

View File

@ -56,5 +56,8 @@
"clickToSeek": "Cliquez pour atteindre ce moment." "clickToSeek": "Cliquez pour atteindre ce moment."
}, },
"zoomIn": "Zoom avant", "zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière" "zoomOut": "Zoom arrière",
"normalActivity": "Normal",
"needsReview": "Nécessite une revue",
"securityConcern": "Problème de sécurité"
} }

View File

@ -57,7 +57,8 @@
"failed": "Misslyckades med att starta exporten: {{error}}", "failed": "Misslyckades med att starta exporten: {{error}}",
"endTimeMustAfterStartTime": "Sluttiden måste vara efter starttiden", "endTimeMustAfterStartTime": "Sluttiden måste vara efter starttiden",
"noVaildTimeSelected": "Inget giltigt tidsintervall valt" "noVaildTimeSelected": "Inget giltigt tidsintervall valt"
} },
"view": "Visa"
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Spara export", "saveExport": "Spara export",

View File

@ -112,7 +112,7 @@
"oven": "Ugn", "oven": "Ugn",
"blender": "Blandare", "blender": "Blandare",
"book": "Bok", "book": "Bok",
"waste_bin": "Papperskorg", "waste_bin": "Soptunna",
"license_plate": "Nummerplåt", "license_plate": "Nummerplåt",
"toothbrush": "Tandborste", "toothbrush": "Tandborste",
"ups": "UPS", "ups": "UPS",

View File

@ -148,7 +148,8 @@
}, },
"generateSuccess": "Exempelbilder har genererats", "generateSuccess": "Exempelbilder har genererats",
"allImagesRequired_one": "Vänligen klassificera alla bilder. {{count}} bild återstår.", "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": { "deleteModel": {

View File

@ -55,5 +55,8 @@
"clickToSeek": "Klicka för att söka till den här tiden" "clickToSeek": "Klicka för att söka till den här tiden"
}, },
"zoomIn": "Zooma in", "zoomIn": "Zooma in",
"zoomOut": "Zooma ut" "zoomOut": "Zooma ut",
"normalActivity": "Normal",
"needsReview": "Behöver granskas",
"securityConcern": "Säkerhetsproblem"
} }

View File

@ -261,7 +261,8 @@
"header": { "header": {
"zones": "Zoner", "zones": "Zoner",
"ratio": "Förhållandet", "ratio": "Förhållandet",
"area": "Område" "area": "Område",
"score": "Resultat"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -64,7 +64,8 @@
"failed": "导出失败:{{error}}", "failed": "导出失败:{{error}}",
"endTimeMustAfterStartTime": "结束时间必须在开始时间之后", "endTimeMustAfterStartTime": "结束时间必须在开始时间之后",
"noVaildTimeSelected": "未选择有效的时间范围" "noVaildTimeSelected": "未选择有效的时间范围"
} },
"view": "查看"
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "保存导出", "saveExport": "保存导出",

View File

@ -144,7 +144,12 @@
"classifyFailed": "图片分类失败:{{error}}" "classifyFailed": "图片分类失败:{{error}}"
}, },
"generateSuccess": "样本图片生成成功", "generateSuccess": "样本图片生成成功",
"allImagesRequired_other": "请对所有图片进行分类。还有 {{count}} 张图片需要分类。" "allImagesRequired_other": "请对所有图片进行分类。还有 {{count}} 张图片需要分类。",
"modelCreated": "模型创建成功。请在“最近分类”页面为缺失的状态添加图片,然后训练模型。",
"missingStatesWarning": {
"title": "缺失状态示例",
"description": "你尚未为所有状态选择示例。在所有状态都有图片数据之前,模型将不能训练。继续后,请使用“最近分类”视图为缺少图片的状态分类添加图片,然后再训练模型。"
}
} }
}, },
"deleteModel": { "deleteModel": {

View File

@ -56,5 +56,8 @@
"clickToSeek": "点击从该时间进行寻找" "clickToSeek": "点击从该时间进行寻找"
}, },
"zoomIn": "放大", "zoomIn": "放大",
"zoomOut": "缩小" "zoomOut": "缩小",
"normalActivity": "正常",
"needsReview": "需要核查",
"securityConcern": "安全隐患"
} }

View File

@ -97,14 +97,14 @@
}, },
"tips": { "tips": {
"mismatch_other": "检测到 {{count}} 个不可用的目标,并已包含在此核查项中。这些目标可能未达到警报或检测标准,或者已被清理/删除。", "mismatch_other": "检测到 {{count}} 个不可用的目标,并已包含在此核查项中。这些目标可能未达到警报或检测标准,或者已被清理/删除。",
"hasMissingObjects": "如果希望 Frigate 保存 <em>{{objects}}</em> 标签的追踪目标,请调整您的配置" "hasMissingObjects": "如果希望 Frigate 保存 <em>{{objects}}</em> 标签的追踪目标,请调整您的配置"
}, },
"toast": { "toast": {
"success": { "success": {
"regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。", "regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。",
"updatedSublabel": "成功更新子标签。", "updatedSublabel": "成功更新子标签。",
"updatedLPR": "成功更新车牌。", "updatedLPR": "成功更新车牌。",
"audioTranscription": "成功请求音频转录。" "audioTranscription": "成功请求音频转录。根据你运行 Frigate 的服务器速度,转录可能需要一些时间才能完成。"
}, },
"error": { "error": {
"regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}", "regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}",
@ -259,7 +259,8 @@
"header": { "header": {
"zones": "区", "zones": "区",
"ratio": "占比", "ratio": "占比",
"area": "坐标区域" "area": "坐标区域",
"score": "分数"
} }
}, },
"annotationSettings": { "annotationSettings": {

View File

@ -174,7 +174,11 @@
"noCameras": { "noCameras": {
"title": "未设置摄像头", "title": "未设置摄像头",
"description": "准备开始连接摄像头至 Frigate 。", "description": "准备开始连接摄像头至 Frigate 。",
"buttonText": "添加摄像头" "buttonText": "添加摄像头",
"restricted": {
"title": "无可用摄像头",
"description": "你没有权限查看此分组中的任何摄像头。"
}
}, },
"snapshot": { "snapshot": {
"takeSnapshot": "下载即时快照", "takeSnapshot": "下载即时快照",

View File

@ -76,7 +76,12 @@
} }
}, },
"npuMemory": "NPU内存", "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": { "otherProcesses": {
"title": "其他进程", "title": "其他进程",

View File

@ -5,7 +5,7 @@ import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu"; import { LuSettings } from "react-icons/lu";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; 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 AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -24,7 +24,7 @@ export default function DebugCameraImage({
}: DebugCameraImageProps) { }: DebugCameraImageProps) {
const { t } = useTranslation(["components/camera"]); const { t } = useTranslation(["components/camera"]);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence<Options>( const [options, setOptions] = useUserPersistence<Options>(
`${cameraConfig?.name}-feed`, `${cameraConfig?.name}-feed`,
emptyObject, emptyObject,
); );

View File

@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
import { VideoPreview } from "../preview/ScrubbablePreview"; import { VideoPreview } from "../preview/ScrubbablePreview";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isDesktop, isSafari } from "react-device-detect"; 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 { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
@ -112,7 +112,7 @@ export function AnimatedEventCard({
// image behavior // image behavior
const [alertVideos, _, alertVideosLoaded] = usePersistence( const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
"alertVideos", "alertVideos",
true, true,
); );

View File

@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuPlus, LuX } from "react-icons/lu"; import { LuPlus, LuX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR, { mutate } from "swr";
import { z } from "zod"; import { z } from "zod";
type ClassificationModelEditDialogProps = { type ClassificationModelEditDialogProps = {
@ -240,15 +240,61 @@ export default function ClassificationModelEditDialog({
position: "top-center", position: "top-center",
}); });
} else { } else {
// State model - update classes const stateData = data as StateFormData;
// Note: For state models, updating classes requires renaming categories const newClasses = stateData.classes.filter(
// which is handled through the dataset API, not the config API (c) => c.trim().length > 0,
// 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 oldClasses = dataset?.categories
? Object.keys(dataset.categories).filter((key) => key !== "none")
: [];
toast.info(t("edit.stateClassesInfo"), { const renameMap = new Map<string, string>();
position: "top-center", 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(); onSuccess();
@ -256,8 +302,10 @@ export default function ClassificationModelEditDialog({
} catch (err) { } catch (err) {
const error = err as { const error = err as {
response?: { data?: { message?: string; detail?: string } }; response?: { data?: { message?: string; detail?: string } };
message?: string;
}; };
const errorMessage = const errorMessage =
error.message ||
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
@ -268,7 +316,7 @@ export default function ClassificationModelEditDialog({
setIsSaving(false); setIsSaving(false);
} }
}, },
[isObjectModel, model, t, onSuccess, onClose], [isObjectModel, model, dataset, t, onSuccess, onClose],
); );
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {

View File

@ -7,7 +7,6 @@ import {
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { MdHome } from "react-icons/md"; import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -57,7 +56,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { ScrollArea, ScrollBar } from "../ui/scroll-area"; 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 { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu"; import * as LuIcons from "react-icons/lu";
@ -79,6 +78,7 @@ import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
type CameraGroupSelectorProps = { type CameraGroupSelectorProps = {
className?: string; className?: string;
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
[timeoutId], [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", "cameraGroup",
"default" as string, "default" as string,
); );
@ -276,7 +276,7 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = usePersistence( const [, , , deleteGridLayout] = useUserPersistence(
`${activeGroup}-draggable-layout`, `${activeGroup}-draggable-layout`,
); );

View File

@ -37,7 +37,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-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 { SaveSearchDialog } from "./SaveSearchDialog";
import { DeleteSearchDialog } from "./DeleteSearchDialog"; import { DeleteSearchDialog } from "./DeleteSearchDialog";
import { import {
@ -128,9 +128,8 @@ export default function InputWithTags({
// TODO: search history from browser storage // TODO: search history from browser storage
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence< const [searchHistory, setSearchHistory, searchHistoryLoaded] =
SavedSearchQuery[] useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
>("frigate-search-history");
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;
@ -68,6 +69,7 @@ type LiveContextMenuProps = {
resetPreferredLiveMode: () => void; resetPreferredLiveMode: () => void;
config?: FrigateConfig; config?: FrigateConfig;
children?: ReactNode; children?: ReactNode;
streamMetadata?: { [key: string]: LiveStreamMetadata };
}; };
export default function LiveContextMenu({ export default function LiveContextMenu({
className, className,
@ -88,6 +90,7 @@ export default function LiveContextMenu({
resetPreferredLiveMode, resetPreferredLiveMode,
config, config,
children, children,
streamMetadata,
}: LiveContextMenuProps) { }: LiveContextMenuProps) {
const { t } = useTranslation("views/live"); const { t } = useTranslation("views/live");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
@ -558,6 +561,7 @@ export default function LiveContextMenu({
setGroupStreamingSettings={setGroupStreamingSettings} setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings} setIsDialogOpen={setShowSettings}
onSave={onSave} onSave={onSave}
streamMetadata={streamMetadata}
/> />
</Dialog> </Dialog>
</div> </div>

View File

@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps, TZDate } from "react-day-picker"; import { type DayButtonProps, TZDate } from "react-day-picker";
import { LAST_24_HOURS_KEY } from "@/types/filter"; 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 { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
}: ReviewActivityCalendarProps) { }: ReviewActivityCalendarProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config); const timezone = useTimezone(config);
const [weekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
const disabledDates = useMemo(() => { const disabledDates = useMemo(() => {
const tomorrow = new Date(); const tomorrow = new Date();
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
selectedDay, selectedDay,
onSelect, onSelect,
}: TimezoneAwareCalendarProps) { }: TimezoneAwareCalendarProps) {
const [weekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
const timezoneOffset = useMemo( const timezoneOffset = useMemo(
() => () =>

View File

@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useOverlayState } from "@/hooks/use-overlay-state"; 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 { cn } from "@/lib/utils";
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
const [tallCamera, setTallCamera] = useState(false); const [tallCamera, setTallCamera] = useState(false);
const [isPlaying, setIsPlaying] = useState(true); 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 [volume, setVolume] = useOverlayState("playerVolume", 1.0);
const [defaultPlaybackRate] = usePersistence("playbackRate", 1); const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
const [playbackRate, setPlaybackRate] = useOverlayState( const [playbackRate, setPlaybackRate] = useOverlayState(
"playbackRate", "playbackRate",
defaultPlaybackRate ?? 1, defaultPlaybackRate ?? 1,

View File

@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { usePersistence } from "@/hooks/use-persistence"; import { useUserPersistence } from "@/hooks/use-user-persistence";
import { import {
LivePlayerError, LivePlayerError,
PlayerStatsType, PlayerStatsType,
@ -72,7 +72,10 @@ function MSEPlayer({
const [errorCount, setErrorCount] = useState<number>(0); const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0); const totalBytesLoaded = useRef(0);
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3); const [fallbackTimeout] = useUserPersistence<number>(
"liveFallbackTimeout",
3,
);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);

View File

@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
type CameraStreamingDialogProps = { type CameraStreamingDialogProps = {
camera: string; camera: string;
groupStreamingSettings: GroupStreamingSettings; groupStreamingSettings: GroupStreamingSettings;
streamMetadata?: { [key: string]: LiveStreamMetadata };
setGroupStreamingSettings: React.Dispatch< setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings> React.SetStateAction<GroupStreamingSettings>
>; >;
@ -48,6 +49,7 @@ type CameraStreamingDialogProps = {
export function CameraStreamingDialog({ export function CameraStreamingDialog({
camera, camera,
groupStreamingSettings, groupStreamingSettings,
streamMetadata,
setGroupStreamingSettings, setGroupStreamingSettings,
setIsDialogOpen, setIsDialogOpen,
onSave, onSave,
@ -76,12 +78,7 @@ export function CameraStreamingDialog({
[config, streamName], [config, streamName],
); );
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>( const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
);
const supportsAudioOutput = useMemo(() => { const supportsAudioOutput = useMemo(() => {
if (!cameraMetadata) { if (!cameraMetadata) {

View File

@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch"; 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 { isDesktop } from "react-device-detect";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
@ -58,7 +58,7 @@ export default function DetailStream({
const effectiveTime = currentTime - annotationOffset / 1000; const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined); const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false); const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence( const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
"detailStreamActiveExpanded", "detailStreamActiveExpanded",
true, true,
); );

View File

@ -6,7 +6,7 @@ import {
useContext, useContext,
} from "react"; } from "react";
import { AllGroupsStreamingSettings } from "@/types/frigateConfig"; import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
import { usePersistence } from "@/hooks/use-persistence"; import { useUserPersistence } from "@/hooks/use-user-persistence";
type StreamingSettingsContextType = { type StreamingSettingsContextType = {
allGroupsStreamingSettings: AllGroupsStreamingSettings; allGroupsStreamingSettings: AllGroupsStreamingSettings;
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
persistedGroupStreamingSettings, persistedGroupStreamingSettings,
setPersistedGroupStreamingSettings, setPersistedGroupStreamingSettings,
isPersistedStreamingSettingsLoaded, isPersistedStreamingSettingsLoaded,
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings"); ] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => { useEffect(() => {
if (isPersistedStreamingSettingsLoaded) { if (isPersistedStreamingSettingsLoaded) {

View File

@ -1,8 +1,8 @@
import { baseUrl } from "@/api/baseUrl";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState, useMemo } from "react"; import { useCallback, useEffect, useState, useMemo } from "react";
import useSWR from "swr"; 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( export default function useCameraLiveMode(
cameras: CameraConfig[], cameras: CameraConfig[],
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
) { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// Get comma-separated list of restreamed stream names for SWR key // Compute which streams need metadata (restreamed streams only)
const restreamedStreamsKey = useMemo(() => { const restreamedStreamNames = useMemo(() => {
if (!cameras || !config) return null; if (!cameras || !config) return [];
const streamNames = new Set<string>(); const streamNames = new Set<string>();
cameras.forEach((camera) => { cameras.forEach((camera) => {
@ -32,56 +32,13 @@ export default function useCameraLiveMode(
} }
}); });
return streamNames.size > 0 return Array.from(streamNames);
? Array.from(streamNames).sort().join(",")
: null;
}, [cameras, config, activeStreams]); }, [cameras, config, activeStreams]);
const streamsFetcher = useCallback(async (key: string) => { // Fetch stream metadata with deferred loading (doesn't block initial render)
const streamNames = key.split(","); const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
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,
});
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode; [key: string]: LivePlayerMode;
}>({}); }>({});
@ -122,10 +79,10 @@ export default function useCameraLiveMode(
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg"; newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
} }
// check each stream for audio support // Check each stream for audio support
if (isRestreamed) { if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => { Object.values(camera.live.streams).forEach((streamName) => {
const metadata = allStreamMetadata?.[streamName]; const metadata = streamMetadata[streamName];
newSupportsAudioOutputStates[streamName] = { newSupportsAudioOutputStates[streamName] = {
supportsAudio: metadata supportsAudio: metadata
? metadata.producers.find( ? metadata.producers.find(
@ -150,7 +107,7 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes); setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates); setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates); setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [cameras, config, windowVisible, allStreamMetadata]); }, [cameras, config, windowVisible, streamMetadata]);
const resetPreferredLiveMode = useCallback( const resetPreferredLiveMode = useCallback(
(cameraName: string) => { (cameraName: string) => {
@ -180,5 +137,6 @@ export default function useCameraLiveMode(
resetPreferredLiveMode, resetPreferredLiveMode,
isRestreamedStates, isRestreamedStates,
supportsAudioOutputStates, supportsAudioOutputStates,
streamMetadata,
}; };
} }

View 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;
}

View File

@ -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 { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence"; import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence";
import { AuthContext } from "@/context/auth-context";
export function useOverlayState<S>( export function useOverlayState<S>(
key: string, 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>(): [ export function useHashState<S extends string>(): [
S | undefined, S | undefined,
(value: S) => void, (value: S) => void,

View 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];
}

View File

@ -3,7 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; 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 { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
import { import {
@ -42,7 +42,10 @@ export default function Events() {
"alert", "alert",
); );
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false); const [showReviewed, setShowReviewed] = useUserPersistence(
"showReviewed",
false,
);
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>( const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
"recording", "recording",

View File

@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar"; import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils"; 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 { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search"; import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import { ModelState } from "@/types/ws"; import { ModelState } from "@/types/ws";
@ -47,7 +47,10 @@ export default function Explore() {
// grid // grid
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const [columnCount, setColumnCount] = useUserPersistence(
"exploreGridColumns",
4,
);
const gridColumns = useMemo(() => { const gridColumns = useMemo(() => {
if (isMobileOnly) { if (isMobileOnly) {
return 2; return 2;
@ -57,7 +60,7 @@ export default function Explore() {
// default layout // default layout
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence( const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
"exploreDefaultView", "exploreDefaultView",
"summary", "summary",
); );

View File

@ -1,10 +1,7 @@
import { useFullscreen } from "@/hooks/use-fullscreen"; import { useFullscreen } from "@/hooks/use-fullscreen";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
useHashState, import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
usePersistedOverlayState,
useSearchEffect,
} from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView"; import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView"; import LiveCameraView from "@/views/live/LiveCameraView";
@ -24,7 +21,7 @@ function Live() {
// selection // selection
const [selectedCameraName, setSelectedCameraName] = useHashState(); const [selectedCameraName, setSelectedCameraName] = useHashState();
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState( const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
"cameraGroup", "cameraGroup",
"default" as string, "default" as string,
); );

View File

@ -1,4 +1,4 @@
import { usePersistence } from "@/hooks/use-persistence"; import { useUserPersistence } from "@/hooks/use-user-persistence";
import { import {
AllGroupsStreamingSettings, AllGroupsStreamingSettings,
BirdseyeConfig, BirdseyeConfig,
@ -24,6 +24,7 @@ import "react-resizable/css/styles.css";
import { import {
AudioState, AudioState,
LivePlayerMode, LivePlayerMode,
LiveStreamMetadata,
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } from "@/types/live";
@ -39,7 +40,7 @@ import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard, LuPencil } from "react-icons/lu"; import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector"; 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 { FaCompress, FaExpand } from "react-icons/fa";
import { import {
Tooltip, Tooltip,
@ -47,7 +48,6 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu"; import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -65,6 +65,16 @@ type DraggableGridLayoutProps = {
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>; setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
fullscreen: boolean; fullscreen: boolean;
toggleFullscreen: () => void; 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({ export default function DraggableGridLayout({
cameras, cameras,
@ -79,6 +89,12 @@ export default function DraggableGridLayout({
setIsEditMode, setIsEditMode,
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
}: DraggableGridLayoutProps) { }: DraggableGridLayoutProps) {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -86,8 +102,8 @@ export default function DraggableGridLayout({
// preferred live modes per camera // preferred live modes per camera
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = useUserPersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings(); useStreamingSettings();
@ -98,42 +114,18 @@ export default function DraggableGridLayout({
} }
}, [allGroupsStreamingSettings, cameraGroup]); }, [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 // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
Layout[] Layout[]
>(`${cameraGroup}-draggable-layout`); >(`${cameraGroup}-draggable-layout`);
const [group] = usePersistedOverlayState("cameraGroup", "default" as string); const [group] = useUserPersistedOverlayState(
"cameraGroup",
"default" as string,
);
const groups = useMemo(() => { const groups = useMemo(() => {
if (!config) { if (!config) {
@ -153,6 +145,11 @@ export default function DraggableGridLayout({
useEffect(() => { useEffect(() => {
setIsEditMode(false); setIsEditMode(false);
setEditGroup(false); setEditGroup(false);
// Reset camera tracking state when group changes to prevent the camera-change
// effect from incorrectly overwriting the loaded layout
setCurrentCameras(undefined);
setCurrentIncludeBirdseye(undefined);
setCurrentGridLayout(undefined);
}, [cameraGroup, setIsEditMode]); }, [cameraGroup, setIsEditMode]);
// camera state // camera state
@ -176,104 +173,120 @@ export default function DraggableGridLayout({
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout], [setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
); );
const generateLayout = useCallback(() => { const generateLayout = useCallback(
if (!isGridLayoutLoaded) { (baseLayout: Layout[] | undefined) => {
return; if (!isGridLayoutLoaded) {
}
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) {
return; return;
} }
let aspectRatio; const cameraNames =
let col; includeBirdseye && birdseyeConfig?.enabled
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
// Handle "birdseye" camera as a special case const optionsMap: Layout[] = baseLayout
if (cameraName === "birdseye") { ? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
aspectRatio = : [];
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
col = 0; // Set birdseye camera in the first column
} else {
const camera = cameras.find((cam) => cam.name === cameraName);
aspectRatio =
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
col = index % 3; // Regular cameras distributed across columns
}
// Calculate layout options based on aspect ratio cameraNames.forEach((cameraName, index) => {
const columnsPerPlayer = 4; const existingLayout = optionsMap.find(
let height; (layout) => layout.i === cameraName,
let width; );
if (aspectRatio < 1) { // Skip if the camera already exists in the layout
// Portrait if (existingLayout) {
height = 2 * columnsPerPlayer; return;
width = columnsPerPlayer; }
} else if (aspectRatio > 2) {
// Wide
height = 1 * columnsPerPlayer;
width = 2 * columnsPerPlayer;
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
}
const options = { let aspectRatio;
i: cameraName, let col;
x: col * width,
y: 0, // don't set y, grid does automatically
w: width,
h: height,
};
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; // Calculate layout options based on aspect ratio
}, [ const columnsPerPlayer = 4;
cameras, let height;
isGridLayoutLoaded, let width;
currentGridLayout,
includeBirdseye, if (aspectRatio < 1) {
birdseyeConfig, // 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(() => { useEffect(() => {
if (isGridLayoutLoaded) { if (isGridLayoutLoaded) {
if (gridLayout) { if (gridLayout) {
// set current grid layout from loaded // set current grid layout from loaded, possibly adding new cameras
setCurrentGridLayout(gridLayout); 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 { } else {
// idb is empty, set it with an initial layout // 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, gridLayout,
currentGridLayout,
setGridLayout, setGridLayout,
isGridLayoutLoaded, isGridLayoutLoaded,
generateLayout, generateLayout,
cameras,
includeBirdseye,
]); ]);
useEffect(() => { 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 ( if (
!isEqual(cameras, currentCameras) || !isEqual(cameras, currentCameras) ||
includeBirdseye !== currentIncludeBirdseye includeBirdseye !== currentIncludeBirdseye
@ -281,15 +294,17 @@ export default function DraggableGridLayout({
setCurrentCameras(cameras); setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye); setCurrentIncludeBirdseye(includeBirdseye);
// set new grid layout in idb // Regenerate layout based on current layout, adding any new cameras
setGridLayout(generateLayout()); const updatedLayout = generateLayout(currentGridLayout);
setCurrentGridLayout(updatedLayout);
setGridLayout(updatedLayout);
} }
}, [ }, [
cameras, cameras,
includeBirdseye, includeBirdseye,
currentCameras, currentCameras,
currentIncludeBirdseye, currentIncludeBirdseye,
setCurrentGridLayout, currentGridLayout,
generateLayout, generateLayout,
setGridLayout, setGridLayout,
isGridLayoutLoaded, isGridLayoutLoaded,
@ -624,6 +639,7 @@ export default function DraggableGridLayout({
resetPreferredLiveMode(camera.name) resetPreferredLiveMode(camera.name)
} }
config={config} config={config}
streamMetadata={streamMetadata}
> >
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
@ -838,6 +854,7 @@ type GridLiveContextMenuProps = {
unmuteAll: () => void; unmuteAll: () => void;
resetPreferredLiveMode: () => void; resetPreferredLiveMode: () => void;
config?: FrigateConfig; config?: FrigateConfig;
streamMetadata?: { [key: string]: LiveStreamMetadata };
}; };
const GridLiveContextMenu = React.forwardRef< const GridLiveContextMenu = React.forwardRef<
@ -868,6 +885,7 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll, unmuteAll,
resetPreferredLiveMode, resetPreferredLiveMode,
config, config,
streamMetadata,
...props ...props
}, },
ref, ref,
@ -899,6 +917,7 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll={unmuteAll} unmuteAll={unmuteAll}
resetPreferredLiveMode={resetPreferredLiveMode} resetPreferredLiveMode={resetPreferredLiveMode}
config={config} config={config}
streamMetadata={streamMetadata}
> >
{children} {children}
</LiveContextMenu> </LiveContextMenu>

View File

@ -101,7 +101,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { usePersistence } from "@/hooks/use-persistence"; import { useUserPersistence } from "@/hooks/use-user-persistence";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import axios from "axios"; import axios from "axios";
@ -146,7 +146,7 @@ export default function LiveCameraView({
// supported features // supported features
const [streamName, setStreamName] = usePersistence<string>( const [streamName, setStreamName] = useUserPersistence<string>(
`${camera.name}-stream`, `${camera.name}-stream`,
Object.values(camera.live.streams)[0], Object.values(camera.live.streams)[0],
); );
@ -279,7 +279,7 @@ export default function LiveCameraView({
const [pip, setPip] = useState(false); const [pip, setPip] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false); const [lowBandwidth, setLowBandwidth] = useState(false);
const [playInBackground, setPlayInBackground] = usePersistence<boolean>( const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
`${camera.name}-background-play`, `${camera.name}-background-play`,
false, false,
); );

View File

@ -13,7 +13,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { usePersistence } from "@/hooks/use-persistence"; import { useUserPersistence } from "@/hooks/use-user-persistence";
import { import {
AllGroupsStreamingSettings, AllGroupsStreamingSettings,
CameraConfig, CameraConfig,
@ -78,7 +78,7 @@ export default function LiveDashboardView({
// layout // layout
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">( const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
"live-layout", "live-layout",
isDesktop ? "grid" : "list", isDesktop ? "grid" : "list",
); );
@ -211,8 +211,8 @@ export default function LiveDashboardView({
}; };
}, []); }, []);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = useUserPersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings(); useStreamingSettings();
@ -265,6 +265,7 @@ export default function LiveDashboardView({
resetPreferredLiveMode, resetPreferredLiveMode,
isRestreamedStates, isRestreamedStates,
supportsAudioOutputStates, supportsAudioOutputStates,
streamMetadata,
} = useCameraLiveMode(cameras, windowVisible, activeStreams); } = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -650,6 +651,12 @@ export default function LiveDashboardView({
setIsEditMode={setIsEditMode} setIsEditMode={setIsEditMode}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
preferredLiveModes={preferredLiveModes}
setPreferredLiveModes={setPreferredLiveModes}
resetPreferredLiveMode={resetPreferredLiveMode}
isRestreamedStates={isRestreamedStates}
supportsAudioOutputStates={supportsAudioOutputStates}
streamMetadata={streamMetadata}
/> />
)} )}
</> </>

View File

@ -478,33 +478,32 @@ export default function AuthenticationView({
<TableCell className="text-right"> <TableCell className="text-right">
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{user.username !== "admin" && {user.username !== "admin" && (
user.username !== "viewer" && ( <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" className="h-8 px-2"
className="h-8 px-2" onClick={() => {
onClick={() => { setSelectedUser(user.username);
setSelectedUser(user.username); setSelectedUserRole(
setSelectedUserRole( user.role || "viewer",
user.role || "viewer", );
); setShowRoleChange(true);
setShowRoleChange(true); }}
}} >
> <LuUserCog className="size-3.5" />
<LuUserCog className="size-3.5" /> <span className="ml-1.5 hidden sm:inline-block">
<span className="ml-1.5 hidden sm:inline-block"> {t("role.title", { ns: "common" })}
{t("role.title", { ns: "common" })} </span>
</span> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>{t("users.table.changeRole")}</p>
<p>{t("users.table.changeRole")}</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> )}
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import useSWR from "swr"; import useSWR from "swr";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Switch } from "@/components/ui/switch"; 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 { Skeleton } from "@/components/ui/skeleton";
import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useCameraActivity } from "@/hooks/use-camera-activity";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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`, `${selectedCamera}-feed`,
emptyObject, emptyObject,
); );

View File

@ -1,15 +1,17 @@
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useCallback, useEffect } from "react"; import { useCallback, useContext, useEffect } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import { Separator } from "../../components/ui/separator"; import { Separator } from "../../components/ui/separator";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { del as delData } from "idb-keyval"; import {
import { usePersistence } from "@/hooks/use-persistence"; useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { isSafari } from "react-device-detect"; import { isSafari } from "react-device-detect";
import { import {
Select, Select,
@ -19,6 +21,7 @@ import {
SelectTrigger, SelectTrigger,
} from "../../components/ui/select"; } from "../../components/ui/select";
import { useTranslation } from "react-i18next"; 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 PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"]; const WEEK_STARTS_ON = ["Sunday", "Monday"];
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function UiSettingsView() { export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
const clearStoredLayouts = useCallback(() => { const clearStoredLayouts = useCallback(() => {
if (!config) { if (!config) {
return []; return [];
} }
Object.entries(config.camera_groups).forEach(async (value) => { Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`) await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
.then(() => { .then(() => {
toast.success( toast.success(
t("general.toast.success.clearStoredLayout", { t("general.toast.success.clearStoredLayout", {
@ -56,14 +62,14 @@ export default function UiSettingsView() {
); );
}); });
}); });
}, [config, t]); }, [config, t, username]);
const clearStreamingSettings = useCallback(async () => { const clearStreamingSettings = useCallback(async () => {
if (!config) { if (!config) {
return []; return [];
} }
await delData(`streaming-settings`) await deleteUserNamespacedKey(`streaming-settings`, username)
.then(() => { .then(() => {
toast.success(t("general.toast.success.clearStreamingSettings"), { toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center", position: "top-center",
@ -83,7 +89,7 @@ export default function UiSettingsView() {
}, },
); );
}); });
}, [config, t]); }, [config, t, username]);
useEffect(() => { useEffect(() => {
document.title = t("documentTitle.general"); document.title = t("documentTitle.general");
@ -91,15 +97,15 @@ export default function UiSettingsView() {
// settings // settings
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true); const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = usePersistence( const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames", "displayCameraNames",
false, false,
); );
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true); const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence( const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
"liveFallbackTimeout", "liveFallbackTimeout",
3, 3,
); );