Improve camera model search with per-model ranking and two-stage loading
- Split camera results into individual models (Brand: Model format) - Add model-specific relevance scoring for better search results - Implement two-stage autocomplete: 10 results immediately, 50 after 1 second - Filter out "Other" models from search results - Sort models by relevance score (exact matches first) - Add auto.json brand for automatic detection fallback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+46
-7
@@ -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)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"brand": "Auto",
|
||||
"brand_id": "auto",
|
||||
"last_updated": "2025-01-01",
|
||||
"source": "strix",
|
||||
"website": "",
|
||||
"cameras": [
|
||||
{
|
||||
"model": "Automatic Detection",
|
||||
"entries": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#0a0a0f">
|
||||
<title>Strix - Camera Stream Discovery</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Screen 1: Initial Address Input -->
|
||||
<div id="screen-address" class="screen active">
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="20" r="6" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 20c0-6.627 5.373-12 12-12s12 5.373 12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="18" cy="18" r="2" fill="currentColor"/>
|
||||
<circle cx="30" cy="18" r="2" fill="currentColor"/>
|
||||
<path d="M20 28l4-2 4 2v8l-4-2-4 2z" fill="currentColor"/>
|
||||
</svg>
|
||||
<h1 class="title">STRIX</h1>
|
||||
<p class="subtitle">Camera Stream Discovery</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="network-address" class="label">Network Address</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network-address"
|
||||
class="input input-large"
|
||||
placeholder="192.168.1.100"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<p class="hint">IP, hostname or full stream URL</p>
|
||||
</div>
|
||||
|
||||
<button id="btn-check-address" class="btn btn-primary btn-large">
|
||||
Check Address
|
||||
</button>
|
||||
|
||||
<div class="examples">
|
||||
<p class="examples-title">Examples</p>
|
||||
<ul class="examples-list">
|
||||
<li>192.168.1.100</li>
|
||||
<li>camera.local</li>
|
||||
<li>rtsp://user:pass@192.168.1.100/stream</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2: Configuration Form -->
|
||||
<div id="screen-config" class="screen">
|
||||
<div class="container">
|
||||
<button id="btn-back-to-address" class="btn-back">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h2 class="screen-title">Camera Configuration</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="address-validated" class="label">Network Address</label>
|
||||
<div class="input-validated">
|
||||
<input
|
||||
type="text"
|
||||
id="address-validated"
|
||||
class="input"
|
||||
readonly
|
||||
>
|
||||
<svg class="icon-check" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M4 10l4 4 8-8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="camera-model" class="label">Camera Model <span class="optional">(optional)</span></label>
|
||||
<div class="autocomplete-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="camera-model"
|
||||
class="input"
|
||||
placeholder="Start typing..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div id="autocomplete-dropdown" class="autocomplete-dropdown hidden"></div>
|
||||
</div>
|
||||
<p id="model-disabled-hint" class="hint hidden">Detected from URL, continue below</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
placeholder="admin"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="label">Password</label>
|
||||
<div class="input-password-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button type="button" class="btn-toggle-password" aria-label="Toggle password visibility">
|
||||
<svg class="icon-eye" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M2 10c1.5-4 4-6.5 8-6.5s6.5 2.5 8 6.5c-1.5 4-4 6.5-8 6.5S3.5 14 2 10z" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="10" cy="10" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="advanced-section">
|
||||
<summary class="advanced-toggle">Advanced</summary>
|
||||
<div class="advanced-content">
|
||||
<div class="form-group">
|
||||
<label for="channel" class="label">Channel</label>
|
||||
<input
|
||||
type="number"
|
||||
id="channel"
|
||||
class="input"
|
||||
value="0"
|
||||
min="0"
|
||||
max="255"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Resolution <span class="optional">(optional)</span></label>
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="number"
|
||||
id="width"
|
||||
class="input"
|
||||
placeholder="Width"
|
||||
>
|
||||
<span class="input-separator">×</span>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
class="input"
|
||||
placeholder="Height"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max-streams" class="label">Max Streams</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max-streams"
|
||||
class="input"
|
||||
value="10"
|
||||
min="1"
|
||||
max="50"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button id="btn-discover" class="btn btn-primary btn-large">
|
||||
Discover Streams
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screen 3: Stream Discovery -->
|
||||
<div id="screen-discovery" class="screen">
|
||||
<div class="container">
|
||||
<button id="btn-back-to-config" class="btn-back">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Back to Configuration
|
||||
</button>
|
||||
|
||||
<h2 class="screen-title">Discovering Streams</h2>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<p id="progress-text" class="progress-text">Starting scan...</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="stat-tested">0</span>
|
||||
<span class="stat-label">Tested</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-primary" id="stat-found">0</span>
|
||||
<span class="stat-label">Found</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="stat-remaining">0</span>
|
||||
<span class="stat-label">Remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="streams-section" class="streams-section hidden">
|
||||
<h3 class="section-title">Found Connections</h3>
|
||||
|
||||
<div class="carousel">
|
||||
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="carousel-track" class="carousel-track"></div>
|
||||
|
||||
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-info">
|
||||
<p id="carousel-counter" class="carousel-counter">Stream 1 of 1</p>
|
||||
<div id="carousel-dots" class="carousel-dots"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screen 4: Configuration Output -->
|
||||
<div id="screen-output" class="screen">
|
||||
<div class="container">
|
||||
<button id="btn-back-to-streams" class="btn-back">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Back to Streams
|
||||
</button>
|
||||
|
||||
<h2 class="screen-title">Stream Configuration</h2>
|
||||
|
||||
<div class="selected-stream-info">
|
||||
<p id="selected-stream-type" class="selected-type"></p>
|
||||
<p id="selected-stream-url" class="selected-url"></p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tabs-scroll">
|
||||
<button class="tab active" data-tab="url">URL</button>
|
||||
<button class="tab" data-tab="go2rtc">Go2RTC</button>
|
||||
<button class="tab" data-tab="frigate">Frigate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" data-pane="url">
|
||||
<pre id="config-url" class="config-code"></pre>
|
||||
</div>
|
||||
<div class="tab-pane" data-pane="go2rtc">
|
||||
<pre id="config-go2rtc" class="config-code"></pre>
|
||||
</div>
|
||||
<div class="tab-pane" data-pane="frigate">
|
||||
<pre id="config-frigate" class="config-code"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="btn-copy-config" class="btn btn-secondary">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="6" y="6" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M4 4h10v2H5v9H4V4z" fill="currentColor"/>
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
<button id="btn-download-config" class="btn btn-secondary">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 3v10m0 0l-4-4m4 4l4-4M4 17h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button id="btn-new-search" class="btn btn-outline">
|
||||
Add Another Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 = '<div class="autocomplete-loading">Searching...</div>';
|
||||
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 = '<div class="autocomplete-loading">No cameras found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
if (!append) {
|
||||
dropdown.innerHTML = '<div class="autocomplete-loading">Search failed</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 `<div class="autocomplete-item" data-value="${fullName}">${fullName}</div>`;
|
||||
})
|
||||
.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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Placeholder for future form-specific logic
|
||||
export class SearchForm {
|
||||
constructor() {
|
||||
// Reserved for form validation and helpers
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
`<button class="carousel-dot ${index === this.currentIndex ? 'active' : ''}"
|
||||
data-index="${index}"
|
||||
aria-label="Go to stream ${index + 1}"></button>`
|
||||
).join('');
|
||||
|
||||
// Attach event listeners
|
||||
this.attachEventListeners();
|
||||
|
||||
// Update view
|
||||
this.updateView();
|
||||
}
|
||||
|
||||
renderCard(stream, index) {
|
||||
const icon = this.getStreamIcon(stream.type);
|
||||
|
||||
return `
|
||||
<div class="stream-card" data-index="${index}">
|
||||
<div class="stream-type">
|
||||
${icon}
|
||||
${stream.type}
|
||||
</div>
|
||||
<div class="stream-url">${this.truncateURL(stream.url)}</div>
|
||||
${stream.resolution ? `<div class="stream-meta">Resolution: ${stream.resolution}</div>` : ''}
|
||||
${stream.codec ? `<div class="stream-meta">Codec: ${stream.codec}${stream.fps ? ` • ${stream.fps} fps` : ''}${stream.bitrate ? ` • ${Math.round(stream.bitrate / 1000)} Kbps` : ''}</div>` : ''}
|
||||
${stream.has_audio ? `<div class="stream-meta">Audio: Yes</div>` : ''}
|
||||
<div class="stream-actions">
|
||||
<button class="btn btn-primary btn-use" data-index="${index}">Use Stream</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getStreamIcon(type) {
|
||||
const icons = {
|
||||
'FFMPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M14 14l-3-2-3 2V8l3 2 3-2v6z" fill="currentColor"/></svg>',
|
||||
'ONVIF': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="2" fill="currentColor"/><circle cx="10" cy="10" r="5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 3"/></svg>',
|
||||
'JPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M3 13l4-4 3 3 5-5" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
'MJPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="2" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="11" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><path d="M5 8l2 2-2 2M14 8l2 2-2 2" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
'HLS': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v8M6 10h8" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
'HTTP_VIDEO': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M7 6l6 4-6 4V6z" fill="currentColor"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/></svg>'
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user