Add go2rtc module, test/config/urls pages, Frigate config fixes
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user