Add dual-stream support for Frigate with optional sub-stream selection
Features: - Optional sub-stream selection from already discovered streams - No additional scanning required - reuse existing results - UI: "Add Sub Stream" button to select secondary stream - UI: "Remove Sub Stream" button to clear selection - Smart stream routing in Frigate configs - Go2RTC: generates _main and _sub stream names - Frigate: detect on sub (CPU efficient), record on main (quality) - Frigate: auto-detection of stream resolution - Object detection: person, car, cat, dog - Motion-based recording by default - Live view streams configuration - Support for any resolution: HD, 4K, 8K+ - Comprehensive documentation with examples
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
# Frigate Configuration - Запись по движению с детекцией объектов
|
||||||
|
|
||||||
|
Конфигурация для Frigate с записью при обнаружении движения и детекцией объектов (person, car, cat, dog).
|
||||||
|
|
||||||
|
## Dual-Stream конфиг (Main + Sub) - РЕКОМЕНДУЕТСЯ
|
||||||
|
|
||||||
|
Используется sub stream для детекции (экономия CPU), main stream для записи (качество).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Глобальные настройки записи
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
retain:
|
||||||
|
days: 7
|
||||||
|
mode: motion # Записывать только при движении
|
||||||
|
|
||||||
|
# Go2RTC Configuration (Frigate built-in)
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
'10_0_20_112_main':
|
||||||
|
- rtsp://admin:password@10.0.20.112/live/main
|
||||||
|
|
||||||
|
'10_0_20_112_sub':
|
||||||
|
- rtsp://admin:password@10.0.20.112/live/sub
|
||||||
|
|
||||||
|
# Frigate Camera Configuration
|
||||||
|
cameras:
|
||||||
|
camera_10_0_20_112:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://127.0.0.1:8554/10_0_20_112_sub
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- path: rtsp://127.0.0.1:8554/10_0_20_112_main
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- record
|
||||||
|
live:
|
||||||
|
streams:
|
||||||
|
Main Stream: 10_0_20_112_main # HD для просмотра
|
||||||
|
Sub Stream: 10_0_20_112_sub # Низкое разрешение (опционально)
|
||||||
|
objects:
|
||||||
|
track:
|
||||||
|
- person
|
||||||
|
- car
|
||||||
|
- cat
|
||||||
|
- dog
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
version: 0.16-0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single-Stream конфиг (Main только)
|
||||||
|
|
||||||
|
Когда нет sub stream - используется main для детекции и записи.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Глобальные настройки записи
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
retain:
|
||||||
|
days: 7
|
||||||
|
mode: motion # Записывать только при движении
|
||||||
|
|
||||||
|
# Go2RTC Configuration (Frigate built-in)
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
'10_0_20_112_main':
|
||||||
|
- rtsp://admin:password@10.0.20.112/stream1
|
||||||
|
|
||||||
|
# Frigate Camera Configuration
|
||||||
|
cameras:
|
||||||
|
camera_10_0_20_112:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://127.0.0.1:8554/10_0_20_112_main
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- record
|
||||||
|
objects:
|
||||||
|
track:
|
||||||
|
- person
|
||||||
|
- car
|
||||||
|
- cat
|
||||||
|
- dog
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
version: 0.16-0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Режимы записи
|
||||||
|
|
||||||
|
### `mode: motion` (рекомендуется)
|
||||||
|
Записывает видео при обнаружении движения. Экономит место на диске.
|
||||||
|
|
||||||
|
### `mode: active_objects`
|
||||||
|
Записывает только когда обнаружены объекты (person, car, etc). Еще больше экономия.
|
||||||
|
|
||||||
|
### `mode: all`
|
||||||
|
Записывает постоянно 24/7. Требует много места на диске.
|
||||||
|
|
||||||
|
## Преимущества Dual-Stream подхода
|
||||||
|
|
||||||
|
✅ **Низкая нагрузка на CPU** - детекция на sub stream (обычно 352x288 или 640x480)
|
||||||
|
✅ **Качественная запись** - запись на main stream в полном разрешении (HD/4K)
|
||||||
|
✅ **Быстрая детекция** - меньше пикселей = быстрее обработка
|
||||||
|
✅ **Авто-определение разрешения** - Frigate сам определяет параметры потока
|
||||||
|
✅ **Одно подключение к камере** - Go2RTC мультиплексирует потоки
|
||||||
|
|
||||||
|
## Что делает этот конфиг
|
||||||
|
|
||||||
|
✅ **Детекция** - работает постоянно, ищет объекты
|
||||||
|
✅ **Запись** - начинается при движении
|
||||||
|
✅ **Объекты** - распознает person, car, cat, dog
|
||||||
|
✅ **Хранение** - 7 дней записи
|
||||||
|
✅ **Snapshots** - сохраняются автоматически при детекции
|
||||||
|
|
||||||
|
## Добавление других объектов
|
||||||
|
|
||||||
|
Чтобы добавить больше объектов для детекции, измените секцию `objects.track`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
objects:
|
||||||
|
track:
|
||||||
|
- person
|
||||||
|
- car
|
||||||
|
- cat
|
||||||
|
- dog
|
||||||
|
- motorcycle # Мотоциклы
|
||||||
|
- bicycle # Велосипеды
|
||||||
|
- truck # Грузовики
|
||||||
|
- bus # Автобусы
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный список доступных объектов: https://docs.frigate.video/configuration/objects/
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Dual-stream экономит CPU, используйте когда камера поддерживает sub stream
|
||||||
|
- Single-stream проще, но требует больше CPU для детекции (особенно на 4K)
|
||||||
|
- Frigate автоматически определяет разрешение потоков, блок `detect` не нужен
|
||||||
|
- Запись по движению экономит место, но может пропустить начало события
|
||||||
|
- Для непрерывной записи используйте `mode: all`
|
||||||
|
- Frigate автоматически управляет удалением старых записей
|
||||||
|
- Main stream поддерживает любое разрешение: HD (1920x1080), 4K (3840x2160) и выше
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Configuration Generators Documentation
|
||||||
|
|
||||||
|
This document describes how Strix generates configurations for go2rtc and Frigate with support for main and sub streams.
|
||||||
|
|
||||||
|
## Go2RTC Generator (`webui/web/js/config-generators/go2rtc/index.js`)
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
Generates YAML configurations for go2rtc based on discovered camera streams. Supports both single stream and dual-stream (main + sub) configurations.
|
||||||
|
|
||||||
|
### Stream Naming Convention
|
||||||
|
|
||||||
|
**Format:** `<ip_with_underscores>_<suffix>`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- Main: `192.168.1.100` → `192_168_1_100_main`
|
||||||
|
- Sub: `192.168.1.100` → `192_168_1_100_sub`
|
||||||
|
- Main: `10.0.20.112` → `10_0_20_112_main`
|
||||||
|
- Sub: `10.0.20.112` → `10_0_20_112_sub`
|
||||||
|
|
||||||
|
### Single Stream Configuration
|
||||||
|
|
||||||
|
When only a main stream is selected:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
streams:
|
||||||
|
'192_168_1_100_main':
|
||||||
|
- rtsp://admin:password@192.168.1.100/stream1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dual Stream Configuration (Main + Sub)
|
||||||
|
|
||||||
|
When both main and sub streams are selected:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
streams:
|
||||||
|
'192_168_1_100_main':
|
||||||
|
- rtsp://admin:password@192.168.1.100/live/main
|
||||||
|
|
||||||
|
'192_168_1_100_sub':
|
||||||
|
- rtsp://admin:password@192.168.1.100/live/sub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logic by Stream Type
|
||||||
|
|
||||||
|
#### 1. **JPEG Snapshots** (Special Case)
|
||||||
|
Static JPEG images require conversion to video stream using FFmpeg.
|
||||||
|
|
||||||
|
**Generated Config:**
|
||||||
|
```yaml
|
||||||
|
streams:
|
||||||
|
'10_0_20_112_main':
|
||||||
|
- exec:ffmpeg -loglevel quiet -f image2 -loop 1 -framerate 10 -i http://admin:pass@10.0.20.112/snapshot.jpg -c:v libx264 -preset ultrafast -tune zerolatency -g 20 -f rtsp {output}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `-f image2 -loop 1`: Loop single image
|
||||||
|
- `-framerate 10`: 10 fps output
|
||||||
|
- `-c:v libx264`: H264 encoding
|
||||||
|
- `-preset ultrafast -tune zerolatency`: Low latency
|
||||||
|
- `-g 20`: GOP size for keyframes
|
||||||
|
- `-f rtsp {output}`: Output to RTSP (go2rtc internal)
|
||||||
|
|
||||||
|
#### 2. **All Other Formats** (Direct Pass-through)
|
||||||
|
For RTSP, MJPEG, HLS, HTTP-FLV, HTTP-TS, RTMP - use direct URL.
|
||||||
|
go2rtc has native support for these formats.
|
||||||
|
|
||||||
|
**Supported Formats:**
|
||||||
|
- **RTSP** (`rtsp://`) - Direct support
|
||||||
|
- **RTMP** (`rtmp://`) - Direct support
|
||||||
|
- **MJPEG** (`http://...mjpeg`) - Direct support
|
||||||
|
- **HLS** (`http://...m3u8`) - Direct support
|
||||||
|
- **HTTP-FLV** (`http://...flv`) - Direct support
|
||||||
|
- **HTTP-TS** (`http://...ts`) - Direct support
|
||||||
|
|
||||||
|
#### 3. **ONVIF Device Endpoints**
|
||||||
|
ONVIF URLs are converted to `onvif://` format:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
streams:
|
||||||
|
'192_168_1_100_main':
|
||||||
|
- onvif://admin:password@192.168.1.100:80
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frigate Generator (`webui/web/js/config-generators/frigate/index.js`)
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
Generates unified Frigate + Go2RTC YAML configurations with intelligent stream routing for optimal performance.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Motion-based recording**: Records only when motion is detected
|
||||||
|
- **Object detection**: Tracks person, car, cat, dog
|
||||||
|
- **Smart stream routing**:
|
||||||
|
- If sub stream exists → detect on sub (low CPU), record on main (quality)
|
||||||
|
- If no sub stream → detect and record on main
|
||||||
|
|
||||||
|
### Benefits of Dual-Stream Setup
|
||||||
|
|
||||||
|
✅ **Lower CPU usage**: Detection runs on lower resolution sub stream
|
||||||
|
✅ **Better quality**: Recording uses high resolution main stream
|
||||||
|
✅ **Single connection per camera**: Go2RTC multiplexes streams
|
||||||
|
✅ **Optimal performance**: Each task uses appropriate stream quality
|
||||||
|
|
||||||
|
### Single Stream Configuration (Main Only)
|
||||||
|
|
||||||
|
When only main stream is selected, it handles both detection and recording:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Global Recording Settings
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
retain:
|
||||||
|
days: 7
|
||||||
|
mode: motion # Record only on motion detection
|
||||||
|
|
||||||
|
# Go2RTC Configuration (Frigate built-in)
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
'192_168_1_100_main':
|
||||||
|
- rtsp://admin:password@192.168.1.100/stream1
|
||||||
|
|
||||||
|
# Frigate Camera Configuration
|
||||||
|
cameras:
|
||||||
|
camera_192_168_1_100:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://127.0.0.1:8554/192_168_1_100_main
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- record
|
||||||
|
objects:
|
||||||
|
track:
|
||||||
|
- person
|
||||||
|
- car
|
||||||
|
- cat
|
||||||
|
- dog
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
version: 0.16-0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dual Stream Configuration (Main + Sub)
|
||||||
|
|
||||||
|
When both streams are selected, detection uses sub stream and recording uses main stream:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Global Recording Settings
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
retain:
|
||||||
|
days: 7
|
||||||
|
mode: motion # Record only on motion detection
|
||||||
|
|
||||||
|
# Go2RTC Configuration (Frigate built-in)
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
'192_168_1_100_main':
|
||||||
|
- rtsp://admin:password@192.168.1.100/live/main
|
||||||
|
|
||||||
|
'192_168_1_100_sub':
|
||||||
|
- rtsp://admin:password@192.168.1.100/live/sub
|
||||||
|
|
||||||
|
# Frigate Camera Configuration
|
||||||
|
cameras:
|
||||||
|
camera_192_168_1_100:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://127.0.0.1:8554/192_168_1_100_sub
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- path: rtsp://127.0.0.1:8554/192_168_1_100_main
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- record
|
||||||
|
live:
|
||||||
|
streams:
|
||||||
|
Main Stream: 192_168_1_100_main # HD для просмотра
|
||||||
|
Sub Stream: 192_168_1_100_sub # Низкое разрешение (опционально)
|
||||||
|
objects:
|
||||||
|
track:
|
||||||
|
- person
|
||||||
|
- car
|
||||||
|
- cat
|
||||||
|
- dog
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
version: 0.16-0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Sub Stream for Detection?
|
||||||
|
|
||||||
|
✅ **CPU Efficiency**: Processing lower resolution (typically 352x288 or 640x480) instead of HD/4K
|
||||||
|
✅ **Faster Inference**: ML model runs faster on smaller resolution
|
||||||
|
✅ **Sufficient Accuracy**: Object detection doesn't need Full HD or 4K
|
||||||
|
✅ **Quality Recording**: Main stream at full resolution (HD/4K) saved to disk
|
||||||
|
✅ **Auto-detection**: Frigate automatically detects stream resolution
|
||||||
|
|
||||||
|
### Camera Naming Convention
|
||||||
|
|
||||||
|
**Format:** `camera_<ip_with_underscores>`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `192.168.1.100` → `camera_192_168_1_100`
|
||||||
|
- `10.0.20.112` → `camera_10_0_20_112`
|
||||||
|
- `camera.local` → `camera_camera_local`
|
||||||
|
|
||||||
|
### Object Detection
|
||||||
|
|
||||||
|
The generator includes basic object detection for common use cases:
|
||||||
|
|
||||||
|
- **person** - Human detection
|
||||||
|
- **car** - Vehicle detection
|
||||||
|
- **cat** - Cat detection
|
||||||
|
- **dog** - Dog detection
|
||||||
|
|
||||||
|
To add more objects, edit the generated config and add items from [Frigate's object list](https://docs.frigate.video/configuration/objects/).
|
||||||
|
|
||||||
|
### Recording Mode
|
||||||
|
|
||||||
|
**Mode: `motion`** (Default)
|
||||||
|
- Records only when motion is detected
|
||||||
|
- Saves disk space
|
||||||
|
- May miss the start of an event
|
||||||
|
|
||||||
|
**To enable 24/7 recording**, change to:
|
||||||
|
```yaml
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
retain:
|
||||||
|
days: 7
|
||||||
|
mode: all # Continuous recording
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Stream Discovery
|
||||||
|
↓
|
||||||
|
User selects Main Stream
|
||||||
|
↓
|
||||||
|
Config generated (main only)
|
||||||
|
↓
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ User clicks │
|
||||||
|
│ "Add Sub Stream" │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
↓
|
||||||
|
User selects Sub Stream from existing results
|
||||||
|
↓
|
||||||
|
Config regenerated (main + sub with optimized routing)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **No additional scanning**: Sub stream is selected from already discovered streams
|
||||||
|
2. **Intelligent routing**: Sub for detect, main for record (when both available)
|
||||||
|
3. **Simplicity first**: Use direct URLs whenever possible
|
||||||
|
4. **Native support**: Leverage go2rtc's built-in format support
|
||||||
|
5. **Special cases only**: Only use exec:ffmpeg for JPEG snapshots
|
||||||
|
6. **Motion-based recording**: Save disk space by default
|
||||||
|
|
||||||
|
## Benefits of This Approach
|
||||||
|
|
||||||
|
✅ **Better performance**: Optimal stream selection for each task
|
||||||
|
✅ **Lower CPU usage**: Detection on lower resolution when sub stream available
|
||||||
|
✅ **Quality recordings**: Full resolution saved to disk
|
||||||
|
✅ **User flexibility**: Optional sub stream - not required
|
||||||
|
✅ **No re-scanning**: Reuses already discovered streams
|
||||||
|
✅ **Disk space efficiency**: Motion-based recording by default
|
||||||
+75
-15
@@ -556,12 +556,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== CAROUSEL ===== */
|
/* ===== CAROUSEL ===== */
|
||||||
.carousel {
|
.carousel-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.carousel-track {
|
.carousel-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
transition: transform var(--transition-slow);
|
transition: transform var(--transition-slow);
|
||||||
@@ -621,9 +628,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.carousel-arrow {
|
.carousel-arrow {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
@@ -634,7 +639,6 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
z-index: 10;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,15 +654,12 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-arrow-left {
|
|
||||||
left: -24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow-right {
|
|
||||||
right: -24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.carousel-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.carousel-arrow {
|
.carousel-arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -699,14 +700,32 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== SELECTED STREAM INFO ===== */
|
/* ===== SELECTED STREAM INFO ===== */
|
||||||
|
.stream-selection-container {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
.selected-stream-info {
|
.selected-stream-info {
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-stream-info:last-child {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.selected-type {
|
.selected-type {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -723,6 +742,28 @@ body {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-stream {
|
||||||
|
border-color: rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-sub {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-sub:hover {
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== TABS ===== */
|
/* ===== TABS ===== */
|
||||||
.tabs {
|
.tabs {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
@@ -796,13 +837,32 @@ body {
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-6);
|
margin-top: 10px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions .btn {
|
.actions .btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-actions .btn-primary {
|
||||||
|
flex: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-actions .btn-outline {
|
||||||
|
flex: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== TOAST ===== */
|
/* ===== TOAST ===== */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+28
-8
@@ -217,14 +217,16 @@
|
|||||||
<div id="streams-section" class="streams-section hidden">
|
<div id="streams-section" class="streams-section hidden">
|
||||||
<h3 class="section-title">Found Connections</h3>
|
<h3 class="section-title">Found Connections</h3>
|
||||||
|
|
||||||
<div class="carousel">
|
<div class="carousel-wrapper">
|
||||||
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
|
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="carousel-track" class="carousel-track"></div>
|
<div class="carousel">
|
||||||
|
<div id="carousel-track" class="carousel-track"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
|
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -253,9 +255,19 @@
|
|||||||
|
|
||||||
<h2 class="screen-title">Stream Configuration</h2>
|
<h2 class="screen-title">Stream Configuration</h2>
|
||||||
|
|
||||||
<div class="selected-stream-info">
|
<div class="stream-selection-container">
|
||||||
<p id="selected-stream-type" class="selected-type"></p>
|
<div class="selected-stream-info">
|
||||||
<p id="selected-stream-url" class="selected-url"></p>
|
<p class="stream-label">Main Stream</p>
|
||||||
|
<p id="selected-main-type" class="selected-type"></p>
|
||||||
|
<p id="selected-main-url" class="selected-url"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sub-stream-info" class="selected-stream-info sub-stream hidden">
|
||||||
|
<p class="stream-label">Sub Stream</p>
|
||||||
|
<p id="selected-sub-type" class="selected-type"></p>
|
||||||
|
<p id="selected-sub-url" class="selected-url"></p>
|
||||||
|
<button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -294,9 +306,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-new-search" class="btn btn-outline">
|
<div class="secondary-actions">
|
||||||
Add Another Camera
|
<button id="btn-add-sub-stream" class="btn btn-primary">
|
||||||
</button>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Add Sub Stream
|
||||||
|
</button>
|
||||||
|
<button id="btn-new-search" class="btn btn-outline">
|
||||||
|
Add Another Camera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Frigate NVR Configuration Generator
|
||||||
|
* Generates unified Frigate + Go2RTC YAML configs
|
||||||
|
* All cameras are routed through Frigate's built-in go2rtc for optimal performance
|
||||||
|
*/
|
||||||
export class FrigateGenerator {
|
export class FrigateGenerator {
|
||||||
static generate(stream) {
|
/**
|
||||||
// For non-RTSP streams, suggest using Go2RTC
|
* Generate complete Frigate config with embedded Go2RTC
|
||||||
if (stream.type !== 'FFMPEG' || stream.protocol !== 'rtsp') {
|
* @param {Object} mainStream - Main stream object (used for recording)
|
||||||
return `# This stream type requires Go2RTC proxy\n\n` +
|
* @param {Object} subStream - Optional sub stream object (used for detection if provided)
|
||||||
`# This ${stream.type} stream is not natively supported by Frigate.\n` +
|
* @returns {string} YAML configuration string
|
||||||
`# Please use Go2RTC to convert it to RTSP first.\n\n` +
|
*/
|
||||||
`# Steps:\n` +
|
static generate(mainStream, subStream = null) {
|
||||||
`# 1. Add this stream to your Go2RTC configuration\n` +
|
const cameraName = this.generateCameraName(mainStream);
|
||||||
`# 2. Use the Go2RTC RTSP endpoint in Frigate\n` +
|
|
||||||
`# 3. Example: rtsp://localhost:8554/camera_stream_0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate RTSP config for Frigate
|
|
||||||
const cameraName = this.generateCameraName(stream);
|
|
||||||
const config = [];
|
const config = [];
|
||||||
|
|
||||||
config.push(`cameras:`);
|
// MQTT Configuration
|
||||||
config.push(` ${cameraName}:`);
|
config.push('mqtt:');
|
||||||
config.push(` ffmpeg:`);
|
config.push(' enabled: false');
|
||||||
config.push(` inputs:`);
|
config.push('');
|
||||||
config.push(` - path: ${stream.url}`);
|
|
||||||
config.push(` roles:`);
|
|
||||||
config.push(` - detect`);
|
|
||||||
config.push(` - record`);
|
|
||||||
|
|
||||||
if (stream.resolution) {
|
// Global Record Configuration
|
||||||
config.push(` detect:`);
|
config.push('# Global Recording Settings');
|
||||||
const [width, height] = stream.resolution.split('x').map(Number);
|
config.push('record:');
|
||||||
if (width && height) {
|
config.push(' enabled: true');
|
||||||
config.push(` width: ${width}`);
|
config.push(' retain:');
|
||||||
config.push(` height: ${height}`);
|
config.push(' days: 7');
|
||||||
}
|
config.push(' mode: motion # Record only on motion detection');
|
||||||
|
config.push('');
|
||||||
|
|
||||||
|
// Generate Go2RTC section
|
||||||
|
config.push('# Go2RTC Configuration (Frigate built-in)');
|
||||||
|
config.push('go2rtc:');
|
||||||
|
config.push(' streams:');
|
||||||
|
|
||||||
|
// Main stream configuration
|
||||||
|
const mainStreamName = this.generateStreamName(mainStream, 'main');
|
||||||
|
const mainSource = this.generateGo2RTCSource(mainStream);
|
||||||
|
config.push(` '${mainStreamName}':`);
|
||||||
|
config.push(` - ${mainSource}`);
|
||||||
|
|
||||||
|
// Sub stream configuration if provided
|
||||||
|
if (subStream) {
|
||||||
|
config.push('');
|
||||||
|
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||||
|
const subSource = this.generateGo2RTCSource(subStream);
|
||||||
|
config.push(` '${subStreamName}':`);
|
||||||
|
config.push(` - ${subSource}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.push('');
|
||||||
|
|
||||||
|
// Generate Frigate cameras section
|
||||||
|
config.push('# Frigate Camera Configuration');
|
||||||
|
config.push('cameras:');
|
||||||
|
config.push(` ${cameraName}:`);
|
||||||
|
config.push(' ffmpeg:');
|
||||||
|
config.push(' inputs:');
|
||||||
|
|
||||||
|
if (subStream) {
|
||||||
|
// If sub stream exists: use it for detection, main for recording
|
||||||
|
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||||
|
config.push(` - path: rtsp://127.0.0.1:8554/${subStreamName}`);
|
||||||
|
config.push(' input_args: preset-rtsp-restream');
|
||||||
|
config.push(' roles:');
|
||||||
|
config.push(' - detect');
|
||||||
|
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
|
||||||
|
config.push(' input_args: preset-rtsp-restream');
|
||||||
|
config.push(' roles:');
|
||||||
|
config.push(' - record');
|
||||||
|
} else {
|
||||||
|
// No sub stream: use main for both detection and recording
|
||||||
|
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
|
||||||
|
config.push(' input_args: preset-rtsp-restream');
|
||||||
|
config.push(' roles:');
|
||||||
|
config.push(' - detect');
|
||||||
|
config.push(' - record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live view configuration
|
||||||
|
if (subStream) {
|
||||||
|
config.push(' live:');
|
||||||
|
config.push(' streams:');
|
||||||
|
config.push(` Main Stream: ${mainStreamName} # HD для просмотра`);
|
||||||
|
config.push(` Sub Stream: ${this.generateStreamName(subStream, 'sub')} # Низкое разрешение (опционально)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object detection configuration
|
||||||
|
config.push(' objects:');
|
||||||
|
config.push(' track:');
|
||||||
|
config.push(' - person');
|
||||||
|
config.push(' - car');
|
||||||
|
config.push(' - cat');
|
||||||
|
config.push(' - dog');
|
||||||
|
|
||||||
|
// Recording configuration
|
||||||
|
config.push(' record:');
|
||||||
|
config.push(' enabled: true');
|
||||||
|
|
||||||
|
config.push('');
|
||||||
|
config.push('version: 0.16-0');
|
||||||
|
|
||||||
return config.join('\n');
|
return config.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Go2RTC source configuration based on stream type
|
||||||
|
* Returns the source string for go2rtc streams section
|
||||||
|
*/
|
||||||
|
static generateGo2RTCSource(stream) {
|
||||||
|
// Handle JPEG snapshots with exec:ffmpeg conversion
|
||||||
|
// Uses full path to ffmpeg and {{output}} for Frigate template escaping
|
||||||
|
if (stream.type === 'JPEG') {
|
||||||
|
return [
|
||||||
|
'exec:/usr/lib/ffmpeg/7.0/bin/ffmpeg',
|
||||||
|
'-loglevel quiet',
|
||||||
|
'-f image2',
|
||||||
|
'-loop 1',
|
||||||
|
'-framerate 10',
|
||||||
|
`-i ${stream.url}`,
|
||||||
|
'-c:v libx264',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-tune zerolatency',
|
||||||
|
'-g 20',
|
||||||
|
'-f rtsp {{output}}' // Double braces for Frigate template escaping
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ONVIF - convert to onvif:// format if needed
|
||||||
|
if (stream.type === 'ONVIF') {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(stream.url);
|
||||||
|
// Extract credentials and host from HTTP URL
|
||||||
|
const username = urlObj.username || 'admin';
|
||||||
|
const password = urlObj.password || '';
|
||||||
|
const host = urlObj.hostname;
|
||||||
|
const port = urlObj.port || '80';
|
||||||
|
|
||||||
|
// Generate onvif:// URL
|
||||||
|
return `onvif://${username}:${password}@${host}:${port}`;
|
||||||
|
} catch (e) {
|
||||||
|
// If URL parsing fails, return as-is
|
||||||
|
return stream.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other types (RTSP, MJPEG, HLS, HTTP-FLV, RTMP, etc.): use direct URL
|
||||||
|
// Go2RTC handles these formats natively
|
||||||
|
return stream.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate camera name from IP address
|
||||||
|
* Format: "camera_192_168_1_100"
|
||||||
|
*/
|
||||||
static generateCameraName(stream) {
|
static generateCameraName(stream) {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(stream.url);
|
const urlObj = new URL(stream.url);
|
||||||
@@ -45,4 +162,18 @@ export class FrigateGenerator {
|
|||||||
return 'camera';
|
return 'camera';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate stream name for Go2RTC reference
|
||||||
|
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
|
||||||
|
*/
|
||||||
|
static generateStreamName(stream, suffix) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(stream.url);
|
||||||
|
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
|
||||||
|
return `${ip}_${suffix}`;
|
||||||
|
} catch (e) {
|
||||||
|
return `camera_stream_${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Go2RTC Configuration Generator
|
||||||
|
* Generates proper go2rtc YAML configs based on stream type
|
||||||
|
* Following go2rtc documentation and best practices
|
||||||
|
*/
|
||||||
export class Go2RTCGenerator {
|
export class Go2RTCGenerator {
|
||||||
static generate(stream) {
|
/**
|
||||||
const streamName = this.generateStreamName(stream);
|
* Generate go2rtc config for streams (main + optional sub)
|
||||||
|
* @param {Object} mainStream - Main stream object with type, protocol, and url
|
||||||
|
* @param {Object} subStream - Optional sub stream object
|
||||||
|
* @returns {string} YAML configuration string
|
||||||
|
*/
|
||||||
|
static generate(mainStream, subStream = null) {
|
||||||
|
const configs = [];
|
||||||
|
configs.push('streams:');
|
||||||
|
|
||||||
switch (stream.type) {
|
// Generate main stream config
|
||||||
case 'FFMPEG':
|
const mainStreamName = this.generateStreamName(mainStream, 'main');
|
||||||
if (stream.protocol === 'rtsp') {
|
const mainSource = this.generateSource(mainStream);
|
||||||
return this.generateRTSP(streamName, stream);
|
configs.push(` '${mainStreamName}':`);
|
||||||
}
|
configs.push(` - ${mainSource}`);
|
||||||
break;
|
|
||||||
case 'JPEG':
|
// Generate sub stream config if provided
|
||||||
return this.generateJPEG(streamName, stream);
|
if (subStream) {
|
||||||
case 'MJPEG':
|
configs.push('');
|
||||||
return this.generateMJPEG(streamName, stream);
|
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||||
case 'HTTP_VIDEO':
|
const subSource = this.generateSource(subStream);
|
||||||
return this.generateHTTPVideo(streamName, stream);
|
configs.push(` '${subStreamName}':`);
|
||||||
case 'HLS':
|
configs.push(` - ${subSource}`);
|
||||||
return this.generateHLS(streamName, stream);
|
|
||||||
case 'ONVIF':
|
|
||||||
return `# ONVIF Device Service\n# This is a device management endpoint, not a stream\n# URL: ${stream.url}`;
|
|
||||||
default:
|
|
||||||
return this.generateRTSP(streamName, stream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return configs.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateStreamName(stream) {
|
/**
|
||||||
|
* Generate stream name from IP address with suffix
|
||||||
|
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
|
||||||
|
*/
|
||||||
|
static generateStreamName(stream, suffix) {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(stream.url);
|
const urlObj = new URL(stream.url);
|
||||||
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
|
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
|
||||||
return `${ip}_0`;
|
return `${ip}_${suffix}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'camera_stream_0';
|
return `camera_stream_${suffix}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateRTSP(streamName, stream) {
|
/**
|
||||||
return `streams:\n '${streamName}':\n - ${stream.url}`;
|
* Generate source configuration based on stream type
|
||||||
|
*/
|
||||||
|
static generateSource(stream) {
|
||||||
|
// Handle JPEG snapshots with special exec:ffmpeg conversion
|
||||||
|
if (stream.type === 'JPEG') {
|
||||||
|
return this.generateJPEGSource(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ONVIF
|
||||||
|
if (stream.type === 'ONVIF') {
|
||||||
|
return this.generateONVIFSource(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other types: use direct URL
|
||||||
|
return stream.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateJPEG(streamName, stream) {
|
/**
|
||||||
const framerate = 10;
|
* Generate JPEG snapshot conversion using exec:ffmpeg
|
||||||
const ffmpegCmd = [
|
* Converts static JPEG to RTSP stream with H264 encoding
|
||||||
|
*/
|
||||||
|
static generateJPEGSource(stream) {
|
||||||
|
return [
|
||||||
'exec:ffmpeg',
|
'exec:ffmpeg',
|
||||||
'-loglevel quiet',
|
'-loglevel quiet',
|
||||||
'-f image2',
|
'-f image2',
|
||||||
'-loop 1',
|
'-loop 1',
|
||||||
`-framerate ${framerate}`,
|
'-framerate 10',
|
||||||
`-i ${stream.url}`,
|
`-i ${stream.url}`,
|
||||||
'-c:v libx264',
|
'-c:v libx264',
|
||||||
'-preset ultrafast',
|
'-preset ultrafast',
|
||||||
@@ -52,36 +82,22 @@ export class Go2RTCGenerator {
|
|||||||
'-g 20',
|
'-g 20',
|
||||||
'-f rtsp {output}'
|
'-f rtsp {output}'
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateMJPEG(streamName, stream) {
|
/**
|
||||||
const ffmpegCmd = [
|
* Generate ONVIF source
|
||||||
'exec:ffmpeg',
|
* Converts HTTP device service endpoint to onvif:// format
|
||||||
'-loglevel quiet',
|
*/
|
||||||
`-i ${stream.url}`,
|
static generateONVIFSource(stream) {
|
||||||
'-c:v copy',
|
try {
|
||||||
'-f rtsp {output}'
|
const urlObj = new URL(stream.url);
|
||||||
].join(' ');
|
const username = urlObj.username || 'admin';
|
||||||
|
const password = urlObj.password || '';
|
||||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
const host = urlObj.hostname;
|
||||||
}
|
const port = urlObj.port || '80';
|
||||||
|
return `onvif://${username}:${password}@${host}:${port}`;
|
||||||
static generateHTTPVideo(streamName, stream) {
|
} catch (e) {
|
||||||
const ffmpegCmd = [
|
return stream.url;
|
||||||
'exec:ffmpeg',
|
}
|
||||||
'-loglevel quiet',
|
|
||||||
`-i ${stream.url}`,
|
|
||||||
'-c:v copy',
|
|
||||||
'-c:a copy',
|
|
||||||
'-f rtsp {output}'
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static generateHLS(streamName, stream) {
|
|
||||||
return `streams:\n '${streamName}':\n - ${stream.url}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-10
@@ -16,7 +16,9 @@ class StrixApp {
|
|||||||
|
|
||||||
this.currentAddress = '';
|
this.currentAddress = '';
|
||||||
this.currentStreams = [];
|
this.currentStreams = [];
|
||||||
this.currentStream = null;
|
this.selectedMainStream = null;
|
||||||
|
this.selectedSubStream = null;
|
||||||
|
this.isSelectingSubStream = false;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -94,11 +96,16 @@ class StrixApp {
|
|||||||
|
|
||||||
// Screen 4: Configuration output
|
// Screen 4: Configuration output
|
||||||
document.getElementById('btn-back-to-streams').addEventListener('click', () => {
|
document.getElementById('btn-back-to-streams').addEventListener('click', () => {
|
||||||
|
this.isSelectingSubStream = false;
|
||||||
this.showScreen('discovery');
|
this.showScreen('discovery');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
|
document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
|
||||||
document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
|
document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
|
||||||
|
|
||||||
|
document.getElementById('btn-add-sub-stream').addEventListener('click', () => this.addSubStream());
|
||||||
|
document.getElementById('btn-remove-sub').addEventListener('click', () => this.removeSubStream());
|
||||||
|
|
||||||
document.getElementById('btn-new-search').addEventListener('click', () => {
|
document.getElementById('btn-new-search').addEventListener('click', () => {
|
||||||
this.reset();
|
this.reset();
|
||||||
this.showScreen('address');
|
this.showScreen('address');
|
||||||
@@ -171,9 +178,16 @@ class StrixApp {
|
|||||||
async searchCameraModels(query, limit = 10, append = false) {
|
async searchCameraModels(query, limit = 10, append = false) {
|
||||||
const dropdown = document.getElementById('autocomplete-dropdown');
|
const dropdown = document.getElementById('autocomplete-dropdown');
|
||||||
|
|
||||||
|
// Keep dropdown open and show loading state smoothly
|
||||||
if (!append) {
|
if (!append) {
|
||||||
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
const isOpen = !dropdown.classList.contains('hidden');
|
||||||
dropdown.classList.remove('hidden');
|
if (!isOpen) {
|
||||||
|
dropdown.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
// Show loading only if dropdown was empty or closed
|
||||||
|
if (!isOpen || dropdown.children.length === 0) {
|
||||||
|
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -316,9 +330,52 @@ class StrixApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectStream(stream, index) {
|
selectStream(stream, index) {
|
||||||
this.currentStream = stream;
|
if (!this.isSelectingSubStream) {
|
||||||
this.configPanel.render(stream);
|
// Selecting main stream
|
||||||
this.showScreen('output');
|
this.selectedMainStream = stream;
|
||||||
|
this.selectedSubStream = null;
|
||||||
|
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||||
|
this.updateSubStreamUI();
|
||||||
|
this.showScreen('output');
|
||||||
|
} else {
|
||||||
|
// Selecting sub stream
|
||||||
|
this.selectedSubStream = stream;
|
||||||
|
this.isSelectingSubStream = false;
|
||||||
|
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||||
|
this.updateSubStreamUI();
|
||||||
|
this.showScreen('output');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubStream() {
|
||||||
|
if (this.currentStreams.length === 0) {
|
||||||
|
showToast('No streams available to select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSelectingSubStream = true;
|
||||||
|
showToast('Select a sub stream from available streams');
|
||||||
|
this.showScreen('discovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSubStream() {
|
||||||
|
this.selectedSubStream = null;
|
||||||
|
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||||
|
this.updateSubStreamUI();
|
||||||
|
showToast('Sub stream removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubStreamUI() {
|
||||||
|
const subStreamInfo = document.getElementById('sub-stream-info');
|
||||||
|
const addSubStreamBtn = document.getElementById('btn-add-sub-stream');
|
||||||
|
|
||||||
|
if (this.selectedSubStream) {
|
||||||
|
subStreamInfo.classList.remove('hidden');
|
||||||
|
addSubStreamBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
subStreamInfo.classList.add('hidden');
|
||||||
|
addSubStreamBtn.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switchTab(tabName) {
|
switchTab(tabName) {
|
||||||
@@ -336,12 +393,22 @@ class StrixApp {
|
|||||||
const configElement = document.getElementById(`config-${activeTab}`);
|
const configElement = document.getElementById(`config-${activeTab}`);
|
||||||
const text = configElement.textContent;
|
const text = configElement.textContent;
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
showToast('Copied to clipboard!');
|
showToast('Copied to clipboard!');
|
||||||
}).catch(err => {
|
} catch (err) {
|
||||||
showToast('Failed to copy');
|
showToast('Failed to copy');
|
||||||
console.error('Copy error:', err);
|
console.error('Copy error:', err);
|
||||||
});
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadConfig() {
|
downloadConfig() {
|
||||||
@@ -364,7 +431,9 @@ class StrixApp {
|
|||||||
reset() {
|
reset() {
|
||||||
this.currentAddress = '';
|
this.currentAddress = '';
|
||||||
this.currentStreams = [];
|
this.currentStreams = [];
|
||||||
this.currentStream = null;
|
this.selectedMainStream = null;
|
||||||
|
this.selectedSubStream = null;
|
||||||
|
this.isSelectingSubStream = false;
|
||||||
|
|
||||||
document.getElementById('network-address').value = '';
|
document.getElementById('network-address').value = '';
|
||||||
document.getElementById('camera-model').value = '';
|
document.getElementById('camera-model').value = '';
|
||||||
|
|||||||
@@ -3,20 +3,28 @@ import { FrigateGenerator } from '../config-generators/frigate/index.js';
|
|||||||
|
|
||||||
export class ConfigPanel {
|
export class ConfigPanel {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.stream = null;
|
this.mainStream = null;
|
||||||
|
this.subStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(stream) {
|
render(mainStream, subStream = null) {
|
||||||
this.stream = stream;
|
this.mainStream = mainStream;
|
||||||
|
this.subStream = subStream;
|
||||||
|
|
||||||
// Update selected stream info
|
// Update main stream info
|
||||||
document.getElementById('selected-stream-type').textContent = stream.type;
|
document.getElementById('selected-main-type').textContent = mainStream.type;
|
||||||
document.getElementById('selected-stream-url').textContent = this.maskCredentials(stream.url);
|
document.getElementById('selected-main-url').textContent = this.maskCredentials(mainStream.url);
|
||||||
|
|
||||||
|
// Update sub stream info if provided
|
||||||
|
if (subStream) {
|
||||||
|
document.getElementById('selected-sub-type').textContent = subStream.type;
|
||||||
|
document.getElementById('selected-sub-url').textContent = this.maskCredentials(subStream.url);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate configs
|
// Generate configs
|
||||||
const urlConfig = stream.url;
|
const urlConfig = this.generateURLConfig();
|
||||||
const go2rtcConfig = Go2RTCGenerator.generate(stream);
|
const go2rtcConfig = Go2RTCGenerator.generate(mainStream, subStream);
|
||||||
const frigateConfig = FrigateGenerator.generate(stream);
|
const frigateConfig = FrigateGenerator.generate(mainStream, subStream);
|
||||||
|
|
||||||
// Update config displays
|
// Update config displays
|
||||||
document.getElementById('config-url').textContent = urlConfig;
|
document.getElementById('config-url').textContent = urlConfig;
|
||||||
@@ -24,6 +32,13 @@ export class ConfigPanel {
|
|||||||
document.getElementById('config-frigate').textContent = frigateConfig;
|
document.getElementById('config-frigate').textContent = frigateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateURLConfig() {
|
||||||
|
if (this.subStream) {
|
||||||
|
return `Main Stream:\n${this.mainStream.url}\n\nSub Stream:\n${this.subStream.url}`;
|
||||||
|
}
|
||||||
|
return this.mainStream.url;
|
||||||
|
}
|
||||||
|
|
||||||
maskCredentials(url) {
|
maskCredentials(url) {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
|||||||
Reference in New Issue
Block a user