Add go2rtc module, test/config/urls pages, Frigate config fixes

This commit is contained in:
eduard256
2026-03-26 22:45:32 +00:00
parent 8dc8ba1096
commit f34a7b96c7
12 changed files with 2411 additions and 52 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
package frigate
import (
"encoding/json"
"io"
"net/http"
"sync"
@@ -99,10 +100,17 @@ func apiConfig(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(resp.Body)
// Frigate /api/config/raw returns JSON-encoded string, unquote it
config := string(body)
var unquoted string
if err := json.Unmarshal(body, &unquoted); err == nil {
config = unquoted
}
api.ResponseJSON(w, map[string]any{
"connected": true,
"url": url,
"config": string(body),
"config": config,
})
}
+108
View File
@@ -0,0 +1,108 @@
package go2rtc
import (
"io"
"net/http"
"sync"
"time"
"github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app"
"github.com/rs/zerolog"
)
var log zerolog.Logger
var go2rtcURL string
var go2rtcOnce sync.Once
var candidates = []string{
"http://localhost:1984",
"http://localhost:11984",
}
const probeTimeout = 50 * time.Millisecond
const requestTimeout = 5 * time.Second
func Init() {
log = app.GetLogger("go2rtc")
if url := app.Env("STRIX_GO2RTC_URL", ""); url != "" {
go2rtcURL = url
log.Info().Str("url", go2rtcURL).Msg("[go2rtc] using STRIX_GO2RTC_URL")
}
api.HandleFunc("api/go2rtc/streams", apiStreams)
}
func getURL() string {
if go2rtcURL != "" {
return go2rtcURL
}
go2rtcOnce.Do(func() {
go2rtcURL = probe()
if go2rtcURL != "" {
log.Info().Str("url", go2rtcURL).Msg("[go2rtc] discovered")
}
})
return go2rtcURL
}
func probe() string {
client := &http.Client{Timeout: probeTimeout}
for _, url := range candidates {
resp, err := client.Get(url + "/api")
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode == 200 {
return url
}
}
return ""
}
// PUT /api/go2rtc/streams?name=...&src=... -- proxy to go2rtc
func apiStreams(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
url := getURL()
if url == "" {
api.ResponseJSON(w, map[string]any{"success": false, "error": "go2rtc not found"})
return
}
// forward query params as-is
target := url + "/api/streams?" + r.URL.RawQuery
client := &http.Client{Timeout: requestTimeout}
req, err := http.NewRequest("PUT", target, nil)
if err != nil {
api.ResponseJSON(w, map[string]any{"success": false, "error": err.Error()})
return
}
resp, err := client.Do(req)
if err != nil {
api.ResponseJSON(w, map[string]any{"success": false, "error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
if resp.StatusCode == 200 {
api.ResponseJSON(w, map[string]any{"success": true})
} else {
api.ResponseJSON(w, map[string]any{"success": false, "error": string(body)})
}
}
+2
View File
@@ -5,6 +5,7 @@ import (
"github.com/eduard256/strix/internal/app"
"github.com/eduard256/strix/internal/frigate"
"github.com/eduard256/strix/internal/generate"
"github.com/eduard256/strix/internal/go2rtc"
"github.com/eduard256/strix/internal/probe"
"github.com/eduard256/strix/internal/search"
"github.com/eduard256/strix/internal/test"
@@ -26,6 +27,7 @@ func main() {
{"probe", probe.Init},
{"generate", generate.Init},
{"frigate", frigate.Init},
{"go2rtc", go2rtc.Init},
}
for _, m := range modules {
+12 -4
View File
@@ -26,9 +26,17 @@ func Generate(req *Request) (*Response, error) {
}
}
if strings.TrimSpace(req.ExistingConfig) == "" {
existing := strings.TrimSpace(req.ExistingConfig)
// generate from scratch if no config or config has no go2rtc streams section
if existing == "" || !strings.Contains(existing, "go2rtc:") {
config := newConfig(info, req)
return &Response{Config: config, Diff: fullDiff(config)}, nil
lines := strings.Count(config, "\n") + 1
added := make([]int, lines)
for i := range added {
added[i] = i + 1
}
return &Response{Config: config, Added: added}, nil
}
return addToConfig(req.ExistingConfig, info, req)
@@ -124,7 +132,7 @@ func newConfig(info *cameraInfo, req *Request) string {
var b strings.Builder
b.WriteString("mqtt:\n enabled: false\n\n")
b.WriteString("record:\n enabled: true\n retain:\n days: 7\n mode: motion\n\n")
b.WriteString("record:\n enabled: true\n\n")
b.WriteString("go2rtc:\n streams:\n")
writeStreamLines(&b, info)
@@ -132,7 +140,7 @@ func newConfig(info *cameraInfo, req *Request) string {
b.WriteString("cameras:\n")
writeCameraBlock(&b, info, req)
b.WriteString("version: 0.18-0\n")
b.WriteString("version: 0.17-0\n")
return b.String()
}
-36
View File
@@ -1,36 +0,0 @@
package generate
import "strings"
func fullDiff(config string) []DiffLine {
lines := strings.Split(config, "\n")
diff := make([]DiffLine, len(lines))
for i, line := range lines {
diff[i] = DiffLine{Line: i + 1, Text: line, Type: "added"}
}
return diff
}
func diffWithContext(lines []string, added map[int]bool, ctx int) []DiffLine {
visible := make(map[int]bool)
for idx := range added {
for c := -ctx; c <= ctx; c++ {
if j := idx + c; j >= 0 && j < len(lines) {
visible[j] = true
}
}
}
var diff []DiffLine
for i, line := range lines {
if !visible[i] {
continue
}
t := "context"
if added[i] {
t = "added"
}
diff = append(diff, DiffLine{Line: i + 1, Text: line, Type: t})
}
return diff
}
+9 -2
View File
@@ -65,8 +65,15 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er
result = append(result, rest[split:]...)
config := strings.Join(result, "\n")
diff := diffWithContext(result, added, 3)
return &Response{Config: config, Diff: diff}, nil
addedLines := make([]int, 0, len(added))
for i := range result {
if added[i] {
addedLines = append(addedLines, i+1)
}
}
return &Response{Config: config, Added: addedLines}, nil
}
func dedup(info *cameraInfo, cams, streams map[string]bool) *cameraInfo {
+2 -8
View File
@@ -106,12 +106,6 @@ type UIConfig struct {
}
type Response struct {
Config string `json:"config"`
Diff []DiffLine `json:"diff"`
}
type DiffLine struct {
Line int `json:"line"`
Text string `json:"text"`
Type string `json:"type"` // context, added, removed
Config string `json:"config"`
Added []int `json:"added"` // 1-based line numbers of added lines
}
+820
View File
@@ -0,0 +1,820 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - Configuration</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--purple-glow-strong: rgba(139, 92, 246, 0.5);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body { font-family: var(--font-primary); background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; min-height: 100vh; }
.screen { padding: 1.5rem; max-width: 1400px; margin: 0 auto; animation: fadeIn var(--transition-base); }
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none; color: var(--text-secondary);
font-size: 0.875rem; font-family: var(--font-primary);
cursor: pointer; padding: 0.5rem 0; margin-bottom: 1rem;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.page-title { font-size: 1.375rem; font-weight: 600; margin-bottom: 1.5rem; }
/* Mobile tabs */
.tabs { display: none; margin-bottom: 1rem; }
.tabs-row {
display: flex; gap: 0;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; padding: 3px; overflow: hidden;
}
.tab-btn {
flex: 1; padding: 0.625rem; text-align: center;
background: none; border: none; border-radius: 6px;
font-size: 0.8125rem; font-weight: 600; font-family: var(--font-primary);
color: var(--text-tertiary); cursor: pointer;
transition: all var(--transition-fast);
}
.tab-btn.active { background: var(--purple-primary); color: white; }
/* Two-column layout */
.columns { display: flex; gap: 1.5rem; align-items: flex-start; }
.col-settings { flex: 1; min-width: 0; }
.col-config { flex: 1; min-width: 0; position: sticky; top: 1.5rem; }
@media (max-width: 768px) {
.tabs { display: block; }
.columns { flex-direction: column; }
.col-settings, .col-config { width: 100%; }
.col-config { position: static; }
.col-settings.hidden, .col-config.hidden { display: none; }
}
/* Stream info */
.stream-card {
padding: 0.75rem 1rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; margin-bottom: 0.5rem;
}
.stream-label {
font-size: 0.625rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-tertiary); margin-bottom: 0.25rem;
}
.stream-val { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-secondary); word-break: break-all; }
.stream-val .scheme { color: var(--purple-light); }
.stream-meta { font-family: var(--font-mono); font-size: 0.625rem; color: var(--text-tertiary); margin-top: 0.25rem; }
.btn-add-sub {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem; background: var(--bg-tertiary);
border: 1px solid var(--border-color); border-radius: 6px;
color: var(--text-secondary); font-size: 0.75rem; font-weight: 500;
font-family: var(--font-primary); cursor: pointer;
transition: all var(--transition-fast); margin-bottom: 1.5rem;
}
.btn-add-sub:hover { border-color: var(--purple-primary); color: var(--purple-light); }
/* Form fields */
.section-divider { height: 1px; background: var(--border-color); margin: 1.5rem 0; }
.form-group { margin-bottom: 1rem; }
.field-label {
font-size: 0.75rem; font-weight: 500; color: var(--text-secondary);
margin-bottom: 0.375rem; display: flex; align-items: center; gap: 0.375rem;
}
.field-hint { font-size: 0.6875rem; color: var(--text-tertiary); margin-top: 0.25rem; }
.input {
width: 100%; padding: 0.625rem 0.75rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 6px; color: var(--text-primary);
font-size: 0.8125rem; font-family: var(--font-primary);
outline: none; transition: all var(--transition-fast);
}
.input:focus { border-color: var(--purple-primary); box-shadow: 0 0 0 2px var(--purple-glow); }
.input::placeholder { color: var(--text-tertiary); }
.input-sm { max-width: 120px; }
.input-mono { font-family: var(--font-mono); font-size: 0.75rem; }
.row { display: flex; gap: 0.75rem; }
.row > .form-group { flex: 1; }
select.input { cursor: pointer; appearance: none; padding-right: 2rem;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23606070' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.75rem center;
}
/* Toggle */
.toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0;
}
.toggle-label { font-size: 0.8125rem; color: var(--text-primary); }
.toggle {
width: 36px; height: 20px; border-radius: 10px;
background: var(--bg-tertiary); border: 1px solid var(--border-color);
cursor: pointer; position: relative; transition: all var(--transition-fast);
flex-shrink: 0;
}
.toggle.on { background: var(--purple-primary); border-color: var(--purple-primary); }
.toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%;
background: white; transition: transform var(--transition-fast);
}
.toggle.on::after { transform: translateX(16px); }
/* Expand buttons */
.expand-btn {
display: flex; align-items: center; gap: 0.5rem;
width: 100%; padding: 0.75rem 0; background: none; border: none;
color: var(--text-secondary); font-size: 0.8125rem; font-weight: 600;
font-family: var(--font-primary); cursor: pointer;
transition: color var(--transition-fast);
}
.expand-btn:hover { color: var(--purple-primary); }
.expand-btn .chevron { transition: transform var(--transition-fast); width: 12px; height: 12px; }
.expand-btn.open .chevron { transform: rotate(90deg); }
.expandable { display: none; }
.expandable.open { display: block; }
.section-title {
font-size: 0.6875rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-tertiary);
padding-bottom: 0.5rem; margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
/* Config panel */
.config-panel {
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; overflow: hidden;
}
.config-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color);
}
.config-title { font-size: 0.8125rem; font-weight: 600; }
.config-actions { display: flex; gap: 0.5rem; }
.btn-sm {
padding: 0.25rem 0.625rem; border-radius: 4px;
font-size: 0.6875rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; border: 1px solid var(--border-color);
background: var(--bg-tertiary); color: var(--text-secondary);
transition: all var(--transition-fast);
}
.btn-sm:hover { border-color: var(--purple-primary); color: var(--purple-light); }
.config-code {
padding: 1rem; font-family: var(--font-mono); font-size: 0.6875rem;
color: var(--text-secondary); line-height: 1.7;
max-height: 75vh; overflow-y: auto; white-space: pre;
}
.config-code::-webkit-scrollbar { width: 5px; }
.config-code::-webkit-scrollbar-track { background: transparent; }
.config-code::-webkit-scrollbar-thumb { background: var(--purple-primary); border-radius: 3px; }
.diff-added { color: var(--success); }
.diff-context { color: var(--text-secondary); }
.diff-line-num { color: var(--text-tertiary); display: inline-block; width: 3ch; text-align: right; margin-right: 1ch; user-select: none; opacity: 0.4; }
.config-footer {
padding: 0.75rem 1rem; border-top: 1px solid var(--border-color);
display: flex; gap: 0.5rem;
}
.save-settings { margin-top: 1.5rem; }
@media (min-width: 769px) {
.config-footer { display: none !important; }
}
@media (max-width: 768px) {
.save-settings { display: none !important; }
}
.btn-save {
flex: 1; padding: 0.625rem; border-radius: 6px;
background: var(--success); color: #000; border: none;
font-size: 0.8125rem; font-weight: 700; font-family: var(--font-primary);
cursor: pointer; transition: opacity var(--transition-fast);
}
.btn-save:hover:not(:disabled) { opacity: 0.9; }
.btn-save:disabled { opacity: 0.4; cursor: not-allowed; }
/* Notice */
.notice {
padding: 1rem; background: rgba(139, 92, 246, 0.06);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 8px; margin-bottom: 1.5rem;
}
.notice-title { font-size: 0.875rem; font-weight: 600; color: var(--purple-light); margin-bottom: 0.375rem; }
.notice-text { font-size: 0.75rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.5rem; }
.notice-code { font-family: var(--font-mono); font-size: 0.6875rem; background: var(--bg-secondary); padding: 0.375rem 0.625rem; border-radius: 4px; color: var(--purple-light); display: block; margin-top: 0.375rem; }
.notice-close { float: right; background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 0; font-size: 1.25rem; line-height: 1; }
.toast {
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 0.75rem 1.25rem; background: var(--bg-elevated);
border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: var(--shadow-lg); font-size: 0.8125rem; color: var(--text-primary);
z-index: 1000; transition: transform var(--transition-base);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body>
<div class="screen">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Back
</button>
<h2 class="page-title">Frigate Configuration</h2>
<div id="notice-area"></div>
<div class="tabs" id="tabs">
<div class="tabs-row">
<button class="tab-btn active" data-tab="settings">Settings</button>
<button class="tab-btn" data-tab="config">Config</button>
</div>
</div>
<div class="columns">
<!-- LEFT: settings -->
<div class="col-settings" id="col-settings">
<!-- streams -->
<div class="stream-card" id="main-card"></div>
<div class="stream-card" id="sub-card" style="display:none"></div>
<button class="btn-add-sub" id="btn-add-sub" style="display:none">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Add Sub Stream
</button>
<!-- Level 1: Basic -->
<div class="form-group">
<div class="field-label">Camera Name</div>
<input class="input" id="f-name" placeholder="camera_name" autocomplete="off" spellcheck="false">
</div>
<div class="section-divider"></div>
<!-- Level 2: Settings -->
<button class="expand-btn" id="btn-l2">
<svg class="chevron" viewBox="0 0 12 12" fill="none"><path d="M4.5 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Settings
</button>
<div class="expandable" id="level2">
<div class="section-title">Detect</div>
<div class="toggle-row"><span class="toggle-label">Enabled</span><div class="toggle on" id="t-detect"></div></div>
<div class="row">
<div class="form-group"><div class="field-label">FPS</div><input class="input input-sm" id="f-detect-fps" type="number" placeholder="5"></div>
<div class="form-group"><div class="field-label">Width</div><input class="input input-sm" id="f-detect-w" type="number" placeholder="auto"></div>
<div class="form-group"><div class="field-label">Height</div><input class="input input-sm" id="f-detect-h" type="number" placeholder="auto"></div>
</div>
<div class="section-title">Record</div>
<div class="toggle-row"><span class="toggle-label">Enabled</span><div class="toggle on" id="t-record"></div></div>
<div class="section-title">Objects</div>
<div class="form-group">
<input class="input" id="f-objects" value="person" placeholder="person, car, dog" autocomplete="off">
<div class="field-hint">Comma-separated list of objects to track</div>
</div>
<div class="section-title">Motion</div>
<div class="toggle-row"><span class="toggle-label">Enabled</span><div class="toggle" id="t-motion"></div></div>
<div class="row">
<div class="form-group"><div class="field-label">Threshold</div><input class="input input-sm" id="f-motion-thresh" type="number" placeholder="30"></div>
<div class="form-group"><div class="field-label">Contour Area</div><input class="input input-sm" id="f-motion-contour" type="number" placeholder="10"></div>
</div>
<div class="section-title">Other</div>
<div class="toggle-row"><span class="toggle-label">Snapshots</span><div class="toggle" id="t-snapshots"></div></div>
<div class="toggle-row"><span class="toggle-label">Audio Detection</span><div class="toggle" id="t-audio"></div></div>
<div class="form-group" id="audio-filters-group" style="display:none">
<div class="field-label">Audio Filters</div>
<input class="input" id="f-audio-filters" placeholder="bark, speech, fire_alarm" autocomplete="off">
</div>
<div class="toggle-row"><span class="toggle-label">Notifications</span><div class="toggle" id="t-notifications"></div></div>
<div class="section-divider"></div>
<!-- Level 3: Advanced -->
<button class="expand-btn" id="btn-l3">
<svg class="chevron" viewBox="0 0 12 12" fill="none"><path d="M4.5 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Advanced
</button>
<div class="expandable" id="level3">
<div class="section-title">FFmpeg</div>
<div class="row">
<div class="form-group"><div class="field-label">HW Accel</div>
<select class="input" id="f-hwaccel"><option value="">auto</option><option value="preset-vaapi">VAAPI</option><option value="preset-nvidia-h264">NVIDIA H264</option><option value="preset-nvidia-h265">NVIDIA H265</option><option value="preset-rpi-64-h264">RPi 64 H264</option></select>
</div>
<div class="form-group"><div class="field-label">GPU</div><input class="input input-sm" id="f-gpu" type="number" placeholder="0"></div>
</div>
<div class="section-title">Live View</div>
<div class="row">
<div class="form-group"><div class="field-label">Height</div><input class="input input-sm" id="f-live-h" type="number" placeholder="auto"></div>
<div class="form-group"><div class="field-label">Quality (1-31)</div><input class="input input-sm" id="f-live-q" type="number" placeholder="8"></div>
</div>
<div class="section-title">Birdseye</div>
<div class="toggle-row"><span class="toggle-label">Enabled</span><div class="toggle" id="t-birdseye"></div></div>
<div class="form-group"><div class="field-label">Mode</div>
<select class="input" id="f-birdseye-mode"><option value="">default</option><option value="continuous">continuous</option><option value="motion">motion</option><option value="objects">objects</option></select>
</div>
<div class="section-title">ONVIF</div>
<div class="row">
<div class="form-group"><div class="field-label">Host</div><input class="input" id="f-onvif-host" placeholder="" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Port</div><input class="input input-sm" id="f-onvif-port" type="number" placeholder="80"></div>
</div>
<div class="row">
<div class="form-group"><div class="field-label">User</div><input class="input" id="f-onvif-user" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Password</div><input class="input" id="f-onvif-pass" autocomplete="off"></div>
</div>
<div class="toggle-row"><span class="toggle-label">Auto-tracking</span><div class="toggle" id="t-onvif-track"></div></div>
<div class="section-title">PTZ</div>
<div class="toggle-row"><span class="toggle-label">Enabled</span><div class="toggle" id="t-ptz"></div></div>
<div class="section-title">UI</div>
<div class="row">
<div class="form-group"><div class="field-label">Order</div><input class="input input-sm" id="f-ui-order" type="number" placeholder="0"></div>
</div>
<div class="toggle-row"><span class="toggle-label">Dashboard</span><div class="toggle on" id="t-ui-dash"></div></div>
<div class="section-title">Go2RTC Overrides</div>
<div class="row">
<div class="form-group"><div class="field-label">Main Stream Name</div><input class="input input-mono" id="f-g2r-main-name" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Sub Stream Name</div><input class="input input-mono" id="f-g2r-sub-name" autocomplete="off"></div>
</div>
<div class="form-group"><div class="field-label">Main Source Override</div><input class="input input-mono" id="f-g2r-main-src" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Sub Source Override</div><input class="input input-mono" id="f-g2r-sub-src" autocomplete="off"></div>
<div class="section-title">Frigate Overrides</div>
<div class="form-group"><div class="field-label">Main Path</div><input class="input input-mono" id="f-fri-main-path" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Sub Path</div><input class="input input-mono" id="f-fri-sub-path" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Main Input Args</div><input class="input input-mono" id="f-fri-main-args" autocomplete="off"></div>
<div class="form-group"><div class="field-label">Sub Input Args</div><input class="input input-mono" id="f-fri-sub-args" autocomplete="off"></div>
</div>
</div>
<div class="save-settings" id="save-settings" style="display:none">
<button class="btn-save" id="btn-save-settings" style="width:100%">Save to Frigate & Restart</button>
</div>
</div>
<!-- RIGHT: config -->
<div class="col-config" id="col-config">
<div class="config-panel">
<div class="config-header">
<span class="config-title">Generated Config</span>
<div class="config-actions">
<button class="btn-sm" id="btn-copy">Copy</button>
<button class="btn-sm" id="btn-download">Download</button>
</div>
</div>
<div class="config-code" id="config-code">Loading...</div>
<div class="config-footer" id="config-footer" style="display:none">
<button class="btn-save" id="btn-save">Save to Frigate & Restart</button>
</div>
</div>
</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script>
var params = new URLSearchParams(location.search);
var mainStream = params.get('main') || '';
var subStream = params.get('sub') || '';
var sessionId = params.get('session') || '';
var ip = params.get('ip') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
var model = params.get('model') || '';
var server = params.get('server') || '';
var hostname = params.get('hostname') || '';
var ports = params.get('ports') || '';
var userParam = params.get('user') || '';
var channel = params.get('channel') || '';
var ids = params.get('ids') || '';
var mainWidth = params.get('main_width') || '';
var mainHeight = params.get('main_height') || '';
var frigateConnected = false;
var existingConfig = '';
var generatedConfig = '';
var addedLines = {};
var abortCtrl = null;
var debounceTimer = null;
// -- init --
document.getElementById('btn-back').addEventListener('click', function() { history.back(); });
// stream cards
renderStreamCard('main-card', 'Main Stream', mainStream, mainWidth, mainHeight);
if (subStream) {
renderStreamCard('sub-card', 'Sub Stream', subStream, '', '');
document.getElementById('sub-card').style.display = '';
}
if (!subStream && sessionId) document.getElementById('btn-add-sub').style.display = '';
document.getElementById('btn-add-sub').addEventListener('click', function() {
var p = new URLSearchParams();
p.set('id', sessionId); p.set('mode', 'sub'); p.set('main', mainStream);
['ip','mac','vendor','model','server','hostname','ports','user','channel','ids'].forEach(function(k) {
if (params.get(k)) p.set(k, params.get(k));
});
window.location.href = 'test.html?' + p.toString();
});
// default name from IP
var defaultName = ip ? 'camera_' + ip.replace(/\./g, '_') : 'camera';
document.getElementById('f-name').value = defaultName;
// prefill ONVIF from probe
if (ip) document.getElementById('f-onvif-host').value = ip;
if (userParam) document.getElementById('f-onvif-user').value = userParam;
// -- tabs (mobile) --
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
var tab = btn.dataset.tab;
document.getElementById('col-settings').classList.toggle('hidden', tab !== 'settings');
document.getElementById('col-config').classList.toggle('hidden', tab !== 'config');
});
});
// -- expand buttons --
['btn-l2', 'btn-l3'].forEach(function(id) {
document.getElementById(id).addEventListener('click', function() {
this.classList.toggle('open');
var target = id === 'btn-l2' ? 'level2' : 'level3';
document.getElementById(target).classList.toggle('open');
});
});
// -- toggles --
document.querySelectorAll('.toggle').forEach(function(el) {
el.addEventListener('click', function() {
el.classList.toggle('on');
if (el.id === 't-audio') {
document.getElementById('audio-filters-group').style.display = el.classList.contains('on') ? '' : 'none';
}
scheduleGenerate(0);
});
});
// -- inputs: debounced regeneration --
document.querySelectorAll('.input').forEach(function(el) {
el.addEventListener('input', function() { scheduleGenerate(300); });
});
document.querySelectorAll('select.input').forEach(function(el) {
el.addEventListener('change', function() { scheduleGenerate(0); });
});
// -- copy / download --
document.getElementById('btn-copy').addEventListener('click', function() {
var ta = document.createElement('textarea');
ta.value = generatedConfig; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
showToast('Copied');
});
document.getElementById('btn-download').addEventListener('click', function() {
var blob = new Blob([generatedConfig], { type: 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = 'frigate-config.yaml'; a.click();
URL.revokeObjectURL(url);
});
// -- save --
document.getElementById('btn-save').addEventListener('click', function() { saveFrigate(); });
document.getElementById('btn-save-settings').addEventListener('click', function() { saveFrigate(); });
// -- start: load frigate config then generate --
loadFrigate();
async function loadFrigate() {
try {
var r = await fetch('api/frigate/config');
var data = await r.json();
if (data.connected && data.config) {
frigateConnected = true;
existingConfig = data.config;
document.getElementById('config-footer').style.display = '';
document.getElementById('save-settings').style.display = '';
} else {
navigateGo2rtc();
return;
}
} catch (e) {
navigateGo2rtc();
return;
}
generate();
}
function scheduleGenerate(delay) {
clearTimeout(debounceTimer);
if (delay <= 0) { generate(); return; }
debounceTimer = setTimeout(generate, delay);
}
function buildRequest() {
var req = { mainStream: mainStream };
if (subStream) req.subStream = subStream;
if (existingConfig) req.existingConfig = existingConfig;
var name = val('f-name');
if (name && name !== defaultName) req.name = name;
// objects
var obj = val('f-objects');
if (obj && obj !== 'person') {
req.objects = obj.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
// detect
if (tog('t-detect') || ival('f-detect-fps') || ival('f-detect-w') || ival('f-detect-h')) {
req.detect = { enabled: tog('t-detect') };
if (ival('f-detect-fps')) req.detect.fps = ival('f-detect-fps');
if (ival('f-detect-w')) req.detect.width = ival('f-detect-w');
if (ival('f-detect-h')) req.detect.height = ival('f-detect-h');
}
// record
if (!tog('t-record')) req.record = { enabled: false };
// motion
if (tog('t-motion')) {
req.motion = { enabled: true };
if (ival('f-motion-thresh')) req.motion.threshold = ival('f-motion-thresh');
if (ival('f-motion-contour')) req.motion.contour_area = ival('f-motion-contour');
}
// snapshots
if (tog('t-snapshots')) req.snapshots = { enabled: true };
// audio
if (tog('t-audio')) {
req.audio = { enabled: true };
var filters = val('f-audio-filters');
if (filters) req.audio.filters = filters.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
// notifications
if (tog('t-notifications')) req.notifications = { enabled: true };
// ffmpeg
var hw = val('f-hwaccel'), gpu = ival('f-gpu');
if (hw || gpu) { req.ffmpeg = {}; if (hw) req.ffmpeg.hwaccel = hw; if (gpu) req.ffmpeg.gpu = gpu; }
// live
var lh = ival('f-live-h'), lq = ival('f-live-q');
if (lh || lq) { req.live = {}; if (lh) req.live.height = lh; if (lq) req.live.quality = lq; }
// birdseye
if (tog('t-birdseye')) {
req.birdseye = { enabled: true };
var bm = val('f-birdseye-mode'); if (bm) req.birdseye.mode = bm;
}
// onvif
var oh = val('f-onvif-host');
if (oh) {
req.onvif = { host: oh };
var op = ival('f-onvif-port'); if (op) req.onvif.port = op;
var ou = val('f-onvif-user'); if (ou) req.onvif.user = ou;
var opass = val('f-onvif-pass'); if (opass) req.onvif.password = opass;
if (tog('t-onvif-track')) req.onvif.autotracking = true;
}
// ptz
if (tog('t-ptz')) req.ptz = { enabled: true };
// ui
var uiOrd = ival('f-ui-order');
if (uiOrd || !tog('t-ui-dash')) {
req.ui = {};
if (uiOrd) req.ui.order = uiOrd;
if (!tog('t-ui-dash')) req.ui.dashboard = false;
}
// go2rtc overrides
var g2mn = val('f-g2r-main-name'), g2sn = val('f-g2r-sub-name'), g2ms = val('f-g2r-main-src'), g2ss = val('f-g2r-sub-src');
if (g2mn || g2sn || g2ms || g2ss) {
req.go2rtc = {};
if (g2mn) req.go2rtc.mainStreamName = g2mn;
if (g2sn) req.go2rtc.subStreamName = g2sn;
if (g2ms) req.go2rtc.mainStreamSource = g2ms;
if (g2ss) req.go2rtc.subStreamSource = g2ss;
}
// frigate overrides
var fmp = val('f-fri-main-path'), fsp = val('f-fri-sub-path'), fma = val('f-fri-main-args'), fsa = val('f-fri-sub-args');
if (fmp || fsp || fma || fsa) {
req.frigate = {};
if (fmp) req.frigate.mainStreamPath = fmp;
if (fsp) req.frigate.subStreamPath = fsp;
if (fma) req.frigate.mainStreamInputArgs = fma;
if (fsa) req.frigate.subStreamInputArgs = fsa;
}
return req;
}
async function generate() {
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
try {
var r = await fetch('api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildRequest()),
signal: abortCtrl.signal
});
if (!r.ok) { renderCode('Error: ' + await r.text()); return; }
var data = await r.json();
generatedConfig = data.config;
addedLines = {};
if (data.added) data.added.forEach(function(n) { addedLines[n] = true; });
renderConfigView();
} catch (e) {
if (e.name !== 'AbortError') renderCode('Error: ' + e.message);
}
}
function renderConfigView() {
var el = document.getElementById('config-code');
el.textContent = '';
var lines = generatedConfig.split('\n');
lines.forEach(function(text, i) {
var row = document.createElement('div');
var num = document.createElement('span');
num.className = 'diff-line-num';
num.textContent = i + 1;
row.appendChild(num);
var span = document.createElement('span');
span.className = addedLines[i + 1] ? 'diff-added' : 'diff-context';
span.textContent = text;
row.appendChild(span);
el.appendChild(row);
});
}
function renderCode(text) {
var el = document.getElementById('config-code');
el.textContent = text;
}
function allSaveBtns() { return [document.getElementById('btn-save'), document.getElementById('btn-save-settings')]; }
async function saveFrigate() {
var btns = allSaveBtns();
btns.forEach(function(b) { b.disabled = true; b.textContent = 'Saving...'; });
try {
var r = await fetch('api/frigate/config/save?save_option=restart', {
method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: generatedConfig
});
var data = await r.json();
if (data.success) {
btns.forEach(function(b) { b.textContent = 'Saved'; });
var seconds = 60;
function tick() {
if (seconds > 0) { showToast('Frigate is restarting... ' + seconds + 's', 1500); seconds--; setTimeout(tick, 1000); }
else showToast('Done! Open Frigate to see your camera.', 10000);
}
tick();
} else {
showToast(data.message || 'Save failed');
btns.forEach(function(b) { b.disabled = false; b.textContent = 'Save to Frigate & Restart'; });
}
} catch (e) {
showToast('Error: ' + e.message);
btns.forEach(function(b) { b.disabled = false; b.textContent = 'Save to Frigate & Restart'; });
}
}
function navigateGo2rtc() {
var p = new URLSearchParams();
if (mainStream) p.set('main', mainStream);
if (subStream) p.set('sub', subStream);
if (ip) p.set('ip', ip);
if (sessionId) p.set('session', sessionId);
window.location.href = 'go2rtc.html?' + p.toString();
}
function showNotice() {
var area = document.getElementById('notice-area');
area.textContent = '';
var n = document.createElement('div'); n.className = 'notice';
var close = document.createElement('button'); close.className = 'notice-close'; close.textContent = 'x';
close.addEventListener('click', function() { n.remove(); }); n.appendChild(close);
var t = document.createElement('div'); t.className = 'notice-title'; t.textContent = 'Frigate NVR not detected'; n.appendChild(t);
var p1 = document.createElement('div'); p1.className = 'notice-text';
p1.textContent = 'If you have Frigate NVR, we recommend running Strix on the same server for automatic config management.'; n.appendChild(p1);
var p2 = document.createElement('div'); p2.className = 'notice-text'; p2.textContent = 'Or set the Frigate URL:';
var code = document.createElement('code'); code.className = 'notice-code'; code.textContent = 'STRIX_FRIGATE_URL=http://frigate:5000';
p2.appendChild(code); n.appendChild(p2);
// go2rtc link
var p3 = document.createElement('div'); p3.className = 'notice-text'; p3.style.marginTop = '0.75rem';
p3.textContent = 'You can also add streams directly to go2rtc:';
var g2link = document.createElement('a');
var g2p = new URLSearchParams();
if (mainStream) g2p.set('main', mainStream);
if (subStream) g2p.set('sub', subStream);
if (ip) g2p.set('ip', ip);
g2link.href = 'go2rtc.html?' + g2p.toString();
g2link.style.cssText = 'display:inline-flex; align-items:center; gap:0.375rem; margin-top:0.5rem; padding:0.5rem 0.75rem; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:6px; color:var(--purple-light); text-decoration:none; font-size:0.8125rem; font-weight:600; transition:all 150ms;';
g2link.textContent = 'Add to go2rtc instead';
g2link.onmouseover = function() { g2link.style.borderColor = 'var(--purple-primary)'; };
g2link.onmouseout = function() { g2link.style.borderColor = 'var(--border-color)'; };
p3.appendChild(document.createElement('br'));
p3.appendChild(g2link);
n.appendChild(p3);
area.appendChild(n);
}
function renderStreamCard(id, label, url, w, h) {
var card = document.getElementById(id);
var lbl = document.createElement('div'); lbl.className = 'stream-label'; lbl.textContent = label; card.appendChild(lbl);
var v = document.createElement('div'); v.className = 'stream-val';
var i = url.indexOf('://');
if (i > 0) { var s = document.createElement('span'); s.className = 'scheme'; s.textContent = url.substring(0, i + 3); v.appendChild(s); v.appendChild(document.createTextNode(url.substring(i + 3))); }
else v.textContent = url;
card.appendChild(v);
if (w && h) { var m = document.createElement('div'); m.className = 'stream-meta'; m.textContent = w + 'x' + h; card.appendChild(m); }
}
// helpers
function val(id) { return document.getElementById(id).value.trim(); }
function ival(id) { return parseInt(val(id)) || 0; }
function tog(id) { return document.getElementById(id).classList.contains('on'); }
var toastTimer = null;
function showToast(msg, dur) {
var t = document.getElementById('toast'); t.textContent = msg;
t.classList.remove('hidden'); t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.classList.add('hidden'); }, 250); }, dur || 3000);
}
</script>
</body>
</html>
+98 -1
View File
@@ -359,7 +359,11 @@
var data = await r.json();
dbStreams = data.streams || [];
renderAll();
if (dbStreams.length === 0 && customStreams.length === 0) {
renderEmpty();
} else {
renderAll();
}
} catch (e) {
renderError('Connection error: ' + e.message);
}
@@ -518,6 +522,99 @@
content.appendChild(div);
}
function renderEmpty() {
var content = document.getElementById('content');
while (content.firstChild) content.removeChild(content.firstChild);
var box = document.createElement('div');
box.style.cssText = 'text-align:center; padding:2.5rem 1.5rem; background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:8px;';
var title = document.createElement('div');
title.style.cssText = 'font-size:1.125rem; font-weight:600; color:var(--text-primary); margin-bottom:0.75rem;';
title.textContent = 'No streams found for this camera';
var msg = document.createElement('div');
msg.style.cssText = 'font-size:0.875rem; color:var(--text-secondary); line-height:1.6; margin-bottom:1.5rem;';
msg.textContent = 'The Strix database does not have stream patterns for this device yet. You can help by adding your camera model to the database.';
var link = document.createElement('a');
var p = new URLSearchParams();
if (vendor) p.set('brand', vendor);
if (model) p.set('model', model);
if (mac && mac.length >= 8) p.set('mac_prefix', mac.substring(0, 8));
link.href = 'https://gostrix.github.io/#/contribute?' + p.toString();
link.target = '_blank';
link.style.cssText = 'display:inline-flex; align-items:center; gap:0.5rem; padding:0.75rem 1.25rem; background:var(--purple-primary); color:white; border-radius:8px; text-decoration:none; font-size:0.875rem; font-weight:600; transition:opacity 150ms;';
link.textContent = 'Add your camera to Strix DB';
link.onmouseover = function() { link.style.opacity = '0.85'; };
link.onmouseout = function() { link.style.opacity = '1'; };
var issueLink = document.createElement('a');
issueLink.href = 'https://github.com/eduard256/Strix/issues';
issueLink.target = '_blank';
issueLink.style.cssText = 'display:inline-flex; align-items:center; gap:0.5rem; padding:0.75rem 1.25rem; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border-color); border-radius:8px; text-decoration:none; font-size:0.875rem; font-weight:600; transition:all 150ms;';
issueLink.textContent = 'Report Issue';
issueLink.onmouseover = function() { issueLink.style.borderColor = 'var(--purple-primary)'; issueLink.style.color = 'var(--purple-light)'; };
issueLink.onmouseout = function() { issueLink.style.borderColor = 'var(--border-color)'; issueLink.style.color = 'var(--text-secondary)'; };
var btns = document.createElement('div');
btns.style.cssText = 'display:flex; gap:0.75rem; justify-content:center; flex-wrap:wrap;';
btns.appendChild(link);
btns.appendChild(issueLink);
var or = document.createElement('div');
or.style.cssText = 'margin:1.25rem 0; color:var(--text-tertiary); font-size:0.75rem;';
or.textContent = 'or add a custom stream URL below';
box.appendChild(title);
box.appendChild(msg);
box.appendChild(btns);
box.appendChild(or);
content.appendChild(box);
// still show add section below
renderAddSection(content);
updateButton();
}
function renderAddSection(content) {
var addSection = document.createElement('div');
addSection.className = 'add-section';
var addRow = document.createElement('div');
addRow.className = 'add-row';
var addInput = document.createElement('input');
addInput.className = 'add-input';
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
addInput.value = pendingInput;
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.type = 'button';
addBtn.textContent = '+';
function addCustom() {
var v = addInput.value.trim();
if (!v) return;
if (v.indexOf('://') === -1) { showToast('URL must include protocol (rtsp://, http://, bubble://, ...)'); return; }
if (customStreams.indexOf(v) !== -1 || dbStreams.indexOf(v) !== -1) { showToast('This URL is already in the list'); return; }
customStreams.push(v);
pendingInput = '';
renderAll();
var newInput = document.querySelector('.add-input');
if (newInput) newInput.focus();
showContributeBanner(v);
}
addBtn.addEventListener('click', addCustom);
addInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') addCustom(); });
addInput.addEventListener('input', function() { pendingInput = addInput.value; });
addRow.appendChild(addInput);
addRow.appendChild(addBtn);
addSection.appendChild(addRow);
content.appendChild(addSection);
}
// test button
document.getElementById('btn-test').addEventListener('click', startTest);
+344
View File
@@ -0,0 +1,344 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - Add to go2rtc</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--purple-glow-strong: rgba(139, 92, 246, 0.5);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--error: #ef4444;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body { font-family: var(--font-primary); background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; min-height: 100vh; }
.screen { padding: 1.5rem; animation: fadeIn var(--transition-base); }
.container { max-width: 600px; margin: 0 auto; width: 100%; }
@media (min-width: 768px) { .screen { padding: 3rem 1.5rem; } }
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none; color: var(--text-secondary);
font-size: 0.875rem; font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0; margin-bottom: 1.5rem;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.page-title { font-size: 1.375rem; font-weight: 600; margin-bottom: 2rem; }
.stream-card {
padding: 1rem 1.25rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; margin-bottom: 1.5rem;
}
.stream-label {
font-size: 0.625rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-tertiary); margin-bottom: 0.375rem;
}
.stream-url {
font-family: var(--font-mono); font-size: 0.75rem;
color: var(--text-secondary); word-break: break-all;
}
.stream-url .scheme { color: var(--purple-light); }
.form-group { margin-bottom: 1.25rem; }
.field-label {
font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.field-hint { font-size: 0.6875rem; color: var(--text-tertiary); margin-top: 0.25rem; }
.input {
width: 100%; padding: 0.875rem 1rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; color: var(--text-primary);
font-size: 0.9375rem; font-family: var(--font-primary);
outline: none; transition: all var(--transition-fast);
}
.input:focus { border-color: var(--purple-primary); box-shadow: 0 0 0 3px var(--purple-glow); }
.input::placeholder { color: var(--text-tertiary); }
.input-mono { font-family: var(--font-mono); font-size: 0.8125rem; }
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
border: none; outline: none; width: 100%;
}
.btn-primary {
background: linear-gradient(135deg, var(--purple-primary), var(--purple-dark));
color: white; box-shadow: 0 4px 12px var(--purple-glow);
}
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 8px 20px var(--purple-glow-strong); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.result {
margin-top: 1.25rem; padding: 1rem;
border-radius: 8px; font-size: 0.875rem;
}
.result-ok { background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); color: var(--success); }
.result-err { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); color: var(--error); }
.result-ok a {
color: var(--purple-light); text-decoration: none;
font-weight: 600;
}
.result-ok a:hover { text-decoration: underline; }
.btn-add-sub {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem; background: var(--bg-tertiary);
border: 1px solid var(--border-color); border-radius: 6px;
color: var(--text-secondary); font-size: 0.75rem; font-weight: 500;
font-family: var(--font-primary); cursor: pointer;
transition: all var(--transition-fast); margin-bottom: 0.75rem;
}
.btn-add-sub:hover { border-color: var(--purple-primary); color: var(--purple-light); }
.section-divider { height: 1px; background: var(--border-color); margin: 1.5rem 0; }
.toast {
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 0.75rem 1.25rem; background: var(--bg-elevated);
border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: var(--shadow-lg); font-size: 0.8125rem; color: var(--text-primary);
z-index: 1000; transition: transform var(--transition-base);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body>
<div class="screen">
<div class="container">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Back
</button>
<h2 class="page-title">Add to go2rtc</h2>
<!-- Main stream -->
<div id="main-card" class="stream-card"></div>
<!-- Sub stream -->
<div id="sub-card" class="stream-card" style="display:none"></div>
<button class="btn-add-sub" id="btn-add-sub" style="display:none">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Add Sub Stream
</button>
<div class="section-divider"></div>
<!-- Main stream name -->
<div class="form-group">
<div class="field-label">Main Stream Name</div>
<input class="input input-mono" id="f-main-name" autocomplete="off" spellcheck="false">
<div class="field-hint">Name used in go2rtc config and Frigate</div>
</div>
<!-- Sub stream name -->
<div class="form-group" id="sub-name-group" style="display:none">
<div class="field-label">Sub Stream Name</div>
<input class="input input-mono" id="f-sub-name" autocomplete="off" spellcheck="false">
</div>
<button class="btn btn-primary" id="btn-add">Add Streams to go2rtc</button>
<div id="result-area"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script>
var params = new URLSearchParams(location.search);
var mainStream = params.get('main') || '';
var subStream = params.get('sub') || '';
var ip = params.get('ip') || '';
var sessionId = params.get('session') || '';
// default names from IP
var sanitized = ip ? ip.replace(/\./g, '_') : 'camera';
document.getElementById('f-main-name').value = sanitized + '_main';
if (subStream) document.getElementById('f-sub-name').value = sanitized + '_sub';
// render stream cards
renderCard('main-card', 'Main Stream', mainStream);
if (subStream) {
renderCard('sub-card', 'Sub Stream', subStream);
document.getElementById('sub-card').style.display = '';
document.getElementById('sub-name-group').style.display = '';
}
// add sub stream button
if (!subStream && sessionId) {
document.getElementById('btn-add-sub').style.display = 'inline-flex';
}
document.getElementById('btn-add-sub').addEventListener('click', function() {
var p = new URLSearchParams();
p.set('id', sessionId);
p.set('mode', 'sub');
p.set('main', mainStream);
if (ip) p.set('ip', ip);
window.location.href = 'test.html?' + p.toString();
});
// check go2rtc availability -- if not found, redirect to urls.html
checkGo2rtc();
async function checkGo2rtc() {
try {
// try adding with empty name to test connectivity
var r = await fetch('api/go2rtc/streams?name=_probe&src=_probe', { method: 'PUT' });
var data = await r.json();
// if go2rtc not found, redirect
if (!data.success && data.error && data.error.indexOf('not found') >= 0) {
navigateUrls();
}
} catch (e) {
navigateUrls();
}
}
function navigateUrls() {
var p = new URLSearchParams();
if (mainStream) p.set('main', mainStream);
if (subStream) p.set('sub', subStream);
if (ip) p.set('ip', ip);
if (sessionId) p.set('session', sessionId);
window.location.href = 'urls.html?' + p.toString();
}
// back
document.getElementById('btn-back').addEventListener('click', function() { history.back(); });
// add
document.getElementById('btn-add').addEventListener('click', addStreams);
async function addStreams() {
var btn = document.getElementById('btn-add');
var mainName = document.getElementById('f-main-name').value.trim();
if (!mainName) { showToast('Main stream name is required'); return; }
btn.disabled = true;
btn.textContent = 'Adding...';
var results = [];
// add main
var r1 = await addOne(mainName, mainStream);
results.push({ name: mainName, success: r1.success, error: r1.error });
// add sub
if (subStream) {
var subName = document.getElementById('f-sub-name').value.trim();
if (subName) {
var r2 = await addOne(subName, subStream);
results.push({ name: subName, success: r2.success, error: r2.error });
}
}
// render results
var area = document.getElementById('result-area');
area.textContent = '';
var allOk = results.every(function(r) { return r.success; });
if (allOk) {
var div = document.createElement('div');
div.className = 'result result-ok';
var lines = results.map(function(r) { return r.name; });
div.textContent = 'Added to go2rtc: ' + lines.join(', ') + '. ';
var link = document.createElement('a');
link.href = 'http://' + location.hostname + ':1984/';
link.target = '_blank';
link.textContent = 'Open go2rtc UI';
div.appendChild(link);
area.appendChild(div);
btn.textContent = 'Added';
} else {
results.forEach(function(r) {
if (!r.success) {
var div = document.createElement('div');
div.className = 'result result-err';
div.textContent = r.name + ': ' + (r.error || 'Unknown error');
area.appendChild(div);
}
});
btn.disabled = false;
btn.textContent = 'Add Streams to go2rtc';
}
}
async function addOne(name, src) {
try {
var url = 'api/go2rtc/streams?name=' + encodeURIComponent(name) + '&src=' + encodeURIComponent(src);
var r = await fetch(url, { method: 'PUT' });
return await r.json();
} catch (e) {
return { success: false, error: e.message };
}
}
function renderCard(id, label, url) {
var card = document.getElementById(id);
var lbl = document.createElement('div'); lbl.className = 'stream-label'; lbl.textContent = label;
card.appendChild(lbl);
var v = document.createElement('div'); v.className = 'stream-url';
var i = url.indexOf('://');
if (i > 0) {
var s = document.createElement('span'); s.className = 'scheme'; s.textContent = url.substring(0, i + 3);
v.appendChild(s); v.appendChild(document.createTextNode(url.substring(i + 3)));
} else {
v.textContent = url;
}
card.appendChild(v);
}
function showToast(msg) {
var t = document.getElementById('toast'); t.textContent = msg;
t.classList.remove('hidden'); t.classList.add('show');
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.classList.add('hidden'); }, 250); }, 3000);
}
</script>
</body>
</html>
+687
View File
@@ -0,0 +1,687 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - Stream Testing</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--purple-glow-strong: rgba(139, 92, 246, 0.5);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font-primary);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.screen { padding: 1.5rem; animation: fadeIn var(--transition-base); }
.container { max-width: 1200px; margin: 0 auto; width: 100%; }
@media (min-width: 768px) { .screen { padding: 2rem; } }
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none;
color: var(--text-secondary); font-size: 0.875rem;
font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0; margin-bottom: 1.5rem;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.header-row {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 1rem;
margin-bottom: 1.5rem;
}
.screen-title { font-size: 1.5rem; font-weight: 600; }
.screen-title .mode-badge {
font-size: 0.75rem; font-weight: 600;
padding: 0.25rem 0.625rem; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.05em;
background: rgba(139, 92, 246, 0.15); color: var(--purple-light);
margin-left: 0.75rem; vertical-align: middle;
}
.btn-stop {
padding: 0.5rem 1rem; border-radius: 6px;
font-size: 0.75rem; font-weight: 600; font-family: var(--font-primary);
text-transform: uppercase; letter-spacing: 0.04em;
background: rgba(239, 68, 68, 0.12); color: var(--error);
border: 1px solid rgba(239, 68, 68, 0.25);
cursor: pointer; transition: all var(--transition-fast);
}
.btn-stop:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); }
.btn-stop:disabled { opacity: 0.3; cursor: not-allowed; }
/* Status bar */
.status-bar {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.status-top {
display: flex; align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.status-badge {
display: inline-flex; align-items: center; gap: 0.375rem;
font-family: var(--font-mono); font-size: 0.6875rem;
font-weight: 500; text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.25rem 0.625rem; border-radius: 3px;
}
.status-badge.running { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.status-badge.done { background: rgba(16, 185, 129, 0.12); color: var(--success); }
.status-dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.status-badge.running .status-dot { animation: pulse 1.2s infinite; }
.status-session { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-tertiary); }
.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
@media (max-width: 640px) { .counters { grid-template-columns: repeat(2, 1fr); } }
.counter {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.75rem; text-align: center;
}
.counter-value {
font-family: var(--font-mono); font-size: 1.5rem;
font-weight: 600; line-height: 1; margin-bottom: 0.25rem;
}
.counter-label {
font-family: var(--font-mono); font-size: 0.625rem;
color: var(--text-secondary); text-transform: uppercase;
letter-spacing: 0.06em;
}
.counter.total .counter-value { color: var(--text-primary); }
.counter.tested .counter-value { color: #3b82f6; }
.counter.alive .counter-value { color: var(--success); }
.counter.screenshots .counter-value { color: var(--warning); }
.progress-track {
height: 3px; background: var(--border-color);
border-radius: 2px; margin-top: 1rem; overflow: hidden;
}
.progress-fill {
height: 100%; background: #3b82f6;
border-radius: 2px; width: 0%;
transition: width 0.3s ease;
}
.progress-fill.complete { background: var(--success); }
/* Groups */
.group { margin-bottom: 1.5rem; }
.group-header {
display: flex; align-items: center; gap: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1rem;
cursor: pointer; user-select: none;
transition: color var(--transition-fast);
}
.group-header:hover { color: var(--purple-primary); }
.group-toggle {
display: flex; background: none; border: none;
padding: 0; cursor: pointer; color: var(--text-tertiary);
}
.group-toggle .chevron { transition: transform var(--transition-fast); }
.group.collapsed .group-toggle .chevron { transform: rotate(-90deg); }
.group.collapsed .group-content { display: none; }
.group-title {
font-size: 0.8125rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
}
.group-count { font-size: 0.8125rem; color: var(--text-tertiary); }
/* Cards grid */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
@media (max-width: 640px) { .cards-grid { grid-template-columns: 1fr; } }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
transition: border-color var(--transition-fast), transform var(--transition-fast);
animation: cardIn 0.25s ease;
}
.card:hover { border-color: var(--purple-primary); transform: translateY(-2px); }
.card-thumb {
position: relative; width: 100%;
aspect-ratio: 16/9;
background: var(--bg-primary); overflow: hidden;
}
.card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-thumb-placeholder {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.card-thumb-placeholder svg { width: 32px; height: 32px; color: var(--text-tertiary); }
.card-overlay { position: absolute; top: 0.5rem; right: 0.5rem; display: flex; gap: 0.25rem; }
.card-tag {
font-family: var(--font-mono); font-size: 0.625rem;
font-weight: 500; padding: 0.125rem 0.375rem; border-radius: 2px;
background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(4px);
color: var(--text-primary); border: 1px solid rgba(255, 255, 255, 0.08);
}
.card-resolution {
position: absolute; bottom: 0.5rem; left: 0.5rem;
font-family: var(--font-mono); font-size: 0.625rem;
font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 2px;
background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(4px);
color: var(--purple-light);
}
.card-body { padding: 0.75rem 1rem; }
.card-url {
font-family: var(--font-mono); font-size: 0.6875rem;
color: var(--text-secondary); line-height: 1.5;
word-break: break-all;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
}
.card-url .scheme { color: var(--purple-light); }
.card-meta {
display: flex; align-items: center; gap: 0.75rem;
margin-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.card-meta-item {
font-family: var(--font-mono); font-size: 0.625rem;
color: var(--text-tertiary); display: flex;
align-items: center; gap: 0.25rem;
}
.card-meta-item svg { width: 12px; height: 12px; }
.latency-fast { color: var(--success); }
.latency-medium { color: var(--warning); }
.latency-slow { color: var(--error); }
.card-action { padding: 0 1rem 0.75rem; }
.btn-use {
width: 100%; padding: 0.5rem;
background: linear-gradient(135deg, var(--purple-primary), var(--purple-dark));
color: white; border: none; border-radius: 6px;
font-size: 0.75rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
box-shadow: 0 2px 8px var(--purple-glow);
}
.btn-use:hover { box-shadow: 0 4px 16px var(--purple-glow-strong); transform: translateY(-1px); }
.empty-state {
text-align: center; padding: 3rem;
color: var(--text-tertiary); font-size: 0.875rem;
}
.toast {
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 1rem 1.5rem;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 8px; box-shadow: var(--shadow-lg);
font-size: 0.875rem; color: var(--text-primary);
z-index: 1000; transition: transform var(--transition-base);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
</style>
</head>
<body>
<div class="screen">
<div class="container">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Back
</button>
<div class="header-row">
<h2 class="screen-title" id="title">Stream Testing</h2>
<button class="btn-stop" id="btn-stop">Cancel</button>
</div>
<div class="status-bar">
<div class="status-top">
<div class="status-badge running" id="badge">
<div class="status-dot"></div>
<span id="badge-text">running</span>
</div>
<span class="status-session" id="session-id"></span>
</div>
<div class="counters">
<div class="counter total"><div class="counter-value" id="c-total">0</div><div class="counter-label">total</div></div>
<div class="counter tested"><div class="counter-value" id="c-tested">0</div><div class="counter-label">tested</div></div>
<div class="counter alive"><div class="counter-value" id="c-alive">0</div><div class="counter-label">alive</div></div>
<div class="counter screenshots"><div class="counter-value" id="c-screens">0</div><div class="counter-label">screenshots</div></div>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress"></div>
</div>
</div>
<div id="results"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script>
var params = new URLSearchParams(location.search);
var sessionId = params.get('id') || '';
var mode = params.get('mode') || 'main'; // main or sub
var ip = params.get('ip') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
var model = params.get('model') || '';
var server = params.get('server') || '';
var hostname = params.get('hostname') || '';
var ports = params.get('ports') || '';
var user = params.get('user') || '';
var channel = params.get('channel') || '';
var ids = params.get('ids') || '';
var mainStream = params.get('main') || ''; // for sub mode, already selected main
var pollTimer = null;
var renderedCount = 0;
var allResults = [];
// title
var titleEl = document.getElementById('title');
titleEl.textContent = mode === 'sub' ? 'Select Sub Stream' : 'Stream Testing';
if (mode === 'sub') {
var badge = document.createElement('span');
badge.className = 'mode-badge';
badge.textContent = 'sub';
titleEl.appendChild(badge);
}
document.getElementById('session-id').textContent = sessionId;
// back
document.getElementById('btn-back').addEventListener('click', function() { history.back(); });
// stop
document.getElementById('btn-stop').addEventListener('click', function() {
if (!sessionId) return;
fetch('api/test?id=' + encodeURIComponent(sessionId), { method: 'DELETE' });
stopPolling();
document.getElementById('btn-stop').disabled = true;
showToast('Session cancelled');
});
// start polling
if (sessionId) {
pollTimer = setInterval(function() { pollSession(); }, 1000);
pollSession();
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
async function pollSession() {
try {
var r = await fetch('api/test?id=' + encodeURIComponent(sessionId));
var data = await r.json();
document.getElementById('c-total').textContent = data.total;
document.getElementById('c-tested').textContent = data.tested;
document.getElementById('c-alive').textContent = data.alive;
document.getElementById('c-screens').textContent = data.with_screenshot;
var pct = data.total > 0 ? Math.round((data.tested / data.total) * 100) : 0;
document.getElementById('progress').style.width = pct + '%';
if (data.results && data.results.length > renderedCount) {
for (var i = renderedCount; i < data.results.length; i++) {
allResults.push(data.results[i]);
}
renderedCount = data.results.length;
renderResults();
}
if (data.status === 'done') {
stopPolling();
document.getElementById('badge').className = 'status-badge done';
document.getElementById('badge-text').textContent = 'done';
document.getElementById('progress').classList.add('complete');
document.getElementById('progress').style.width = '100%';
document.getElementById('btn-stop').disabled = true;
}
} catch (e) {}
}
function classifyResult(r) {
var scheme = r.source.split('://')[0] || '';
var isRtsp = scheme === 'rtsp' || scheme === 'rtsps';
var isHD = r.width >= 1280;
if (isRtsp && isHD) return 'rec-main';
if (isRtsp) return 'rec-sub';
return 'alt';
}
function renderResults() {
var container = document.getElementById('results');
container.textContent = '';
var groups = { 'rec-main': [], 'rec-sub': [], 'alt': [] };
allResults.forEach(function(r) {
groups[classifyResult(r)].push(r);
});
var defs = [
{ key: 'rec-main', title: 'Recommended - Main', collapsed: mode === 'sub' },
{ key: 'rec-sub', title: 'Recommended - Sub', collapsed: mode === 'main' },
{ key: 'alt', title: 'Alternative', collapsed: true }
];
defs.forEach(function(def) {
var items = groups[def.key];
if (items.length === 0) return;
var group = document.createElement('div');
group.className = 'group' + (def.collapsed ? ' collapsed' : '');
var header = document.createElement('div');
header.className = 'group-header';
var toggle = document.createElement('button');
toggle.className = 'group-toggle';
toggle.type = 'button';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '12'); svg.setAttribute('height', '12');
svg.setAttribute('viewBox', '0 0 12 12'); svg.setAttribute('fill', 'none');
svg.setAttribute('class', 'chevron');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M3 4.5l3 3 3-3');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('stroke-linecap', 'round');
svg.appendChild(path);
toggle.appendChild(svg);
header.appendChild(toggle);
var title = document.createElement('span');
title.className = 'group-title';
title.textContent = def.title;
header.appendChild(title);
var count = document.createElement('span');
count.className = 'group-count';
count.textContent = '(' + items.length + ')';
header.appendChild(count);
header.addEventListener('click', function() {
group.classList.toggle('collapsed');
});
group.appendChild(header);
var content = document.createElement('div');
content.className = 'group-content';
var grid = document.createElement('div');
grid.className = 'cards-grid';
items.forEach(function(r) {
grid.appendChild(createCard(r));
});
content.appendChild(grid);
group.appendChild(content);
container.appendChild(group);
});
if (allResults.length === 0) {
var empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = 'Waiting for results...';
container.appendChild(empty);
}
}
function createCard(r) {
var card = document.createElement('div');
card.className = 'card';
// thumbnail
var thumb = document.createElement('div');
thumb.className = 'card-thumb';
if (r.screenshot) {
var img = document.createElement('img');
img.src = r.screenshot;
img.alt = '';
img.loading = 'lazy';
thumb.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'card-thumb-placeholder';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.5');
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', '2'); rect.setAttribute('y', '2');
rect.setAttribute('width', '20'); rect.setAttribute('height', '20');
rect.setAttribute('rx', '2');
var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '8.5'); circle.setAttribute('cy', '8.5'); circle.setAttribute('r', '1.5');
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', 'm21 15-5-5L5 21');
svg.appendChild(rect); svg.appendChild(circle); svg.appendChild(p);
ph.appendChild(svg);
thumb.appendChild(ph);
}
// codec tags
if (r.codecs && r.codecs.length) {
var overlay = document.createElement('div');
overlay.className = 'card-overlay';
r.codecs.forEach(function(c) {
var tag = document.createElement('span');
tag.className = 'card-tag';
tag.textContent = c;
overlay.appendChild(tag);
});
thumb.appendChild(overlay);
}
// resolution badge
if (r.width && r.height) {
var res = document.createElement('span');
res.className = 'card-resolution';
res.textContent = r.width + 'x' + r.height;
thumb.appendChild(res);
}
card.appendChild(thumb);
// body
var body = document.createElement('div');
body.className = 'card-body';
var urlDiv = document.createElement('div');
urlDiv.className = 'card-url';
var schemeEnd = r.source.indexOf('://');
if (schemeEnd > 0) {
var schemeSpan = document.createElement('span');
schemeSpan.className = 'scheme';
schemeSpan.textContent = r.source.substring(0, schemeEnd + 3);
urlDiv.appendChild(schemeSpan);
// mask creds in display
var rest = r.source.substring(schemeEnd + 3);
var atIdx = rest.indexOf('@');
if (atIdx > 0) {
var auth = rest.substring(0, atIdx);
var colonIdx = auth.indexOf(':');
if (colonIdx > 0) {
urlDiv.appendChild(document.createTextNode(auth.substring(0, colonIdx + 1) + '***@'));
urlDiv.appendChild(document.createTextNode(rest.substring(atIdx + 1)));
} else {
urlDiv.appendChild(document.createTextNode(rest));
}
} else {
urlDiv.appendChild(document.createTextNode(rest));
}
} else {
urlDiv.textContent = r.source;
}
body.appendChild(urlDiv);
// meta
var meta = document.createElement('div');
meta.className = 'card-meta';
if (r.latency_ms !== undefined) {
var lat = document.createElement('span');
lat.className = 'card-meta-item';
if (r.latency_ms < 200) lat.classList.add('latency-fast');
else if (r.latency_ms < 500) lat.classList.add('latency-medium');
else lat.classList.add('latency-slow');
lat.textContent = r.latency_ms + 'ms';
meta.appendChild(lat);
}
if (r.width && r.height) {
var resItem = document.createElement('span');
resItem.className = 'card-meta-item';
resItem.textContent = r.width + 'x' + r.height;
meta.appendChild(resItem);
}
body.appendChild(meta);
card.appendChild(body);
// action button
var action = document.createElement('div');
action.className = 'card-action';
var btn = document.createElement('button');
btn.className = 'btn-use';
btn.textContent = mode === 'sub' ? 'Use as Sub Stream' : 'Use as Main Stream';
btn.addEventListener('click', function() {
selectStream(r);
});
action.appendChild(btn);
card.appendChild(action);
return card;
}
function selectStream(r) {
var p = new URLSearchParams();
if (mode === 'sub') {
// sub mode: go to config with both main and sub
if (mainStream) p.set('main', mainStream);
p.set('sub', r.source);
} else {
// main mode: go to config with main, session id for potential sub selection
p.set('main', r.source);
p.set('session', sessionId);
}
// pass through all known params
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (model) p.set('model', model);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (ports) p.set('ports', ports);
if (user) p.set('user', user);
if (channel) p.set('channel', channel);
if (ids) p.set('ids', ids);
if (r.width) p.set('main_width', r.width);
if (r.height) p.set('main_height', r.height);
window.location.href = 'config.html?' + p.toString();
}
function showToast(msg) {
var t = document.getElementById('toast');
t.textContent = msg;
t.classList.remove('hidden');
t.classList.add('show');
setTimeout(function() {
t.classList.remove('show');
setTimeout(function() { t.classList.add('hidden'); }, 250);
}, 3000);
}
</script>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - Stream URLs</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body { font-family: var(--font-primary); background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; min-height: 100vh; }
.screen { padding: 1.5rem; animation: fadeIn var(--transition-base); }
.container { max-width: 600px; margin: 0 auto; width: 100%; }
@media (min-width: 768px) { .screen { padding: 3rem 1.5rem; } }
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none; color: var(--text-secondary);
font-size: 0.875rem; font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0; margin-bottom: 1.5rem;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.page-title { font-size: 1.375rem; font-weight: 600; margin-bottom: 0.5rem; }
.page-subtitle { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 2rem; }
.stream-card {
padding: 1rem 1.25rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; margin-bottom: 0.75rem;
}
.stream-label {
font-size: 0.625rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-tertiary); margin-bottom: 0.375rem;
}
.stream-url-box {
display: flex; align-items: center; gap: 0.5rem;
}
.stream-url {
flex: 1; font-family: var(--font-mono); font-size: 0.75rem;
color: var(--text-secondary); word-break: break-all; line-height: 1.5;
}
.stream-url .scheme { color: var(--purple-light); }
.btn-copy-sm {
flex-shrink: 0; padding: 0.375rem;
background: var(--bg-tertiary); border: 1px solid var(--border-color);
border-radius: 4px; cursor: pointer; color: var(--text-tertiary);
display: flex; transition: all var(--transition-fast);
}
.btn-copy-sm:hover { border-color: var(--purple-primary); color: var(--purple-light); }
.btn-copy-sm svg { width: 14px; height: 14px; }
.section-divider { height: 1px; background: var(--border-color); margin: 1.5rem 0; }
.info-box {
padding: 1rem; background: rgba(139, 92, 246, 0.06);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 8px; margin-bottom: 1.5rem;
}
.info-title { font-size: 0.875rem; font-weight: 600; color: var(--purple-light); margin-bottom: 0.5rem; }
.info-text { font-size: 0.8125rem; color: var(--text-secondary); line-height: 1.6; }
.config-example {
margin-top: 1rem; padding: 1rem;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px;
}
.config-label { font-size: 0.6875rem; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.config-code {
font-family: var(--font-mono); font-size: 0.6875rem;
color: var(--text-secondary); line-height: 1.7;
white-space: pre; overflow-x: auto;
}
.config-code .key { color: var(--purple-light); }
.btn-copy-block {
margin-top: 0.75rem; padding: 0.5rem 1rem;
background: var(--bg-tertiary); border: 1px solid var(--border-color);
border-radius: 6px; cursor: pointer;
color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;
font-family: var(--font-primary); transition: all var(--transition-fast);
}
.btn-copy-block:hover { border-color: var(--purple-primary); color: var(--purple-light); }
.btn-add-sub {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem; background: var(--bg-tertiary);
border: 1px solid var(--border-color); border-radius: 6px;
color: var(--text-secondary); font-size: 0.75rem; font-weight: 500;
font-family: var(--font-primary); cursor: pointer;
transition: all var(--transition-fast); margin-bottom: 0.75rem;
}
.btn-add-sub:hover { border-color: var(--purple-primary); color: var(--purple-light); }
.toast {
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 0.75rem 1.25rem; background: var(--bg-elevated);
border: 1px solid var(--border-color); border-radius: 8px;
box-shadow: var(--shadow-lg); font-size: 0.8125rem; color: var(--text-primary);
z-index: 1000; transition: transform var(--transition-base);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body>
<div class="screen">
<div class="container">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Back
</button>
<h2 class="page-title">Your Stream URLs</h2>
<p class="page-subtitle">Copy these URLs to use in your NVR, media player, or streaming software.</p>
<div id="streams"></div>
<button class="btn-add-sub" id="btn-add-sub" style="display:none">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Add Sub Stream
</button>
<div class="section-divider"></div>
<div class="info-box">
<div class="info-title">How to use</div>
<div class="info-text">Add these URLs to your NVR software (Frigate, Blue Iris, Shinobi, etc.) or stream player (VLC, go2rtc). The main stream is high resolution for recording, the sub stream is lower resolution for real-time detection.</div>
</div>
<div id="config-examples"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script>
var params = new URLSearchParams(location.search);
var mainStream = params.get('main') || '';
var subStream = params.get('sub') || '';
var ip = params.get('ip') || '';
var sessionId = params.get('session') || '';
var sanitized = ip ? ip.replace(/\./g, '_') : 'camera';
var mainName = sanitized + '_main';
var subName = sanitized + '_sub';
document.getElementById('btn-back').addEventListener('click', function() { history.back(); });
// add sub stream button
if (!subStream && sessionId) {
document.getElementById('btn-add-sub').style.display = 'inline-flex';
}
document.getElementById('btn-add-sub').addEventListener('click', function() {
var p = new URLSearchParams();
p.set('id', sessionId);
p.set('mode', 'sub');
p.set('main', mainStream);
if (ip) p.set('ip', ip);
window.location.href = 'test.html?' + p.toString();
});
// render stream cards
var container = document.getElementById('streams');
if (mainStream) renderStreamCard(container, 'Main Stream', mainStream);
if (subStream) renderStreamCard(container, 'Sub Stream', subStream);
// render config examples
var examples = document.getElementById('config-examples');
// go2rtc config
var g2cfg = 'streams:\n';
if (mainStream) g2cfg += " '" + mainName + "':\n - " + mainStream + '\n';
if (subStream) g2cfg += " '" + subName + "':\n - " + subStream + '\n';
renderConfigBlock(examples, 'go2rtc.yaml', g2cfg);
// frigate config snippet
var fcfg = 'go2rtc:\n streams:\n';
if (mainStream) fcfg += " '" + mainName + "':\n - " + mainStream + '\n';
if (subStream) fcfg += " '" + subName + "':\n - " + subStream + '\n';
fcfg += '\ncameras:\n camera_' + sanitized + ':\n ffmpeg:\n inputs:\n';
if (subStream) {
fcfg += ' - path: rtsp://127.0.0.1:8554/' + subName + '\n input_args: preset-rtsp-restream\n roles:\n - detect\n';
fcfg += ' - path: rtsp://127.0.0.1:8554/' + mainName + '\n input_args: preset-rtsp-restream\n roles:\n - record\n';
} else if (mainStream) {
fcfg += ' - path: rtsp://127.0.0.1:8554/' + mainName + '\n input_args: preset-rtsp-restream\n roles:\n - detect\n - record\n';
}
renderConfigBlock(examples, 'Frigate config snippet', fcfg);
function renderStreamCard(parent, label, url) {
var card = document.createElement('div');
card.className = 'stream-card';
var lbl = document.createElement('div');
lbl.className = 'stream-label';
lbl.textContent = label;
card.appendChild(lbl);
var row = document.createElement('div');
row.className = 'stream-url-box';
var urlEl = document.createElement('div');
urlEl.className = 'stream-url';
var i = url.indexOf('://');
if (i > 0) {
var s = document.createElement('span');
s.className = 'scheme';
s.textContent = url.substring(0, i + 3);
urlEl.appendChild(s);
urlEl.appendChild(document.createTextNode(url.substring(i + 3)));
} else {
urlEl.textContent = url;
}
row.appendChild(urlEl);
var btn = document.createElement('button');
btn.className = 'btn-copy-sm';
btn.type = 'button';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('fill', 'none');
var p1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
p1.setAttribute('x', '5'); p1.setAttribute('y', '5');
p1.setAttribute('width', '9'); p1.setAttribute('height', '9');
p1.setAttribute('rx', '1'); p1.setAttribute('stroke', 'currentColor');
p1.setAttribute('stroke-width', '1.5');
var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p2.setAttribute('d', 'M11 5V3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h2');
p2.setAttribute('stroke', 'currentColor');
p2.setAttribute('stroke-width', '1.5');
svg.appendChild(p1);
svg.appendChild(p2);
btn.appendChild(svg);
btn.addEventListener('click', function() {
copyText(url);
showToast('Copied');
});
row.appendChild(btn);
card.appendChild(row);
parent.appendChild(card);
}
function renderConfigBlock(parent, label, code) {
var block = document.createElement('div');
block.className = 'config-example';
var lbl = document.createElement('div');
lbl.className = 'config-label';
lbl.textContent = label;
block.appendChild(lbl);
var codeEl = document.createElement('div');
codeEl.className = 'config-code';
codeEl.textContent = code;
block.appendChild(codeEl);
var btn = document.createElement('button');
btn.className = 'btn-copy-block';
btn.type = 'button';
btn.textContent = 'Copy';
btn.addEventListener('click', function() {
copyText(code);
showToast('Copied');
});
block.appendChild(btn);
parent.appendChild(block);
}
function copyText(text) {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
}
function showToast(msg) {
var t = document.getElementById('toast'); t.textContent = msg;
t.classList.remove('hidden'); t.classList.add('show');
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.classList.add('hidden'); }, 250); }, 3000);
}
</script>
</body>
</html>