Merge pull request #1 from eduard256/develop
WebUI Improvements - Mock Mode, Tooltips, and UX Enhancements
This commit is contained in:
+382
-103
@@ -79,6 +79,37 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ===== MOCK MODE BADGE ===== */
|
||||
.mock-badge {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 6px;
|
||||
color: var(--warning);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.mock-badge svg {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ===== LAYOUT ===== */
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
@@ -555,148 +586,173 @@ body {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* ===== CAROUSEL ===== */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
/* ===== STREAMS LIST ===== */
|
||||
.streams-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
flex: 1;
|
||||
/* Custom scrollbar */
|
||||
.streams-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.streams-list::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.streams-list::-webkit-scrollbar-thumb {
|
||||
background: var(--purple-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.streams-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--purple-light);
|
||||
}
|
||||
|
||||
/* Stream item */
|
||||
.stream-item {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
transition: all var(--transition-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.stream-item:hover {
|
||||
border-color: var(--purple-primary);
|
||||
box-shadow: 0 8px 24px var(--purple-glow);
|
||||
box-shadow: 0 4px 12px var(--purple-glow);
|
||||
}
|
||||
|
||||
.stream-type {
|
||||
.stream-item.expanded {
|
||||
border-color: var(--purple-primary);
|
||||
}
|
||||
|
||||
/* Stream item header */
|
||||
.stream-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stream-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stream-info-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stream-type-badge {
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stream-type svg {
|
||||
.stream-type-badge svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stream-url {
|
||||
.stream-url-preview {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stream-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stream-toggle:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.stream-toggle .chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-item.expanded .stream-toggle .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.btn-use-stream {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Stream item details */
|
||||
.stream-item-details {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--transition-base);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.stream-item-details.visible {
|
||||
max-height: 500px;
|
||||
padding: 0 var(--space-4) var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.stream-url-full {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stream-meta {
|
||||
.stream-meta-item {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stream-actions {
|
||||
margin-top: var(--space-6);
|
||||
.stream-meta-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel-arrow {
|
||||
flex-shrink: 0;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.carousel-wrapper {
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.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);
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== SELECTED STREAM INFO ===== */
|
||||
@@ -718,6 +774,9 @@ body {
|
||||
}
|
||||
|
||||
.stream-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
@@ -976,6 +1035,104 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Button with tooltip wrapper */
|
||||
.button-with-tooltip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-with-tooltip .btn-generate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Button with tooltip in secondary-actions */
|
||||
.secondary-actions .button-with-tooltip {
|
||||
flex: 1.2;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.secondary-actions .button-with-tooltip .btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.secondary-actions .button-with-tooltip:last-child {
|
||||
flex: 0.8;
|
||||
}
|
||||
|
||||
/* Info icon inside button */
|
||||
.info-icon-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: help;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.info-icon-button:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.info-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Info icon inside outline button */
|
||||
.info-icon-button-outline {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: help;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.info-icon-button-outline:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.info-icon-button-outline svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Info icon inside stream type badge */
|
||||
.info-icon-stream {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: var(--space-2);
|
||||
cursor: help;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.info-icon-stream:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.info-icon-stream svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.frigate-output-section {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-6);
|
||||
@@ -987,6 +1144,128 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== TOOLTIPS ===== */
|
||||
.label-with-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: help;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.info-icon:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.info-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--purple-primary);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-4);
|
||||
width: 320px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tooltip opens downward */
|
||||
.tooltip.tooltip-down {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.info-icon:hover .tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--purple-primary);
|
||||
}
|
||||
|
||||
/* Arrow for downward tooltip */
|
||||
.tooltip.tooltip-down::after {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-weight: 600;
|
||||
color: var(--purple-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tooltip-text {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.tooltip-text:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip-examples {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tooltip-examples-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-example {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--purple-light);
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 4px;
|
||||
margin-bottom: var(--space-1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-example:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== UTILITIES ===== */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Simple development server for Strix WebUI
|
||||
# This allows you to test the UI without running the Go backend
|
||||
|
||||
PORT=${1:-8080}
|
||||
|
||||
echo "Starting development server on port $PORT"
|
||||
echo "Open: http://localhost:$PORT?mock=true"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
|
||||
# Use Python's built-in HTTP server
|
||||
cd "$(dirname "$0")"
|
||||
python3 -m http.server $PORT
|
||||
+372
-80
@@ -8,6 +8,15 @@
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Mock Mode Indicator -->
|
||||
<div id="mock-mode-badge" class="mock-badge hidden">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 1v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
MOCK MODE
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<!-- Screen 1: Initial Address Input -->
|
||||
<div id="screen-address" class="screen active">
|
||||
@@ -33,7 +42,30 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="network-address" class="label">Network Address</label>
|
||||
<label for="network-address" class="label label-with-info">
|
||||
Network Address
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Network Address</div>
|
||||
<p class="tooltip-text">Enter the network location of your IP camera. This can be an IP address, hostname, or a complete RTSP URL.</p>
|
||||
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Accepted formats:</div>
|
||||
<code class="tooltip-example">192.168.1.100 - IP address only</code>
|
||||
<code class="tooltip-example">camera.local - Hostname/mDNS</code>
|
||||
<code class="tooltip-example">rtsp://user:pass@192.168.1.100/stream - Full URL</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text"><strong>Where to find it:</strong><br>Check your camera's web interface, router's DHCP leases page, or network scanner app. Most cameras use addresses in the 192.168.x.x range.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>Next steps:</strong><br>After entering the address, click "Check Address" to validate the camera connection and proceed to stream discovery.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="network-address"
|
||||
@@ -73,7 +105,26 @@
|
||||
<h2 class="screen-title">Camera Configuration</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="address-validated" class="label">Network Address</label>
|
||||
<label for="address-validated" class="label label-with-info">
|
||||
Network Address
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Network Address</div>
|
||||
<p class="tooltip-text">The IP address, hostname, or full RTSP URL of your camera. This is the network location where the camera can be reached.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Examples:</div>
|
||||
<code class="tooltip-example">192.168.1.100</code>
|
||||
<code class="tooltip-example">camera.local</code>
|
||||
<code class="tooltip-example">rtsp://admin:pass@192.168.1.100</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Find it in your camera's network settings or router's device list (DHCP leases).</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-validated">
|
||||
<input
|
||||
type="text"
|
||||
@@ -88,7 +139,27 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="camera-model" class="label">Camera Model <span class="optional">(optional)</span></label>
|
||||
<label for="camera-model" class="label label-with-info">
|
||||
Camera Model <span class="optional">(optional)</span>
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Camera Model</div>
|
||||
<p class="tooltip-text">The manufacturer and model of your IP camera. This helps the system use optimized stream paths for your specific camera brand.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Examples:</div>
|
||||
<code class="tooltip-example">Hikvision: DS-2CD2142FWD</code>
|
||||
<code class="tooltip-example">Dahua: IPC-HDW4433C</code>
|
||||
<code class="tooltip-example">Amcrest: IP4M-1041</code>
|
||||
<code class="tooltip-example">Reolink: RLC-410</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Find it on the camera label, in the camera's web interface (Device Info), or in your purchase documentation. Leave empty for auto-detection.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="autocomplete-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
@@ -104,18 +175,52 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<label for="username" class="label label-with-info">
|
||||
Username
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Username</div>
|
||||
<p class="tooltip-text">The authentication username for accessing your camera's RTSP stream. This is required for most IP cameras to access video feeds.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Common defaults:</div>
|
||||
<code class="tooltip-example">admin</code>
|
||||
<code class="tooltip-example">root</code>
|
||||
<code class="tooltip-example">user</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Find it in your camera setup documentation or the camera's web interface under User Management. Change default credentials for security.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
value="admin"
|
||||
placeholder="admin"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="label">Password</label>
|
||||
<label for="password" class="label label-with-info">
|
||||
Password
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Password</div>
|
||||
<p class="tooltip-text">The authentication password for your camera's RTSP stream. This credential is used together with the username to access the video feed.</p>
|
||||
<p class="tooltip-text">For security reasons, always use a strong, unique password and avoid default passwords like "12345" or "password".</p>
|
||||
<p class="tooltip-text">Find it in your camera's documentation, setup guide, or change it via the camera's web interface under Security/User Management settings.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-password-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
@@ -134,23 +239,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="channel" class="label label-with-info">
|
||||
Channel
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Channel Number</div>
|
||||
<p class="tooltip-text">The channel number identifies which specific camera or video input to access on the device.</p>
|
||||
<p class="tooltip-text"><strong>For standalone IP cameras:</strong> Always use 0 (default). Single cameras don't use channel numbers.</p>
|
||||
<p class="tooltip-text"><strong>For NVR/DVR systems ONLY:</strong> Each connected camera has its own channel number. Channel numbering typically starts from 0.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">NVR/DVR channel values:</div>
|
||||
<code class="tooltip-example">0 - First camera on NVR/DVR</code>
|
||||
<code class="tooltip-example">1 - Second camera on NVR/DVR</code>
|
||||
<code class="tooltip-example">2-15 - Additional cameras (for 4, 8, 16-channel NVRs)</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Check your NVR's camera list in the device web interface to see the correct channel assignment for each camera.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="channel"
|
||||
class="input"
|
||||
value="0"
|
||||
min="0"
|
||||
max="255"
|
||||
>
|
||||
</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>
|
||||
<label class="label label-with-info">
|
||||
Resolution <span class="optional">(optional)</span>
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Resolution Filter</div>
|
||||
<p class="tooltip-text">Optionally filter discovered streams by specific resolution. Leave empty to find all available resolutions. Use this to target specific stream qualities.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Common resolutions:</div>
|
||||
<code class="tooltip-example">1920 × 1080 - Full HD (main stream)</code>
|
||||
<code class="tooltip-example">1280 × 720 - HD (sub stream)</code>
|
||||
<code class="tooltip-example">640 × 480 - VGA (low quality)</code>
|
||||
<code class="tooltip-example">3840 × 2160 - 4K Ultra HD</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Tip: Leave empty for initial discovery, then use specific values to find particular stream types (main vs sub streams).</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="number"
|
||||
@@ -169,7 +316,26 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max-streams" class="label">Max Streams</label>
|
||||
<label for="max-streams" class="label label-with-info">
|
||||
Max Streams
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Maximum Streams</div>
|
||||
<p class="tooltip-text">The maximum number of stream URLs to test during discovery. Higher values increase scan time but may find more stream variants. Lower values speed up discovery.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Recommended values:</div>
|
||||
<code class="tooltip-example">5 - Quick scan (faster)</code>
|
||||
<code class="tooltip-example">10 - Balanced (default)</code>
|
||||
<code class="tooltip-example">20-50 - Thorough scan (slower)</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Purpose: Controls how many different RTSP URL patterns are tested. Most cameras have 2-5 valid streams (main, sub, mobile, etc.).</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max-streams"
|
||||
@@ -207,46 +373,11 @@
|
||||
<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-wrapper">
|
||||
<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 class="carousel">
|
||||
<div id="carousel-track" class="carousel-track"></div>
|
||||
</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 id="streams-list" class="streams-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,13 +396,51 @@
|
||||
|
||||
<div class="stream-selection-container">
|
||||
<div class="selected-stream-info">
|
||||
<p class="stream-label">Main Stream</p>
|
||||
<div class="stream-label">
|
||||
<span>Main Stream</span>
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Main Stream</div>
|
||||
<p class="tooltip-text">The primary high-resolution video stream from your camera. This stream is typically used for recording and high-quality viewing.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Common uses:</div>
|
||||
<code class="tooltip-example">Recording to disk</code>
|
||||
<code class="tooltip-example">Live HD viewing</code>
|
||||
<code class="tooltip-example">High-quality playback</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Resolution is usually 1080p (1920×1080) or higher. Higher resolution means better quality but requires more bandwidth and storage.</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<p id="selected-main-type" class="selected-type"></p>
|
||||
<p id="selected-main-url" class="selected-url"></p>
|
||||
</div>
|
||||
|
||||
<div id="sub-stream-info" class="selected-stream-info sub-stream hidden">
|
||||
<p class="stream-label">Sub Stream</p>
|
||||
<div class="stream-label">
|
||||
<span>Sub Stream</span>
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Sub Stream</div>
|
||||
<p class="tooltip-text">A secondary lower-resolution video stream from your camera. This stream is optimized for object detection and reduces CPU usage.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Common uses:</div>
|
||||
<code class="tooltip-example">Motion detection</code>
|
||||
<code class="tooltip-example">Object detection (person, car)</code>
|
||||
<code class="tooltip-example">Low-bandwidth monitoring</code>
|
||||
</div>
|
||||
<p class="tooltip-text">Resolution is usually 640×480 or 720p. Using a sub stream for detection significantly improves performance while maintaining recording quality on the main stream.</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<p id="selected-sub-type" class="selected-type"></p>
|
||||
<p id="selected-sub-url" class="selected-url"></p>
|
||||
<button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button>
|
||||
@@ -280,24 +449,41 @@
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tabs-scroll">
|
||||
<button class="tab active" data-tab="url">URL</button>
|
||||
<button class="tab active" data-tab="frigate">Frigate</button>
|
||||
<button class="tab" data-tab="go2rtc">Go2RTC</button>
|
||||
<button class="tab" data-tab="frigate">Frigate</button>
|
||||
<button class="tab" data-tab="url">URL</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">
|
||||
<div class="tab-pane active" data-pane="frigate">
|
||||
<!-- Input section for existing config -->
|
||||
<div class="frigate-input-section">
|
||||
<label class="frigate-label">
|
||||
Your Current Frigate Config
|
||||
<label class="frigate-label label-with-info">
|
||||
Your Current Frigate Config <span class="optional">(optional)</span>
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Frigate Configuration</div>
|
||||
<p class="tooltip-text">You can either create a new Frigate config or add this camera to your existing configuration.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>Option 1: New Config (Recommended for beginners)</strong><br>Leave the example config below as-is, and the system will generate a complete working configuration for you.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>Option 2: Add to Existing Config</strong><br>If you already have Frigate running, paste your current config.yml here. The system will intelligently add this camera without breaking your existing setup.</p>
|
||||
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Where to find your config.yml:</div>
|
||||
<code class="tooltip-example">Docker: /config/config.yml</code>
|
||||
<code class="tooltip-example">Home Assistant addon: /config/frigate.yml</code>
|
||||
<code class="tooltip-example">Standalone: /etc/frigate/config.yml</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text">The generator will preserve all your existing cameras and settings, only adding the new camera configuration.</p>
|
||||
</div>
|
||||
</span>
|
||||
<span class="hint">Paste your existing config.yml or leave the example below</span>
|
||||
</label>
|
||||
<textarea
|
||||
@@ -308,16 +494,72 @@
|
||||
</div>
|
||||
|
||||
<!-- Generate button -->
|
||||
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
||||
Generate Config
|
||||
</button>
|
||||
<div class="button-with-tooltip">
|
||||
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
||||
Generate Config
|
||||
<span class="info-icon info-icon-button">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Generate Configuration</div>
|
||||
<p class="tooltip-text">This button will process your camera streams and generate a ready-to-use Frigate configuration.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>What happens:</strong></p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Configuration includes:</div>
|
||||
<code class="tooltip-example">Go2RTC streams setup</code>
|
||||
<code class="tooltip-example">Camera with detect & record roles</code>
|
||||
<code class="tooltip-example">Object tracking (person, car, etc.)</code>
|
||||
<code class="tooltip-example">Recording settings</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text">If you provided an existing config, your camera will be added to it. Otherwise, a complete new configuration will be created.</p>
|
||||
|
||||
<p class="tooltip-text">After generation, use Copy or Download buttons to save your config.</p>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Output section (hidden by default) -->
|
||||
<div id="frigate-output-section" class="frigate-output-section hidden">
|
||||
<label class="frigate-label">Updated Config (Camera Added)</label>
|
||||
<label class="frigate-label label-with-info">
|
||||
Updated Config (Camera Added)
|
||||
<span class="info-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">Generated Configuration</div>
|
||||
<p class="tooltip-text">This is your complete Frigate configuration with the camera successfully added.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>What's included:</strong></p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Configuration sections:</div>
|
||||
<code class="tooltip-example">go2rtc: Stream definitions</code>
|
||||
<code class="tooltip-example">cameras: Camera with roles</code>
|
||||
<code class="tooltip-example">objects: Person, car tracking</code>
|
||||
<code class="tooltip-example">record: Recording settings</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text"><strong>How to use:</strong><br>Copy or download this configuration and save it as <code>config.yml</code> in your Frigate directory. Restart Frigate to apply the changes.</p>
|
||||
|
||||
<p class="tooltip-text">If you added to existing config, your previous cameras and settings are preserved - only the new camera was added.</p>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
<pre id="config-frigate" class="config-code"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" data-pane="go2rtc">
|
||||
<pre id="config-go2rtc" class="config-code"></pre>
|
||||
</div>
|
||||
<div class="tab-pane" data-pane="url">
|
||||
<pre id="config-url" class="config-code"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -337,15 +579,65 @@
|
||||
</div>
|
||||
|
||||
<div class="secondary-actions">
|
||||
<button id="btn-add-sub-stream" class="btn btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add Sub Stream
|
||||
</button>
|
||||
<button id="btn-new-search" class="btn btn-outline">
|
||||
Add Another Camera
|
||||
</button>
|
||||
<div class="button-with-tooltip">
|
||||
<button id="btn-add-sub-stream" class="btn btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add Sub Stream
|
||||
<span class="info-icon info-icon-button">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Add Sub Stream</div>
|
||||
<p class="tooltip-text">Add a secondary lower-resolution stream for efficient object detection and motion monitoring.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>Why add a sub stream?</strong></p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Benefits:</div>
|
||||
<code class="tooltip-example">Reduces CPU usage by 50-70%</code>
|
||||
<code class="tooltip-example">Faster object detection</code>
|
||||
<code class="tooltip-example">Lower bandwidth consumption</code>
|
||||
<code class="tooltip-example">Main stream quality preserved</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text"><strong>How it works:</strong><br>After clicking, you'll return to the stream list where you can select a lower-resolution stream (usually 640×480 or 720p). Frigate will use this for detection while recording the main stream in full quality.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>Recommended:</strong> Most IP cameras support multiple streams. Using a sub stream is highly recommended for optimal Frigate performance.</p>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-with-tooltip">
|
||||
<button id="btn-new-search" class="btn btn-outline">
|
||||
Add Another Camera
|
||||
<span class="info-icon info-icon-button-outline">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-title">Add Another Camera</div>
|
||||
<p class="tooltip-text">Start the configuration process for a new camera from the beginning.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>⚠️ Important - Save First!</strong><br>Before clicking this button, make sure to save your current configuration using Copy or Download buttons above. This will reset the form.</p>
|
||||
|
||||
<p class="tooltip-text"><strong>What happens:</strong></p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">The process will:</div>
|
||||
<code class="tooltip-example">1. Return to address input screen</code>
|
||||
<code class="tooltip-example">2. Clear current camera settings</code>
|
||||
<code class="tooltip-example">3. Start fresh discovery</code>
|
||||
<code class="tooltip-example">4. Generate new config for next camera</code>
|
||||
</div>
|
||||
|
||||
<p class="tooltip-text">You can then add the new camera to your saved Frigate config by pasting it in the config field.</p>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { MockCameraSearch } from '../mock/mock-data.js';
|
||||
|
||||
export class CameraSearchAPI {
|
||||
constructor(baseURL = null) {
|
||||
constructor(baseURL = null, useMock = false) {
|
||||
// Use relative URLs since API and UI are on the same port
|
||||
if (!baseURL) {
|
||||
this.baseURL = '';
|
||||
} else {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
this.useMock = useMock;
|
||||
this.mockAPI = useMock ? new MockCameraSearch() : null;
|
||||
}
|
||||
|
||||
async search(query, limit = 10) {
|
||||
// Use mock API if enabled
|
||||
if (this.useMock) {
|
||||
return await this.mockAPI.search(query, limit);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}api/v1/cameras/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MockStreamDiscovery } from '../mock/mock-data.js';
|
||||
|
||||
export class StreamDiscoveryAPI {
|
||||
constructor(baseURL = null) {
|
||||
constructor(baseURL = null, useMock = false) {
|
||||
// Use relative URLs since API and UI are on the same port
|
||||
if (!baseURL) {
|
||||
this.baseURL = '';
|
||||
@@ -7,11 +9,19 @@ export class StreamDiscoveryAPI {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
this.eventSource = null;
|
||||
this.useMock = useMock;
|
||||
this.mockAPI = useMock ? new MockStreamDiscovery() : null;
|
||||
}
|
||||
|
||||
discover(request, callbacks) {
|
||||
this.close();
|
||||
|
||||
// Use mock API if enabled
|
||||
if (this.useMock) {
|
||||
this.mockAPI.discover(request, callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${this.baseURL}api/v1/streams/discover`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -91,6 +101,9 @@ export class StreamDiscoveryAPI {
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.useMock && this.mockAPI) {
|
||||
this.mockAPI.close();
|
||||
}
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
|
||||
+82
-32
@@ -1,18 +1,36 @@
|
||||
import { CameraSearchAPI } from './api/camera-search.js';
|
||||
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
|
||||
import { MockCameraAPI } from './mock/mock-camera-api.js';
|
||||
import { MockStreamAPI } from './mock/mock-stream-api.js';
|
||||
import { SearchForm } from './ui/search-form.js';
|
||||
import { StreamCarousel } from './ui/stream-carousel.js';
|
||||
import { StreamList } from './ui/stream-list.js';
|
||||
import { ConfigPanel } from './ui/config-panel.js';
|
||||
import { FrigateGenerator } from './config-generators/frigate/index.js';
|
||||
import { showToast } from './utils/toast.js';
|
||||
|
||||
class StrixApp {
|
||||
constructor() {
|
||||
this.cameraAPI = new CameraSearchAPI();
|
||||
this.streamAPI = new StreamDiscoveryAPI();
|
||||
// Check if mock mode is enabled via URL parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isMockMode = urlParams.get('mock') === 'true';
|
||||
|
||||
if (isMockMode) {
|
||||
console.log('🎭 Mock mode enabled - using fake data');
|
||||
this.cameraAPI = new MockCameraAPI();
|
||||
this.streamAPI = new MockStreamAPI();
|
||||
|
||||
// Show mock mode badge
|
||||
const mockBadge = document.getElementById('mock-mode-badge');
|
||||
if (mockBadge) {
|
||||
mockBadge.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
this.cameraAPI = new CameraSearchAPI();
|
||||
this.streamAPI = new StreamDiscoveryAPI();
|
||||
}
|
||||
|
||||
this.searchForm = new SearchForm();
|
||||
this.carousel = new StreamCarousel();
|
||||
this.streamList = new StreamList();
|
||||
this.configPanel = new ConfigPanel();
|
||||
|
||||
this.currentAddress = '';
|
||||
@@ -20,15 +38,41 @@ class StrixApp {
|
||||
this.selectedMainStream = null;
|
||||
this.selectedSubStream = null;
|
||||
this.isSelectingSubStream = false;
|
||||
this.frigateConfigGenerated = false; // Track if Frigate config has been generated
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.prefillNetworkAddress();
|
||||
this.showScreen('address');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fill network address input with smart default based on server IP
|
||||
*/
|
||||
prefillNetworkAddress() {
|
||||
const hostname = window.location.hostname;
|
||||
const input = document.getElementById('network-address');
|
||||
|
||||
// Skip if localhost or empty
|
||||
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if hostname is an IP address (matches pattern like 192.168.1.1)
|
||||
const ipPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = hostname.match(ipPattern);
|
||||
|
||||
if (match) {
|
||||
// Extract first three octets (e.g., "192.168.1." from "192.168.1.254")
|
||||
const networkPrefix = `${match[1]}.${match[2]}.${match[3]}.`;
|
||||
input.value = networkPrefix;
|
||||
input.placeholder = `${networkPrefix}100`;
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Screen 1: Address input
|
||||
document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress());
|
||||
@@ -77,24 +121,6 @@ class StrixApp {
|
||||
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.isSelectingSubStream = false;
|
||||
@@ -155,10 +181,12 @@ class StrixApp {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Extract credentials
|
||||
// Extract credentials (only override if provided in URL)
|
||||
if (urlObj.username) {
|
||||
document.getElementById('username').value = urlObj.username;
|
||||
}
|
||||
// If no username in URL, keep the default "admin" value
|
||||
|
||||
if (urlObj.password) {
|
||||
document.getElementById('password').value = urlObj.password;
|
||||
}
|
||||
@@ -285,9 +313,6 @@ class StrixApp {
|
||||
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 = [];
|
||||
}
|
||||
@@ -298,9 +323,6 @@ class StrixApp {
|
||||
|
||||
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) {
|
||||
@@ -312,8 +334,8 @@ class StrixApp {
|
||||
streamsSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update carousel
|
||||
this.carousel.render(this.currentStreams, (stream, index) => {
|
||||
// Update stream list
|
||||
this.streamList.render(this.currentStreams, (stream, index) => {
|
||||
this.selectStream(stream, index);
|
||||
});
|
||||
}
|
||||
@@ -338,16 +360,22 @@ class StrixApp {
|
||||
// Selecting main stream
|
||||
this.selectedMainStream = stream;
|
||||
this.selectedSubStream = null;
|
||||
this.frigateConfigGenerated = false; // Reset Frigate config state
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
this.showScreen('output');
|
||||
// Hide action buttons initially since Frigate tab is active by default
|
||||
document.querySelector('.actions').style.display = 'none';
|
||||
} else {
|
||||
// Selecting sub stream
|
||||
this.selectedSubStream = stream;
|
||||
this.isSelectingSubStream = false;
|
||||
this.frigateConfigGenerated = false; // Reset Frigate config state
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
this.showScreen('output');
|
||||
// Hide action buttons initially since Frigate tab is active by default
|
||||
document.querySelector('.actions').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,8 +397,16 @@ class StrixApp {
|
||||
|
||||
removeSubStream() {
|
||||
this.selectedSubStream = null;
|
||||
this.frigateConfigGenerated = false; // Reset Frigate config state when sub stream is removed
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
|
||||
// Hide action buttons if on Frigate tab
|
||||
const activeTab = document.querySelector('.tab.active').dataset.tab;
|
||||
if (activeTab === 'frigate') {
|
||||
document.querySelector('.actions').style.display = 'none';
|
||||
}
|
||||
|
||||
showToast('Sub stream removed');
|
||||
}
|
||||
|
||||
@@ -395,6 +431,10 @@ class StrixApp {
|
||||
document.getElementById('config-frigate').textContent = newConfig;
|
||||
document.getElementById('frigate-output-section').classList.remove('hidden');
|
||||
|
||||
// Mark as generated and show action buttons
|
||||
this.frigateConfigGenerated = true;
|
||||
document.querySelector('.actions').style.display = 'flex';
|
||||
|
||||
// Scroll to result
|
||||
document.getElementById('frigate-output-section').scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
@@ -429,6 +469,16 @@ class StrixApp {
|
||||
// Update tab panes
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active');
|
||||
|
||||
// Show/hide action buttons based on tab and Frigate config state
|
||||
const actionsContainer = document.querySelector('.actions');
|
||||
if (tabName === 'frigate' && !this.frigateConfigGenerated) {
|
||||
// Hide buttons on Frigate tab until config is generated
|
||||
actionsContainer.style.display = 'none';
|
||||
} else {
|
||||
// Show buttons for other tabs or after Frigate config is generated
|
||||
actionsContainer.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
copyConfig() {
|
||||
@@ -482,7 +532,7 @@ class StrixApp {
|
||||
document.getElementById('camera-model').value = '';
|
||||
document.getElementById('camera-model').disabled = false;
|
||||
document.getElementById('camera-model').placeholder = 'Start typing...';
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('username').value = 'admin'; // Reset to default value
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('channel').value = '0';
|
||||
document.getElementById('max-streams').value = '10';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Mock implementation of CameraSearchAPI for development
|
||||
export class MockCameraAPI {
|
||||
constructor() {
|
||||
this.mockCameras = [
|
||||
{ brand: "Hikvision", model: "DS-2CD2042WD-I" },
|
||||
{ brand: "Hikvision", model: "DS-2CD2142FWD-I" },
|
||||
{ brand: "Hikvision", model: "DS-2CD2032-I" },
|
||||
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
|
||||
{ brand: "Dahua", model: "IPC-HFW4431R-Z" },
|
||||
{ brand: "Dahua", model: "IPC-HDBW4433R-ZS" },
|
||||
{ brand: "Dahua", model: "DH-IPC-HFW2431S-S-S2" },
|
||||
{ brand: "Dahua", model: "IPC-HDW2531T-AS-S2" },
|
||||
{ brand: "Axis", model: "M3046-V" },
|
||||
{ brand: "Axis", model: "P3245-LVE" },
|
||||
{ brand: "Axis", model: "M5525-E" },
|
||||
{ brand: "Uniview", model: "IPC322SR3-DVS28-F" },
|
||||
{ brand: "Uniview", model: "IPC2124SR3-DPF40" },
|
||||
{ brand: "Reolink", model: "RLC-410" },
|
||||
{ brand: "Reolink", model: "RLC-520A" },
|
||||
{ brand: "Reolink", model: "RLC-810A" },
|
||||
{ brand: "TP-Link", model: "VIGI C300HP-4" },
|
||||
{ brand: "TP-Link", model: "VIGI C540V" },
|
||||
{ brand: "Amcrest", model: "IP8M-2496EW" },
|
||||
{ brand: "Amcrest", model: "IP4M-1041B" },
|
||||
{ brand: "Foscam", model: "FI9900P" },
|
||||
{ brand: "Foscam", model: "R5" },
|
||||
];
|
||||
}
|
||||
|
||||
async search(query, limit = 10) {
|
||||
// Simulate network delay
|
||||
await this.delay(150);
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const filtered = this.mockCameras.filter(camera => {
|
||||
const searchText = `${camera.brand} ${camera.model}`.toLowerCase();
|
||||
return searchText.includes(lowerQuery);
|
||||
});
|
||||
|
||||
return {
|
||||
cameras: filtered.slice(0, limit),
|
||||
total: filtered.length
|
||||
};
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Mock data for development and testing
|
||||
export const MOCK_CAMERAS = [
|
||||
{ brand: "Hikvision", model: "DS-2CD2143G0-I" },
|
||||
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
|
||||
{ brand: "Hikvision", model: "DS-2CD2T85G1-I8" },
|
||||
{ brand: "Dahua", model: "IPC-HFW5831E-Z5E" },
|
||||
{ brand: "Dahua", model: "IPC-HDW5831R-ZE" },
|
||||
{ brand: "Axis", model: "M3046-V" },
|
||||
{ brand: "Axis", model: "P3245-LVE" },
|
||||
{ brand: "Uniview", model: "IPC2324LB-ADZK-G" },
|
||||
{ brand: "Reolink", model: "RLC-810A" },
|
||||
{ brand: "TP-Link", model: "VIGI C540V" }
|
||||
];
|
||||
|
||||
export const MOCK_STREAMS = [
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100:554/stream1",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "h264",
|
||||
fps: 25,
|
||||
bitrate: 4096,
|
||||
audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100:554/stream2",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x360",
|
||||
codec: "h264",
|
||||
fps: 15,
|
||||
bitrate: 512,
|
||||
audio: true
|
||||
},
|
||||
{
|
||||
url: "http://admin:password@192.168.1.100:80/onvif/device_service",
|
||||
type: "ONVIF",
|
||||
resolution: "1920x1080",
|
||||
codec: "h264",
|
||||
fps: 25,
|
||||
bitrate: 4096,
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100/live/main",
|
||||
type: "FFMPEG",
|
||||
resolution: "2560x1440",
|
||||
codec: "h265",
|
||||
fps: 30,
|
||||
bitrate: 6144,
|
||||
audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100/live/sub",
|
||||
type: "FFMPEG",
|
||||
resolution: "704x576",
|
||||
codec: "h264",
|
||||
fps: 15,
|
||||
bitrate: 768,
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100:554/ch01/0",
|
||||
type: "FFMPEG",
|
||||
resolution: "3840x2160",
|
||||
codec: "h265",
|
||||
fps: 25,
|
||||
bitrate: 8192,
|
||||
audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100:554/ch01/1",
|
||||
type: "FFMPEG",
|
||||
resolution: "1280x720",
|
||||
codec: "h264",
|
||||
fps: 20,
|
||||
bitrate: 2048,
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
url: "http://admin:password@192.168.1.100:8080/video.mjpeg",
|
||||
type: "MJPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "mjpeg",
|
||||
fps: 10,
|
||||
bitrate: 3072,
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
url: "rtsp://admin:password@192.168.1.100/h264_stream",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "h264",
|
||||
fps: 30,
|
||||
bitrate: 4096,
|
||||
audio: true
|
||||
},
|
||||
{
|
||||
url: "http://admin:password@192.168.1.100:8081/stream.m3u8",
|
||||
type: "HLS",
|
||||
resolution: "1920x1080",
|
||||
codec: "h264",
|
||||
fps: 25,
|
||||
bitrate: 4096,
|
||||
audio: true
|
||||
}
|
||||
];
|
||||
|
||||
// Mock Camera Search API
|
||||
export class MockCameraSearch {
|
||||
async search(query, limit = 10) {
|
||||
// Simulate network delay
|
||||
await this.delay(100);
|
||||
|
||||
const results = MOCK_CAMERAS.filter(camera => {
|
||||
const searchStr = `${camera.brand} ${camera.model}`.toLowerCase();
|
||||
return searchStr.includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
cameras: results.slice(0, limit),
|
||||
total: results.length
|
||||
};
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Stream Discovery API
|
||||
export class MockStreamDiscovery {
|
||||
constructor() {
|
||||
this.isRunning = false;
|
||||
this.timeoutId = null;
|
||||
}
|
||||
|
||||
discover(request, callbacks) {
|
||||
this.isRunning = true;
|
||||
let tested = 0;
|
||||
const totalToTest = 516;
|
||||
const foundStreams = [...MOCK_STREAMS];
|
||||
|
||||
// Initial progress
|
||||
callbacks.onProgress({
|
||||
tested: 0,
|
||||
found: 0,
|
||||
remaining: totalToTest
|
||||
});
|
||||
|
||||
// Simulate progressive testing
|
||||
const progressInterval = setInterval(() => {
|
||||
if (!this.isRunning) {
|
||||
clearInterval(progressInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
tested += Math.floor(Math.random() * 30) + 20;
|
||||
if (tested > totalToTest) tested = totalToTest;
|
||||
|
||||
callbacks.onProgress({
|
||||
tested: tested,
|
||||
found: foundStreams.length,
|
||||
remaining: totalToTest - tested
|
||||
});
|
||||
|
||||
if (tested >= totalToTest) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Send found streams progressively
|
||||
let streamIndex = 0;
|
||||
const streamInterval = setInterval(() => {
|
||||
if (!this.isRunning) {
|
||||
clearInterval(streamInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamIndex < foundStreams.length) {
|
||||
callbacks.onStreamFound({
|
||||
stream: foundStreams[streamIndex]
|
||||
});
|
||||
streamIndex++;
|
||||
} else {
|
||||
clearInterval(streamInterval);
|
||||
}
|
||||
}, 800);
|
||||
|
||||
// Complete after ~7.7 seconds
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
callbacks.onComplete({
|
||||
total_found: foundStreams.length,
|
||||
duration: 7.7
|
||||
});
|
||||
|
||||
this.isRunning = false;
|
||||
}, 7700);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isRunning = false;
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Mock implementation of StreamDiscoveryAPI for development
|
||||
export class MockStreamAPI {
|
||||
constructor() {
|
||||
this.mockStreams = [
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
||||
path: "/Streaming/Channels/101",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 4096000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "http://192.168.1.100/snap.jpg",
|
||||
path: "/snap.jpg",
|
||||
type: "JPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "JPEG",
|
||||
fps: 1,
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
{
|
||||
url: "http://192.168.1.100/video.mjpg",
|
||||
path: "/video.mjpg",
|
||||
type: "MJPEG",
|
||||
resolution: "1280x720",
|
||||
codec: "MJPEG",
|
||||
fps: 10,
|
||||
bitrate: 2048000,
|
||||
has_audio: false
|
||||
},
|
||||
{
|
||||
url: "http://192.168.1.100/stream/live.m3u8",
|
||||
path: "/stream/live.m3u8",
|
||||
type: "HLS",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 3072000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "http://192.168.1.100/videostream.cgi?user=admin&pwd=12345",
|
||||
path: "/videostream.cgi?user=admin&pwd=12345",
|
||||
type: "HTTP_VIDEO",
|
||||
resolution: "1280x960",
|
||||
codec: "H.264",
|
||||
fps: 20,
|
||||
bitrate: 2048000,
|
||||
has_audio: false
|
||||
},
|
||||
{
|
||||
url: "bubble://192.168.1.100:34567/bubble/live?ch=0&stream=0",
|
||||
path: "/bubble/live?ch=0&stream=0",
|
||||
type: "BUBBLE",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 3072000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
||||
path: "/cam/realmonitor?channel=1&subtype=0",
|
||||
type: "ONVIF",
|
||||
resolution: "2560x1440",
|
||||
codec: "H.265",
|
||||
fps: 30,
|
||||
bitrate: 6144000,
|
||||
has_audio: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
discover(request, callbacks) {
|
||||
const totalToScan = 150;
|
||||
const streamsToFind = this.mockStreams;
|
||||
let tested = 0;
|
||||
let found = 0;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Simulate progressive discovery
|
||||
const interval = setInterval(() => {
|
||||
const increment = Math.floor(Math.random() * 8) + 3;
|
||||
tested = Math.min(tested + increment, totalToScan);
|
||||
const remaining = totalToScan - tested;
|
||||
|
||||
// Send progress event
|
||||
if (callbacks.onProgress) {
|
||||
callbacks.onProgress({
|
||||
tested: tested,
|
||||
found: found,
|
||||
remaining: remaining
|
||||
});
|
||||
}
|
||||
|
||||
// Randomly find streams
|
||||
if (found < streamsToFind.length && Math.random() > 0.6) {
|
||||
const stream = streamsToFind[found];
|
||||
found++;
|
||||
|
||||
if (callbacks.onStreamFound) {
|
||||
callbacks.onStreamFound({
|
||||
stream: stream
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Complete when done
|
||||
if (tested >= totalToScan) {
|
||||
clearInterval(interval);
|
||||
|
||||
// Send any remaining streams
|
||||
while (found < streamsToFind.length) {
|
||||
const stream = streamsToFind[found];
|
||||
found++;
|
||||
|
||||
if (callbacks.onStreamFound) {
|
||||
callbacks.onStreamFound({
|
||||
stream: stream
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (callbacks.onComplete) {
|
||||
callbacks.onComplete({
|
||||
total_tested: totalToScan,
|
||||
total_found: found,
|
||||
duration: duration
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
close() {
|
||||
// Nothing to close in mock mode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
export class StreamList {
|
||||
constructor() {
|
||||
this.listContainer = document.getElementById('streams-list');
|
||||
this.streams = [];
|
||||
this.onUseCallback = null;
|
||||
this.expandedIndex = null;
|
||||
}
|
||||
|
||||
render(streams, onUseCallback) {
|
||||
this.streams = streams;
|
||||
this.onUseCallback = onUseCallback;
|
||||
|
||||
// Render stream items
|
||||
this.listContainer.innerHTML = streams.map((stream, index) => this.renderItem(stream, index)).join('');
|
||||
|
||||
// Attach event listeners
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
renderItem(stream, index) {
|
||||
const icon = this.getStreamIcon(stream.type);
|
||||
const isExpanded = this.expandedIndex === index;
|
||||
const truncatedUrl = this.truncateURL(stream.url, 60);
|
||||
|
||||
return `
|
||||
<div class="stream-item ${isExpanded ? 'expanded' : ''}" data-index="${index}">
|
||||
<div class="stream-item-header" data-index="${index}">
|
||||
<div class="stream-item-main">
|
||||
<div class="stream-info-left">
|
||||
<div class="stream-type-badge">
|
||||
${icon}
|
||||
<span>${stream.type}</span>
|
||||
${this.getStreamTypeTooltip(stream.type)}
|
||||
</div>
|
||||
<div class="stream-url-preview">${truncatedUrl}</div>
|
||||
</div>
|
||||
<button class="stream-toggle" data-index="${index}" aria-label="Toggle details">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="chevron">
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-use-stream" data-index="${index}">Use Stream</button>
|
||||
</div>
|
||||
<div class="stream-item-details ${isExpanded ? 'visible' : ''}">
|
||||
<div class="stream-url-full">${stream.url}</div>
|
||||
${stream.resolution ? `<div class="stream-meta-item"><span class="meta-label">Resolution:</span> ${stream.resolution}</div>` : ''}
|
||||
${stream.codec ? `<div class="stream-meta-item"><span class="meta-label">Codec:</span> ${stream.codec}${stream.fps ? ` • ${stream.fps} fps` : ''}${stream.bitrate ? ` • ${Math.round(stream.bitrate / 1000)} Kbps` : ''}</div>` : ''}
|
||||
${stream.has_audio ? '<div class="stream-meta-item"><span class="meta-label">Audio:</span> Yes</div>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
truncateURL(url, maxLength = 60) {
|
||||
if (url.length <= maxLength) {
|
||||
return url;
|
||||
}
|
||||
return url.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
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>',
|
||||
'BUBBLE': '<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"/><circle cx="7" cy="9" r="1.5" fill="currentColor"/><circle cx="10" cy="9" r="1.5" fill="currentColor"/><circle cx="13" cy="9" r="1.5" fill="currentColor"/><path d="M6 13q2 2 4 2t4-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
|
||||
};
|
||||
return icons[type] || icons['FFMPEG'];
|
||||
}
|
||||
|
||||
getStreamTypeTooltip(type) {
|
||||
const tooltips = {
|
||||
'FFMPEG': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">FFMPEG Stream</div>
|
||||
<p class="tooltip-text">Standard video stream decoded by FFmpeg. Most compatible and widely supported format for RTSP, HTTP, and other protocols.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Universal compatibility</code>
|
||||
<code class="tooltip-example">✓ Supports H.264, H.265, MJPEG</code>
|
||||
<code class="tooltip-example">✓ Works with most cameras</code>
|
||||
<code class="tooltip-example">✓ Best for recording</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Main streams, recording, high-quality playback. Default choice for most use cases.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'ONVIF': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">ONVIF Stream</div>
|
||||
<p class="tooltip-text">Industry standard protocol for IP cameras. Discovered via ONVIF specification, ensuring maximum compatibility with camera features.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Standardized protocol</code>
|
||||
<code class="tooltip-example">✓ Auto-discovery support</code>
|
||||
<code class="tooltip-example">✓ PTZ control capable</code>
|
||||
<code class="tooltip-example">✓ Vendor-independent</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Enterprise cameras, systems requiring standardization, cameras with PTZ controls.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'JPEG': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">JPEG Snapshot</div>
|
||||
<p class="tooltip-text">Single static image endpoint. Can be converted to video stream by repeatedly fetching images.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Low bandwidth</code>
|
||||
<code class="tooltip-example">✓ Simple HTTP request</code>
|
||||
<code class="tooltip-example">✓ No streaming protocol needed</code>
|
||||
<code class="tooltip-example">⚠ Limited framerate (1-10 fps)</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Thumbnails, snapshots, very low bandwidth scenarios. Not recommended for recording.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'MJPEG': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">MJPEG Stream</div>
|
||||
<p class="tooltip-text">Motion JPEG - sequence of JPEG images transmitted continuously. Simple but bandwidth-intensive.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Simple HTTP streaming</code>
|
||||
<code class="tooltip-example">✓ No complex codecs</code>
|
||||
<code class="tooltip-example">✓ Frame-by-frame</code>
|
||||
<code class="tooltip-example">⚠ High bandwidth usage</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Sub streams, low-latency monitoring, simple camera integration. Higher bandwidth than H.264.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'HLS': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">HLS Stream</div>
|
||||
<p class="tooltip-text">HTTP Live Streaming - Apple's adaptive bitrate streaming protocol. Delivers video in small chunks over HTTP.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Adaptive bitrate</code>
|
||||
<code class="tooltip-example">✓ Wide browser support</code>
|
||||
<code class="tooltip-example">✓ Firewall-friendly (HTTP)</code>
|
||||
<code class="tooltip-example">⚠ Higher latency (5-30s)</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Web playback, public streaming, CDN delivery. Not ideal for real-time monitoring.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'HTTP_VIDEO': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">HTTP Video Stream</div>
|
||||
<p class="tooltip-text">Generic HTTP-based video stream. Simple protocol that works over standard web connections.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">✓ Simple HTTP protocol</code>
|
||||
<code class="tooltip-example">✓ No special ports</code>
|
||||
<code class="tooltip-example">✓ Firewall-friendly</code>
|
||||
<code class="tooltip-example">✓ Direct browser playback</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Best for:</strong> Quick viewing, simple setups, scenarios where RTSP ports are blocked.</p>
|
||||
</div>
|
||||
</span>
|
||||
`,
|
||||
'BUBBLE': `
|
||||
<span class="info-icon info-icon-stream">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="tooltip tooltip-down">
|
||||
<div class="tooltip-title">BUBBLE / DVRIP Protocol</div>
|
||||
<p class="tooltip-text">Proprietary protocol for Chinese DVR/NVR cameras. Also known as: ESeeCloud, dvr163, DVR-IP, NetSurveillance, Sofia protocol, XMeye SDK.</p>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Compatible brands:</div>
|
||||
<code class="tooltip-example">XMEye, Floureon, ZOSI</code>
|
||||
<code class="tooltip-example">Sannce, Annke, DVR163</code>
|
||||
<code class="tooltip-example">ESeeCloud, NetSurveillance</code>
|
||||
</div>
|
||||
<div class="tooltip-examples">
|
||||
<div class="tooltip-examples-title">Features:</div>
|
||||
<code class="tooltip-example">⚠ Proprietary protocol</code>
|
||||
<code class="tooltip-example">✓ Go2RTC converts to standard</code>
|
||||
<code class="tooltip-example">✓ Two-way audio support</code>
|
||||
<code class="tooltip-example">⚠ TCP only (port 34567)</code>
|
||||
</div>
|
||||
<p class="tooltip-text"><strong>Note:</strong> Automatically converted to standard RTSP format by Go2RTC. Works seamlessly with Frigate without additional configuration.</p>
|
||||
</div>
|
||||
</span>
|
||||
`
|
||||
};
|
||||
return tooltips[type] || '';
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
// Click on header to toggle
|
||||
this.listContainer.querySelectorAll('.stream-item-header').forEach(header => {
|
||||
header.addEventListener('click', (e) => {
|
||||
// Don't toggle if clicking "Use Stream" button
|
||||
if (e.target.closest('.btn-use-stream')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = parseInt(header.dataset.index);
|
||||
this.toggleExpand(index);
|
||||
});
|
||||
});
|
||||
|
||||
// Use Stream buttons
|
||||
this.listContainer.querySelectorAll('.btn-use-stream').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent toggle
|
||||
const index = parseInt(e.target.dataset.index);
|
||||
if (this.onUseCallback) {
|
||||
this.onUseCallback(this.streams[index], index);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleExpand(index) {
|
||||
if (this.expandedIndex === index) {
|
||||
// Collapse if already expanded
|
||||
this.expandedIndex = null;
|
||||
} else {
|
||||
// Expand new item
|
||||
this.expandedIndex = index;
|
||||
}
|
||||
|
||||
// Re-render to update state
|
||||
this.render(this.streams, this.onUseCallback);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user