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:
eduard256
2025-11-06 00:43:03 +03:00
parent 1cfc2fa2e5
commit 74fe12bcf1
15 changed files with 2222 additions and 16 deletions
+46 -7
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
{
"brand": "Auto",
"brand_id": "auto",
"last_updated": "2025-01-01",
"source": "strix",
"website": "",
"cameras": [
{
"model": "Automatic Detection",
"entries": []
}
]
}
+59 -9
View File
@@ -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
}
+76
View File
@@ -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
}
+856
View File
@@ -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;
}
+309
View File
@@ -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>
+27
View File
@@ -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();
}
}
+101
View File
@@ -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}`;
}
}
+385
View File
@@ -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();
+39
View File
@@ -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;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Placeholder for future form-specific logic
export class SearchForm {
constructor() {
// Reserved for form validation and helpers
}
}
+157
View File
@@ -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;
}
}
+13
View File
@@ -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);
}