diff --git a/cmd/strix/main.go b/cmd/strix/main.go
index ccae411..0eb48f6 100644
--- a/cmd/strix/main.go
+++ b/cmd/strix/main.go
@@ -13,6 +13,7 @@ import (
"github.com/strix-project/strix/internal/api"
"github.com/strix-project/strix/internal/config"
"github.com/strix-project/strix/internal/utils/logger"
+ "github.com/strix-project/strix/webui"
)
const (
@@ -67,7 +68,10 @@ func main() {
os.Exit(1)
}
- // Create HTTP server
+ // Create Web UI server
+ webuiServer := webui.NewServer(log)
+
+ // Create API HTTP server
httpServer := &http.Server{
Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port),
Handler: apiServer,
@@ -76,20 +80,41 @@ func main() {
IdleTimeout: 120 * time.Second,
}
- // Start server in goroutine
+ // Create Web UI HTTP server
+ webuiHTTPServer := &http.Server{
+ Addr: fmt.Sprintf("%s:4567", cfg.Server.Host),
+ Handler: webuiServer,
+ ReadTimeout: cfg.Server.ReadTimeout,
+ WriteTimeout: cfg.Server.WriteTimeout,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ // Start API server in goroutine
go func() {
- log.Info("HTTP server starting",
+ log.Info("API server starting",
slog.String("address", httpServer.Addr),
slog.String("api_version", "v1"),
)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Error("HTTP server failed", err)
+ log.Error("API server failed", err)
os.Exit(1)
}
}()
- // Print API endpoints
+ // Start Web UI server in goroutine
+ go func() {
+ log.Info("Web UI server starting",
+ slog.String("address", webuiHTTPServer.Addr),
+ )
+
+ if err := webuiHTTPServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Error("Web UI server failed", err)
+ os.Exit(1)
+ }
+ }()
+
+ // Print endpoints
printEndpoints(cfg.Server.Host, cfg.Server.Port)
// Wait for interrupt signal
@@ -103,12 +128,19 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
+ // Shutdown API server
if err := httpServer.Shutdown(ctx); err != nil {
- log.Error("server shutdown failed", err)
+ log.Error("API server shutdown failed", err)
os.Exit(1)
}
- log.Info("server stopped gracefully")
+ // Shutdown Web UI server
+ if err := webuiHTTPServer.Shutdown(ctx); err != nil {
+ log.Error("Web UI server shutdown failed", err)
+ os.Exit(1)
+ }
+
+ log.Info("servers stopped gracefully")
}
// checkFFProbe checks if ffprobe is available
@@ -143,6 +175,13 @@ func printEndpoints(host, port string) {
baseURL := fmt.Sprintf("http://%s:%s", host, port)
+ webuiURL := fmt.Sprintf("http://%s:4567", host)
+
+ fmt.Println("\nš Web Interface:")
+ fmt.Println("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
+ fmt.Printf(" Open in browser: %s\n", webuiURL)
+ fmt.Println("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
+
fmt.Println("\nš API Endpoints:")
fmt.Println("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
fmt.Printf(" Health Check: GET %s/api/v1/health\n", baseURL)
diff --git a/data/brands/auto.json b/data/brands/auto.json
new file mode 100644
index 0000000..e7ef23c
--- /dev/null
+++ b/data/brands/auto.json
@@ -0,0 +1,13 @@
+{
+ "brand": "Auto",
+ "brand_id": "auto",
+ "last_updated": "2025-01-01",
+ "source": "strix",
+ "website": "",
+ "cameras": [
+ {
+ "model": "Automatic Detection",
+ "entries": []
+ }
+ ]
+}
diff --git a/internal/camera/database/search.go b/internal/camera/database/search.go
index fd4d771..cd3fad7 100644
--- a/internal/camera/database/search.go
+++ b/internal/camera/database/search.go
@@ -58,21 +58,71 @@ func (s *SearchEngine) Search(query string, limit int) (*models.CameraSearchResp
return results[i].Score > results[j].Score
})
- // Apply limit
- if len(results) > limit {
- results = results[:limit]
+ // Expand each camera into individual model entries with model-specific scores
+ type ModelResult struct {
+ Camera models.Camera
+ Score float64
+ }
+ var modelResults []ModelResult
+
+ for _, result := range results {
+ camera := result.Camera
+
+ // Collect unique models with their scores
+ modelScores := make(map[string]float64)
+ for _, entry := range camera.Entries {
+ for _, model := range entry.Models {
+ if model != "" && model != "Other" {
+ // Calculate model-specific score
+ modelScore := s.calculateModelScore(model, modelTokens, normalizedQuery)
+ if modelScore > modelScores[model] {
+ modelScores[model] = modelScore
+ }
+ }
+ }
+ }
+
+ // Create a separate camera entry for each unique model
+ for model, modelScore := range modelScores {
+ // Combine brand score with model score
+ finalScore := result.Score*0.3 + modelScore*0.7
+
+ expandedCamera := models.Camera{
+ Brand: camera.Brand,
+ BrandID: camera.BrandID,
+ Model: model,
+ LastUpdated: camera.LastUpdated,
+ Source: camera.Source,
+ Website: camera.Website,
+ Entries: camera.Entries,
+ MatchScore: finalScore,
+ }
+ modelResults = append(modelResults, ModelResult{
+ Camera: expandedCamera,
+ Score: finalScore,
+ })
+ }
}
- // Convert to response
- cameras := make([]models.Camera, len(results))
- for i, result := range results {
- cameras[i] = *result.Camera
- cameras[i].MatchScore = result.Score
+ // Sort by final score (best matches first)
+ sort.Slice(modelResults, func(i, j int) bool {
+ return modelResults[i].Score > modelResults[j].Score
+ })
+
+ // Apply limit
+ if len(modelResults) > limit {
+ modelResults = modelResults[:limit]
+ }
+
+ // Convert to camera slice
+ cameras := make([]models.Camera, len(modelResults))
+ for i, result := range modelResults {
+ cameras[i] = result.Camera
}
return &models.CameraSearchResponse{
Cameras: cameras,
- Total: len(results),
+ Total: len(cameras),
Returned: len(cameras),
}, nil
}
diff --git a/webui/server.go b/webui/server.go
new file mode 100644
index 0000000..851317d
--- /dev/null
+++ b/webui/server.go
@@ -0,0 +1,76 @@
+package webui
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+//go:embed web
+var webFiles embed.FS
+
+// Server represents the Web UI server
+type Server struct {
+ router chi.Router
+ logger interface{ Info(string, ...any); Error(string, error, ...any) }
+}
+
+// NewServer creates a new Web UI server
+func NewServer(logger interface{ Info(string, ...any); Error(string, error, ...any) }) *Server {
+ server := &Server{
+ router: chi.NewRouter(),
+ logger: logger,
+ }
+
+ server.setupRoutes()
+ return server
+}
+
+// setupRoutes configures all routes for the web UI
+func (s *Server) setupRoutes() {
+ // Middleware
+ s.router.Use(middleware.RequestID)
+ s.router.Use(middleware.RealIP)
+ s.router.Use(middleware.Logger)
+ s.router.Use(middleware.Recoverer)
+
+ // CORS middleware
+ s.router.Use(func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
+
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ })
+
+ // Get the embedded filesystem
+ webFS, err := fs.Sub(webFiles, "web")
+ if err != nil {
+ s.logger.Error("failed to get web filesystem", err)
+ return
+ }
+
+ // Serve static files
+ fileServer := http.FileServer(http.FS(webFS))
+ s.router.Handle("/*", fileServer)
+}
+
+// ServeHTTP implements http.Handler
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.router.ServeHTTP(w, r)
+}
+
+// GetRouter returns the chi router
+func (s *Server) GetRouter() chi.Router {
+ return s.router
+}
diff --git a/webui/web/css/main.css b/webui/web/css/main.css
new file mode 100644
index 0000000..21ca2e4
--- /dev/null
+++ b/webui/web/css/main.css
@@ -0,0 +1,856 @@
+/* ===== CSS RESET ===== */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+/* ===== CSS VARIABLES ===== */
+:root {
+ /* Colors */
+ --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;
+ --text-disabled: #404050;
+
+ --success: #10b981;
+ --warning: #f59e0b;
+ --error: #ef4444;
+
+ --border-color: rgba(139, 92, 246, 0.15);
+ --border-focus: rgba(139, 92, 246, 0.5);
+
+ /* Typography */
+ --font-primary: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', sans-serif;
+ --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.5rem;
+ --text-2xl: 2rem;
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-12: 3rem;
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Shadows */
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+ --shadow-purple: 0 8px 24px var(--purple-glow);
+}
+
+/* ===== GLOBAL STYLES ===== */
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font-family: var(--font-primary);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.5;
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* ===== LAYOUT ===== */
+#app {
+ min-height: 100vh;
+ position: relative;
+}
+
+.screen {
+ display: none;
+ min-height: 100vh;
+ padding: var(--space-6);
+ animation: fadeIn var(--transition-base);
+}
+
+.screen.active {
+ display: block;
+}
+
+.container {
+ max-width: 480px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+@media (min-width: 768px) {
+ .screen {
+ padding: var(--space-12) var(--space-6);
+ }
+
+ .container {
+ max-width: 540px;
+ }
+}
+
+@media (min-width: 1024px) {
+ .container {
+ max-width: 600px;
+ }
+}
+
+/* ===== HERO SECTION ===== */
+.hero {
+ text-align: center;
+ margin-bottom: var(--space-12);
+}
+
+.logo {
+ width: 64px;
+ height: 64px;
+ color: var(--purple-primary);
+ margin: 0 auto var(--space-4);
+ filter: drop-shadow(0 4px 12px var(--purple-glow));
+}
+
+.title {
+ font-size: var(--text-2xl);
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ margin-bottom: var(--space-2);
+ background: linear-gradient(135deg, var(--purple-light), var(--purple-primary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.subtitle {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ font-weight: 400;
+}
+
+/* ===== FORM ELEMENTS ===== */
+.form-group {
+ margin-bottom: var(--space-6);
+}
+
+.label {
+ display: block;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: var(--space-2);
+}
+
+.optional {
+ color: var(--text-tertiary);
+ font-weight: 400;
+}
+
+.input {
+ width: 100%;
+ padding: var(--space-4);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: var(--text-base);
+ font-family: var(--font-primary);
+ transition: all var(--transition-fast);
+ outline: none;
+}
+
+.input:focus {
+ border-color: var(--purple-primary);
+ box-shadow: 0 0 0 3px var(--purple-glow);
+}
+
+.input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.input:disabled, .input:read-only {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.input-large {
+ padding: var(--space-6);
+ font-size: var(--text-lg);
+}
+
+.hint {
+ margin-top: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--text-tertiary);
+}
+
+/* Input with validation checkmark */
+.input-validated {
+ position: relative;
+}
+
+.input-validated .input {
+ padding-right: var(--space-12);
+}
+
+.icon-check {
+ position: absolute;
+ right: var(--space-4);
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--success);
+}
+
+/* Password input */
+.input-password-wrapper {
+ position: relative;
+}
+
+.input-password-wrapper .input {
+ padding-right: var(--space-12);
+}
+
+.btn-toggle-password {
+ position: absolute;
+ right: var(--space-3);
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ padding: var(--space-2);
+ cursor: pointer;
+ color: var(--text-tertiary);
+ transition: color var(--transition-fast);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-toggle-password:hover {
+ color: var(--purple-primary);
+}
+
+.icon-eye {
+ width: 20px;
+ height: 20px;
+}
+
+/* Resolution inputs */
+.input-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.input-row .input {
+ flex: 1;
+}
+
+.input-separator {
+ color: var(--text-tertiary);
+ font-size: var(--text-lg);
+}
+
+/* ===== BUTTONS ===== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-4) var(--space-6);
+ border-radius: 8px;
+ font-size: var(--text-base);
+ font-weight: 600;
+ font-family: var(--font-primary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ border: none;
+ outline: none;
+ text-decoration: none;
+}
+
+.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;
+}
+
+.btn-secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ border-color: var(--purple-primary);
+ color: var(--purple-primary);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-outline:hover {
+ border-color: var(--purple-primary);
+ color: var(--purple-primary);
+}
+
+.btn-large {
+ width: 100%;
+ padding: var(--space-6);
+ font-size: var(--text-lg);
+}
+
+.btn-back {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: var(--text-sm);
+ font-family: var(--font-primary);
+ cursor: pointer;
+ padding: var(--space-2) 0;
+ margin-bottom: var(--space-6);
+ transition: color var(--transition-fast);
+}
+
+.btn-back:hover {
+ color: var(--purple-primary);
+}
+
+/* ===== ADVANCED SECTION ===== */
+.advanced-section {
+ margin-bottom: var(--space-6);
+}
+
+.advanced-toggle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ cursor: pointer;
+ user-select: none;
+ font-size: var(--text-base);
+ font-weight: 500;
+ color: var(--text-secondary);
+ padding: var(--space-3) 0;
+ transition: color var(--transition-fast);
+}
+
+.advanced-toggle:hover {
+ color: var(--purple-primary);
+}
+
+.advanced-toggle::before {
+ content: 'ā¶';
+ font-size: var(--text-sm);
+ transition: transform var(--transition-fast);
+}
+
+.advanced-section[open] .advanced-toggle::before {
+ transform: rotate(90deg);
+}
+
+.advanced-content {
+ padding-top: var(--space-4);
+}
+
+/* ===== AUTOCOMPLETE ===== */
+.autocomplete-wrapper {
+ position: relative;
+}
+
+.autocomplete-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: var(--space-2);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ max-height: 300px;
+ overflow-y: auto;
+ z-index: 100;
+ box-shadow: var(--shadow-lg);
+}
+
+.autocomplete-dropdown.hidden {
+ display: none;
+}
+
+.autocomplete-item {
+ padding: var(--space-3) var(--space-4);
+ cursor: pointer;
+ transition: background-color var(--transition-fast);
+ font-size: var(--text-sm);
+}
+
+.autocomplete-item:hover, .autocomplete-item.selected {
+ background: var(--bg-tertiary);
+}
+
+.autocomplete-item:first-child {
+ border-radius: 8px 8px 0 0;
+}
+
+.autocomplete-item:last-child {
+ border-radius: 0 0 8px 8px;
+}
+
+.autocomplete-loading {
+ padding: var(--space-4);
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: var(--text-sm);
+}
+
+/* ===== EXAMPLES ===== */
+.examples {
+ margin-top: var(--space-12);
+ text-align: center;
+}
+
+.examples-title {
+ font-size: var(--text-sm);
+ color: var(--text-tertiary);
+ margin-bottom: var(--space-3);
+ font-weight: 500;
+}
+
+.examples-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.examples-list li {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+}
+
+/* ===== SCREEN TITLES ===== */
+.screen-title {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ margin-bottom: var(--space-8);
+}
+
+/* ===== PROGRESS ===== */
+.progress-container {
+ margin-bottom: var(--space-8);
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: var(--space-3);
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--purple-primary), var(--purple-light));
+ border-radius: 4px;
+ transition: width var(--transition-base);
+ box-shadow: 0 0 12px var(--purple-glow);
+}
+
+.progress-text {
+ text-align: center;
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+}
+
+/* ===== STATS ===== */
+.stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+ margin-bottom: var(--space-12);
+}
+
+.stat {
+ text-align: center;
+ padding: var(--space-4);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.stat-value {
+ display: block;
+ font-size: var(--text-xl);
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: var(--space-1);
+}
+
+.stat-value.stat-primary {
+ color: var(--purple-primary);
+}
+
+.stat-label {
+ display: block;
+ font-size: var(--text-xs);
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* ===== STREAMS SECTION ===== */
+.streams-section {
+ margin-top: var(--space-12);
+}
+
+.streams-section.hidden {
+ display: none;
+}
+
+.section-title {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ margin-bottom: var(--space-6);
+}
+
+/* ===== CAROUSEL ===== */
+.carousel {
+ position: relative;
+ overflow: hidden;
+ margin-bottom: var(--space-4);
+}
+
+.carousel-track {
+ display: flex;
+ transition: transform var(--transition-slow);
+}
+
+.stream-card {
+ flex: 0 0 100%;
+ width: 100%;
+ padding: var(--space-6);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ transition: all var(--transition-base);
+}
+
+.stream-card:hover {
+ border-color: var(--purple-primary);
+ box-shadow: 0 8px 24px var(--purple-glow);
+}
+
+.stream-type {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--purple-primary);
+ margin-bottom: var(--space-4);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.stream-type svg {
+ width: 20px;
+ height: 20px;
+}
+
+.stream-url {
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+ word-break: break-all;
+ margin-bottom: var(--space-4);
+ padding: var(--space-3);
+ background: var(--bg-tertiary);
+ border-radius: 6px;
+}
+
+.stream-meta {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-2);
+}
+
+.stream-actions {
+ margin-top: var(--space-6);
+}
+
+.carousel-arrow {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 48px;
+ height: 48px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-color);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ z-index: 10;
+ color: var(--text-secondary);
+}
+
+.carousel-arrow:hover:not(:disabled) {
+ background: var(--purple-primary);
+ border-color: var(--purple-primary);
+ color: white;
+ box-shadow: 0 4px 12px var(--purple-glow);
+}
+
+.carousel-arrow:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.carousel-arrow-left {
+ left: -24px;
+}
+
+.carousel-arrow-right {
+ right: -24px;
+}
+
+@media (max-width: 767px) {
+ .carousel-arrow {
+ display: none;
+ }
+}
+
+.carousel-info {
+ text-align: center;
+}
+
+.carousel-counter {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-3);
+}
+
+.carousel-dots {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-2);
+}
+
+.carousel-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(139, 92, 246, 0.3);
+ border: none;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ padding: 0;
+}
+
+.carousel-dot.active {
+ width: 24px;
+ border-radius: 4px;
+ background: var(--purple-primary);
+ box-shadow: 0 0 8px var(--purple-glow);
+}
+
+/* ===== SELECTED STREAM INFO ===== */
+.selected-stream-info {
+ padding: var(--space-6);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ margin-bottom: var(--space-6);
+}
+
+.selected-type {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--purple-primary);
+ margin-bottom: var(--space-2);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.selected-url {
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ word-break: break-all;
+}
+
+/* ===== TABS ===== */
+.tabs {
+ margin-bottom: var(--space-6);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.tabs::-webkit-scrollbar {
+ display: none;
+}
+
+.tabs-scroll {
+ display: flex;
+ gap: var(--space-2);
+ min-width: min-content;
+}
+
+.tab {
+ padding: var(--space-3) var(--space-6);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.tab:hover {
+ border-color: var(--purple-primary);
+ color: var(--purple-primary);
+}
+
+.tab.active {
+ background: var(--purple-primary);
+ border-color: var(--purple-primary);
+ color: white;
+ box-shadow: 0 4px 12px var(--purple-glow);
+}
+
+.tab-content {
+ position: relative;
+}
+
+.tab-pane {
+ display: none;
+}
+
+.tab-pane.active {
+ display: block;
+ animation: fadeIn var(--transition-fast);
+}
+
+.config-code {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: var(--space-6);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+ overflow-x: auto;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+/* ===== ACTIONS ===== */
+.actions {
+ display: flex;
+ gap: var(--space-3);
+ margin-bottom: var(--space-6);
+}
+
+.actions .btn {
+ flex: 1;
+}
+
+/* ===== TOAST ===== */
+.toast {
+ position: fixed;
+ bottom: var(--space-6);
+ left: 50%;
+ transform: translateX(-50%) translateY(100px);
+ padding: var(--space-4) var(--space-6);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg);
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+ z-index: 1000;
+ transition: transform var(--transition-base);
+ max-width: 90%;
+}
+
+.toast.show {
+ transform: translateX(-50%) translateY(0);
+}
+
+.toast.hidden {
+ display: none;
+}
+
+/* ===== ANIMATIONS ===== */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ===== UTILITIES ===== */
+.hidden {
+ display: none !important;
+}
diff --git a/webui/web/index.html b/webui/web/index.html
new file mode 100644
index 0000000..d3d87d1
--- /dev/null
+++ b/webui/web/index.html
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+ Strix - Camera Stream Discovery
+
+
+
+
+
+
+
+
+
+
STRIX
+
Camera Stream Discovery
+
+
+
+
+
+
+
+
Examples
+
+ - 192.168.1.100
+ - camera.local
+ - rtsp://user:pass@192.168.1.100/stream
+
+
+
+
+
+
+
+
+
+
+
Camera Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Discovering Streams
+
+
+
+
+
+ 0
+ Tested
+
+
+ 0
+ Found
+
+
+ 0
+ Remaining
+
+
+
+
+
Found Connections
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Stream Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webui/web/js/api/camera-search.js b/webui/web/js/api/camera-search.js
new file mode 100644
index 0000000..3347385
--- /dev/null
+++ b/webui/web/js/api/camera-search.js
@@ -0,0 +1,27 @@
+export class CameraSearchAPI {
+ constructor(baseURL = null) {
+ // Auto-detect API URL based on current host
+ if (!baseURL) {
+ const currentHost = window.location.hostname;
+ this.baseURL = `http://${currentHost}:8080`;
+ } else {
+ this.baseURL = baseURL;
+ }
+ }
+
+ async search(query, limit = 10) {
+ const response = await fetch(`${this.baseURL}/api/v1/cameras/search`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query, limit }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return await response.json();
+ }
+}
diff --git a/webui/web/js/api/stream-discovery.js b/webui/web/js/api/stream-discovery.js
new file mode 100644
index 0000000..e9464c0
--- /dev/null
+++ b/webui/web/js/api/stream-discovery.js
@@ -0,0 +1,101 @@
+export class StreamDiscoveryAPI {
+ constructor(baseURL = null) {
+ // Auto-detect API URL based on current host
+ if (!baseURL) {
+ const currentHost = window.location.hostname;
+ this.baseURL = `http://${currentHost}:8080`;
+ } else {
+ this.baseURL = baseURL;
+ }
+ this.eventSource = null;
+ }
+
+ discover(request, callbacks) {
+ this.close();
+
+ const url = new URL(`${this.baseURL}/api/v1/streams/discover`);
+
+ fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'text/event-stream',
+ },
+ body: JSON.stringify(request),
+ }).then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ const processStream = ({ done, value }) => {
+ if (done) {
+ return;
+ }
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('event:')) {
+ const eventType = line.substring(6).trim();
+ continue;
+ }
+
+ if (line.startsWith('data:')) {
+ const data = line.substring(5).trim();
+
+ try {
+ const parsed = JSON.parse(data);
+ this.handleEvent(parsed, callbacks);
+ } catch (e) {
+ console.error('Failed to parse SSE data:', e);
+ }
+ }
+ }
+
+ return reader.read().then(processStream);
+ };
+
+ return reader.read().then(processStream);
+ }).catch(error => {
+ if (callbacks.onError) {
+ callbacks.onError(error.message);
+ }
+ });
+ }
+
+ handleEvent(data, callbacks) {
+ // Determine event type from data
+ if (data.tested !== undefined && data.found !== undefined) {
+ // Progress event
+ if (callbacks.onProgress) {
+ callbacks.onProgress(data);
+ }
+ } else if (data.stream) {
+ // Stream found event
+ if (callbacks.onStreamFound) {
+ callbacks.onStreamFound(data);
+ }
+ } else if (data.total_tested !== undefined) {
+ // Complete event
+ if (callbacks.onComplete) {
+ callbacks.onComplete(data);
+ }
+ } else if (data.error) {
+ // Error event
+ if (callbacks.onError) {
+ callbacks.onError(data.error);
+ }
+ }
+ }
+
+ close() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ }
+}
diff --git a/webui/web/js/config-generators/frigate/index.js b/webui/web/js/config-generators/frigate/index.js
new file mode 100644
index 0000000..ad02346
--- /dev/null
+++ b/webui/web/js/config-generators/frigate/index.js
@@ -0,0 +1,48 @@
+export class FrigateGenerator {
+ static generate(stream) {
+ // For non-RTSP streams, suggest using Go2RTC
+ if (stream.type !== 'FFMPEG' || stream.protocol !== 'rtsp') {
+ return `# This stream type requires Go2RTC proxy\n\n` +
+ `# This ${stream.type} stream is not natively supported by Frigate.\n` +
+ `# Please use Go2RTC to convert it to RTSP first.\n\n` +
+ `# Steps:\n` +
+ `# 1. Add this stream to your Go2RTC configuration\n` +
+ `# 2. Use the Go2RTC RTSP endpoint in Frigate\n` +
+ `# 3. Example: rtsp://localhost:8554/camera_stream_0`;
+ }
+
+ // Generate RTSP config for Frigate
+ const cameraName = this.generateCameraName(stream);
+ const config = [];
+
+ config.push(`cameras:`);
+ config.push(` ${cameraName}:`);
+ config.push(` ffmpeg:`);
+ config.push(` inputs:`);
+ config.push(` - path: ${stream.url}`);
+ config.push(` roles:`);
+ config.push(` - detect`);
+ config.push(` - record`);
+
+ if (stream.resolution) {
+ config.push(` detect:`);
+ const [width, height] = stream.resolution.split('x').map(Number);
+ if (width && height) {
+ config.push(` width: ${width}`);
+ config.push(` height: ${height}`);
+ }
+ }
+
+ return config.join('\n');
+ }
+
+ static generateCameraName(stream) {
+ try {
+ const urlObj = new URL(stream.url);
+ const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
+ return `camera_${ip}`;
+ } catch (e) {
+ return 'camera';
+ }
+ }
+}
diff --git a/webui/web/js/config-generators/go2rtc/index.js b/webui/web/js/config-generators/go2rtc/index.js
new file mode 100644
index 0000000..c8a2dee
--- /dev/null
+++ b/webui/web/js/config-generators/go2rtc/index.js
@@ -0,0 +1,87 @@
+export class Go2RTCGenerator {
+ static generate(stream) {
+ const streamName = this.generateStreamName(stream);
+
+ switch (stream.type) {
+ case 'FFMPEG':
+ if (stream.protocol === 'rtsp') {
+ return this.generateRTSP(streamName, stream);
+ }
+ break;
+ case 'JPEG':
+ return this.generateJPEG(streamName, stream);
+ case 'MJPEG':
+ return this.generateMJPEG(streamName, stream);
+ case 'HTTP_VIDEO':
+ return this.generateHTTPVideo(streamName, stream);
+ case 'HLS':
+ return this.generateHLS(streamName, stream);
+ case 'ONVIF':
+ return `# ONVIF Device Service\n# This is a device management endpoint, not a stream\n# URL: ${stream.url}`;
+ default:
+ return this.generateRTSP(streamName, stream);
+ }
+ }
+
+ static generateStreamName(stream) {
+ try {
+ const urlObj = new URL(stream.url);
+ const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
+ return `${ip}_0`;
+ } catch (e) {
+ return 'camera_stream_0';
+ }
+ }
+
+ static generateRTSP(streamName, stream) {
+ return `streams:\n '${streamName}':\n - ${stream.url}`;
+ }
+
+ static generateJPEG(streamName, stream) {
+ const framerate = 10;
+ const ffmpegCmd = [
+ 'exec:ffmpeg',
+ '-loglevel quiet',
+ '-f image2',
+ '-loop 1',
+ `-framerate ${framerate}`,
+ `-i ${stream.url}`,
+ '-c:v libx264',
+ '-preset ultrafast',
+ '-tune zerolatency',
+ '-g 20',
+ '-f rtsp {output}'
+ ].join(' ');
+
+ return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
+ }
+
+ static generateMJPEG(streamName, stream) {
+ const ffmpegCmd = [
+ 'exec:ffmpeg',
+ '-loglevel quiet',
+ `-i ${stream.url}`,
+ '-c:v copy',
+ '-f rtsp {output}'
+ ].join(' ');
+
+ return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
+ }
+
+ static generateHTTPVideo(streamName, stream) {
+ const ffmpegCmd = [
+ 'exec:ffmpeg',
+ '-loglevel quiet',
+ `-i ${stream.url}`,
+ '-c:v copy',
+ '-c:a copy',
+ '-f rtsp {output}'
+ ].join(' ');
+
+ return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
+ }
+
+ static generateHLS(streamName, stream) {
+ return `streams:\n '${streamName}':\n - ${stream.url}`;
+ }
+}
diff --git a/webui/web/js/main.js b/webui/web/js/main.js
new file mode 100644
index 0000000..86d0717
--- /dev/null
+++ b/webui/web/js/main.js
@@ -0,0 +1,385 @@
+import { CameraSearchAPI } from './api/camera-search.js';
+import { StreamDiscoveryAPI } from './api/stream-discovery.js';
+import { SearchForm } from './ui/search-form.js';
+import { StreamCarousel } from './ui/stream-carousel.js';
+import { ConfigPanel } from './ui/config-panel.js';
+import { showToast } from './utils/toast.js';
+
+class StrixApp {
+ constructor() {
+ this.cameraAPI = new CameraSearchAPI();
+ this.streamAPI = new StreamDiscoveryAPI();
+
+ this.searchForm = new SearchForm();
+ this.carousel = new StreamCarousel();
+ this.configPanel = new ConfigPanel();
+
+ this.currentAddress = '';
+ this.currentStreams = [];
+ this.currentStream = null;
+
+ this.init();
+ }
+
+ init() {
+ this.setupEventListeners();
+ this.showScreen('address');
+ }
+
+ setupEventListeners() {
+ // Screen 1: Address input
+ document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress());
+ document.getElementById('network-address').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.checkAddress();
+ });
+
+ // Screen 2: Configuration form
+ document.getElementById('btn-back-to-address').addEventListener('click', () => {
+ this.showScreen('address');
+ });
+
+ document.getElementById('btn-discover').addEventListener('click', () => this.discoverStreams());
+
+ // Password toggle
+ document.querySelector('.btn-toggle-password').addEventListener('click', () => {
+ const input = document.getElementById('password');
+ input.type = input.type === 'password' ? 'text' : 'password';
+ });
+
+ // Camera model autocomplete
+ const modelInput = document.getElementById('camera-model');
+ let debounceTimer;
+ let extendedSearchTimer;
+ modelInput.addEventListener('input', (e) => {
+ clearTimeout(debounceTimer);
+ clearTimeout(extendedSearchTimer);
+ const query = e.target.value.trim();
+
+ if (query.length >= 2) {
+ debounceTimer = setTimeout(() => {
+ this.searchCameraModels(query, 10);
+
+ extendedSearchTimer = setTimeout(() => {
+ this.searchCameraModels(query, 50, true);
+ }, 1000);
+ }, 300);
+ } else {
+ this.hideAutocomplete();
+ }
+ });
+
+ // Screen 3: Stream discovery
+ document.getElementById('btn-back-to-config').addEventListener('click', () => {
+ this.streamAPI.close();
+ this.showScreen('config');
+ });
+
+ // Carousel navigation
+ document.getElementById('carousel-prev').addEventListener('click', () => {
+ this.carousel.prev();
+ });
+
+ document.getElementById('carousel-next').addEventListener('click', () => {
+ this.carousel.next();
+ });
+
+ // Keyboard navigation
+ document.addEventListener('keydown', (e) => {
+ const currentScreen = document.querySelector('.screen.active').id;
+ if (currentScreen === 'screen-discovery') {
+ if (e.key === 'ArrowLeft') this.carousel.prev();
+ if (e.key === 'ArrowRight') this.carousel.next();
+ }
+ });
+
+ // Screen 4: Configuration output
+ document.getElementById('btn-back-to-streams').addEventListener('click', () => {
+ this.showScreen('discovery');
+ });
+
+ document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
+ document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
+ document.getElementById('btn-new-search').addEventListener('click', () => {
+ this.reset();
+ this.showScreen('address');
+ });
+
+ // Tab switching
+ document.querySelectorAll('.tab').forEach(tab => {
+ tab.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
+ });
+ }
+
+ showScreen(screenName) {
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
+ document.getElementById(`screen-${screenName}`).classList.add('active');
+ }
+
+ async checkAddress() {
+ const input = document.getElementById('network-address');
+ const address = input.value.trim();
+
+ if (!address) {
+ showToast('Please enter a network address');
+ return;
+ }
+
+ // Check if it's a full URL with credentials
+ if (this.isFullURL(address)) {
+ this.parseFullURL(address);
+ } else {
+ // Just an IP or hostname
+ this.currentAddress = address;
+ document.getElementById('address-validated').value = address;
+ }
+
+ this.showScreen('config');
+ }
+
+ isFullURL(str) {
+ return str.startsWith('rtsp://') || str.startsWith('http://') || str.startsWith('https://');
+ }
+
+ parseFullURL(url) {
+ try {
+ const urlObj = new URL(url);
+
+ // Extract credentials
+ if (urlObj.username) {
+ document.getElementById('username').value = urlObj.username;
+ }
+ if (urlObj.password) {
+ document.getElementById('password').value = urlObj.password;
+ }
+
+ // Extract IP/hostname
+ this.currentAddress = urlObj.hostname;
+ document.getElementById('address-validated').value = url;
+
+ // Disable model input
+ const modelInput = document.getElementById('camera-model');
+ modelInput.disabled = true;
+ modelInput.placeholder = 'Detected from URL';
+ document.getElementById('model-disabled-hint').classList.remove('hidden');
+
+ } catch (e) {
+ this.currentAddress = url;
+ document.getElementById('address-validated').value = url;
+ }
+ }
+
+ async searchCameraModels(query, limit = 10, append = false) {
+ const dropdown = document.getElementById('autocomplete-dropdown');
+
+ if (!append) {
+ dropdown.innerHTML = 'Searching...
';
+ dropdown.classList.remove('hidden');
+ }
+
+ try {
+ const response = await this.cameraAPI.search(query, limit);
+
+ if (response.cameras && response.cameras.length > 0) {
+ this.renderAutocomplete(response.cameras, append);
+ } else if (!append) {
+ dropdown.innerHTML = 'No cameras found
';
+ }
+ } catch (error) {
+ console.error('Search error:', error);
+ if (!append) {
+ dropdown.innerHTML = 'Search failed
';
+ }
+ }
+ }
+
+ renderAutocomplete(cameras, append = false) {
+ const dropdown = document.getElementById('autocomplete-dropdown');
+ const modelInput = document.getElementById('camera-model');
+
+ const existingValues = new Set();
+ if (append) {
+ dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
+ existingValues.add(item.dataset.value);
+ });
+ }
+
+ const newItems = cameras
+ .map(camera => {
+ const fullName = `${camera.brand}: ${camera.model}`;
+ if (append && existingValues.has(fullName)) {
+ return null;
+ }
+ return `${fullName}
`;
+ })
+ .filter(item => item !== null)
+ .join('');
+
+ if (append) {
+ dropdown.insertAdjacentHTML('beforeend', newItems);
+ } else {
+ dropdown.innerHTML = newItems;
+ }
+
+ dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
+ if (!item.hasAttribute('data-listener')) {
+ item.setAttribute('data-listener', 'true');
+ item.addEventListener('click', () => {
+ modelInput.value = item.dataset.value;
+ this.hideAutocomplete();
+ });
+ }
+ });
+ }
+
+ hideAutocomplete() {
+ document.getElementById('autocomplete-dropdown').classList.add('hidden');
+ }
+
+ async discoverStreams() {
+ const model = document.getElementById('camera-model').value.trim();
+ const username = document.getElementById('username').value.trim();
+ const password = document.getElementById('password').value.trim();
+ const channel = parseInt(document.getElementById('channel').value) || 0;
+ const maxStreams = parseInt(document.getElementById('max-streams').value) || 10;
+
+ const request = {
+ target: this.currentAddress,
+ model: model || 'auto',
+ username: username,
+ password: password,
+ channel: channel,
+ max_streams: maxStreams,
+ timeout: 240
+ };
+
+ this.showScreen('discovery');
+ this.resetDiscoveryUI();
+
+ // Start SSE stream
+ this.streamAPI.discover(request, {
+ onProgress: (data) => this.handleProgress(data),
+ onStreamFound: (data) => this.handleStreamFound(data),
+ onComplete: (data) => this.handleComplete(data),
+ onError: (error) => this.handleError(error)
+ });
+ }
+
+ resetDiscoveryUI() {
+ document.getElementById('progress-fill').style.width = '0%';
+ document.getElementById('progress-text').textContent = 'Starting scan...';
+ document.getElementById('stat-tested').textContent = '0';
+ document.getElementById('stat-found').textContent = '0';
+ document.getElementById('stat-remaining').textContent = '0';
+ document.getElementById('streams-section').classList.add('hidden');
+ this.currentStreams = [];
+ }
+
+ handleProgress(data) {
+ const total = data.tested + data.remaining;
+ const percentage = total > 0 ? (data.tested / total) * 100 : 0;
+
+ document.getElementById('progress-fill').style.width = `${percentage}%`;
+ document.getElementById('progress-text').textContent = `Testing streams... ${Math.round(percentage)}%`;
+ document.getElementById('stat-tested').textContent = data.tested;
+ document.getElementById('stat-found').textContent = data.found;
+ document.getElementById('stat-remaining').textContent = data.remaining;
+ }
+
+ handleStreamFound(data) {
+ this.currentStreams.push(data.stream);
+
+ // Show streams section if hidden
+ const streamsSection = document.getElementById('streams-section');
+ if (streamsSection.classList.contains('hidden')) {
+ streamsSection.classList.remove('hidden');
+ }
+
+ // Update carousel
+ this.carousel.render(this.currentStreams, (stream, index) => {
+ this.selectStream(stream, index);
+ });
+ }
+
+ handleComplete(data) {
+ document.getElementById('progress-fill').style.width = '100%';
+ document.getElementById('progress-text').textContent =
+ `Scan complete! Found ${data.total_found} stream(s) in ${data.duration.toFixed(1)}s`;
+
+ if (this.currentStreams.length === 0) {
+ showToast('No streams found. Try different credentials or model.');
+ }
+ }
+
+ handleError(error) {
+ console.error('Discovery error:', error);
+ showToast(`Error: ${error}`);
+ }
+
+ selectStream(stream, index) {
+ this.currentStream = stream;
+ this.configPanel.render(stream);
+ this.showScreen('output');
+ }
+
+ switchTab(tabName) {
+ // Update tab buttons
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
+
+ // Update tab panes
+ document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
+ document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active');
+ }
+
+ copyConfig() {
+ const activeTab = document.querySelector('.tab.active').dataset.tab;
+ const configElement = document.getElementById(`config-${activeTab}`);
+ const text = configElement.textContent;
+
+ navigator.clipboard.writeText(text).then(() => {
+ showToast('Copied to clipboard!');
+ }).catch(err => {
+ showToast('Failed to copy');
+ console.error('Copy error:', err);
+ });
+ }
+
+ downloadConfig() {
+ const activeTab = document.querySelector('.tab.active').dataset.tab;
+ const configElement = document.getElementById(`config-${activeTab}`);
+ const text = configElement.textContent;
+
+ const filename = activeTab === 'url' ? 'stream-url.txt' : `${activeTab}-config.yaml`;
+ const blob = new Blob([text], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ showToast('Downloaded!');
+ }
+
+ reset() {
+ this.currentAddress = '';
+ this.currentStreams = [];
+ this.currentStream = null;
+
+ document.getElementById('network-address').value = '';
+ document.getElementById('camera-model').value = '';
+ document.getElementById('camera-model').disabled = false;
+ document.getElementById('camera-model').placeholder = 'Start typing...';
+ document.getElementById('username').value = '';
+ document.getElementById('password').value = '';
+ document.getElementById('channel').value = '0';
+ document.getElementById('max-streams').value = '10';
+ document.getElementById('model-disabled-hint').classList.add('hidden');
+
+ this.hideAutocomplete();
+ this.streamAPI.close();
+ }
+}
+
+// Initialize app
+const app = new StrixApp();
diff --git a/webui/web/js/ui/config-panel.js b/webui/web/js/ui/config-panel.js
new file mode 100644
index 0000000..55c43b0
--- /dev/null
+++ b/webui/web/js/ui/config-panel.js
@@ -0,0 +1,39 @@
+import { Go2RTCGenerator } from '../config-generators/go2rtc/index.js';
+import { FrigateGenerator } from '../config-generators/frigate/index.js';
+
+export class ConfigPanel {
+ constructor() {
+ this.stream = null;
+ }
+
+ render(stream) {
+ this.stream = stream;
+
+ // Update selected stream info
+ document.getElementById('selected-stream-type').textContent = stream.type;
+ document.getElementById('selected-stream-url').textContent = this.maskCredentials(stream.url);
+
+ // Generate configs
+ const urlConfig = stream.url;
+ const go2rtcConfig = Go2RTCGenerator.generate(stream);
+ const frigateConfig = FrigateGenerator.generate(stream);
+
+ // Update config displays
+ document.getElementById('config-url').textContent = urlConfig;
+ document.getElementById('config-go2rtc').textContent = go2rtcConfig;
+ document.getElementById('config-frigate').textContent = frigateConfig;
+ }
+
+ maskCredentials(url) {
+ try {
+ const urlObj = new URL(url);
+ if (urlObj.username || urlObj.password) {
+ urlObj.username = urlObj.username ? '***' : '';
+ urlObj.password = urlObj.password ? '***' : '';
+ }
+ return urlObj.toString();
+ } catch (e) {
+ return url;
+ }
+ }
+}
diff --git a/webui/web/js/ui/search-form.js b/webui/web/js/ui/search-form.js
new file mode 100644
index 0000000..a5b2df5
--- /dev/null
+++ b/webui/web/js/ui/search-form.js
@@ -0,0 +1,6 @@
+// Placeholder for future form-specific logic
+export class SearchForm {
+ constructor() {
+ // Reserved for form validation and helpers
+ }
+}
diff --git a/webui/web/js/ui/stream-carousel.js b/webui/web/js/ui/stream-carousel.js
new file mode 100644
index 0000000..26dcb34
--- /dev/null
+++ b/webui/web/js/ui/stream-carousel.js
@@ -0,0 +1,157 @@
+export class StreamCarousel {
+ constructor() {
+ this.track = document.getElementById('carousel-track');
+ this.prevBtn = document.getElementById('carousel-prev');
+ this.nextBtn = document.getElementById('carousel-next');
+ this.counter = document.getElementById('carousel-counter');
+ this.dotsContainer = document.getElementById('carousel-dots');
+
+ this.streams = [];
+ this.currentIndex = 0;
+ this.onUseCallback = null;
+ }
+
+ render(streams, onUseCallback) {
+ this.streams = streams;
+ this.onUseCallback = onUseCallback;
+ this.currentIndex = Math.min(this.currentIndex, streams.length - 1);
+
+ // Render stream cards
+ this.track.innerHTML = streams.map((stream, index) => this.renderCard(stream, index)).join('');
+
+ // Render dots
+ this.dotsContainer.innerHTML = streams.map((_, index) =>
+ ``
+ ).join('');
+
+ // Attach event listeners
+ this.attachEventListeners();
+
+ // Update view
+ this.updateView();
+ }
+
+ renderCard(stream, index) {
+ const icon = this.getStreamIcon(stream.type);
+
+ return `
+
+
+ ${icon}
+ ${stream.type}
+
+
${this.truncateURL(stream.url)}
+ ${stream.resolution ? `
Resolution: ${stream.resolution}
` : ''}
+ ${stream.codec ? `
Codec: ${stream.codec}${stream.fps ? ` ⢠${stream.fps} fps` : ''}${stream.bitrate ? ` ⢠${Math.round(stream.bitrate / 1000)} Kbps` : ''}
` : ''}
+ ${stream.has_audio ? `
Audio: Yes
` : ''}
+
+
+
+
+ `;
+ }
+
+ getStreamIcon(type) {
+ const icons = {
+ 'FFMPEG': '',
+ 'ONVIF': '',
+ 'JPEG': '',
+ 'MJPEG': '',
+ 'HLS': '',
+ 'HTTP_VIDEO': ''
+ };
+ return icons[type] || icons['FFMPEG'];
+ }
+
+ truncateURL(url) {
+ if (url.length > 50) {
+ return url.substring(0, 47) + '...';
+ }
+ return url;
+ }
+
+ attachEventListeners() {
+ // Use buttons
+ this.track.querySelectorAll('.btn-use').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const index = parseInt(e.target.dataset.index);
+ if (this.onUseCallback) {
+ this.onUseCallback(this.streams[index], index);
+ }
+ });
+ });
+
+ // Dots
+ this.dotsContainer.querySelectorAll('.carousel-dot').forEach(dot => {
+ dot.addEventListener('click', (e) => {
+ const index = parseInt(e.target.dataset.index);
+ this.goTo(index);
+ });
+ });
+
+ // Touch gestures
+ let touchStartX = 0;
+ let touchEndX = 0;
+
+ this.track.addEventListener('touchstart', (e) => {
+ touchStartX = e.changedTouches[0].screenX;
+ });
+
+ this.track.addEventListener('touchend', (e) => {
+ touchEndX = e.changedTouches[0].screenX;
+ this.handleSwipe(touchStartX, touchEndX);
+ });
+ }
+
+ handleSwipe(startX, endX) {
+ const swipeThreshold = 50;
+ const diff = startX - endX;
+
+ if (Math.abs(diff) > swipeThreshold) {
+ if (diff > 0) {
+ this.next();
+ } else {
+ this.prev();
+ }
+ }
+ }
+
+ prev() {
+ if (this.currentIndex > 0) {
+ this.goTo(this.currentIndex - 1);
+ }
+ }
+
+ next() {
+ if (this.currentIndex < this.streams.length - 1) {
+ this.goTo(this.currentIndex + 1);
+ }
+ }
+
+ goTo(index) {
+ if (index < 0 || index >= this.streams.length) return;
+
+ this.currentIndex = index;
+ this.updateView();
+ }
+
+ updateView() {
+ // Update track position
+ const offset = -100 * this.currentIndex;
+ this.track.style.transform = `translateX(${offset}%)`;
+
+ // Update counter
+ this.counter.textContent = `Stream ${this.currentIndex + 1} of ${this.streams.length}`;
+
+ // Update dots
+ this.dotsContainer.querySelectorAll('.carousel-dot').forEach((dot, i) => {
+ dot.classList.toggle('active', i === this.currentIndex);
+ });
+
+ // Update arrow buttons
+ this.prevBtn.disabled = this.currentIndex === 0;
+ this.nextBtn.disabled = this.currentIndex === this.streams.length - 1;
+ }
+}
diff --git a/webui/web/js/utils/toast.js b/webui/web/js/utils/toast.js
new file mode 100644
index 0000000..df30bbe
--- /dev/null
+++ b/webui/web/js/utils/toast.js
@@ -0,0 +1,13 @@
+export function showToast(message, duration = 3000) {
+ const toast = document.getElementById('toast');
+ toast.textContent = message;
+ toast.classList.remove('hidden');
+ toast.classList.add('show');
+
+ setTimeout(() => {
+ toast.classList.remove('show');
+ setTimeout(() => {
+ toast.classList.add('hidden');
+ }, 250);
+ }, duration);
+}