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

+
+ +
+ + +

IP, hostname or full stream URL

+
+ + + +
+

Examples

+
    +
  • 192.168.1.100
  • +
  • camera.local
  • +
  • rtsp://user:pass@192.168.1.100/stream
  • +
+
+
+
+ + +
+
+ + +

Camera Configuration

+ +
+ +
+ + + + +
+
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ Advanced +
+
+ + +
+ +
+ +
+ + Ɨ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +

Discovering Streams

+ +
+
+
+
+

Starting scan...

+
+ +
+
+ 0 + Tested +
+
+ 0 + Found +
+
+ 0 + Remaining +
+
+ + +
+
+ + +
+
+ + +

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); +}