export class StreamList { constructor() { this.listContainer = document.getElementById('streams-list'); this.streams = []; this.onUseCallback = null; this.expandedIndex = null; // Track collapsed state for groups and subgroups this.collapsedGroups = new Set(); this.collapsedSubgroups = new Set(); // Selection mode: 'main' or 'sub' this.selectionMode = 'main'; // Flag to apply smart defaults on first render after reset this.needsSmartDefaults = true; } /** * Set selection mode and apply smart defaults for collapsed state * Only resets collapsed state when mode actually changes */ setSelectionMode(mode) { if (this.selectionMode === mode) return; this.selectionMode = mode; this.applySmartDefaults(); } /** * Apply smart collapsed defaults based on current selection mode and available streams */ applySmartDefaults() { // Get current stream classification const recommended = this.streams.filter(s => this.isRecommended(s)); const { main, sub, other } = this.classifyRecommendedStreams( recommended.map((stream, i) => ({ stream, index: i })) ); // Reset all collapsed states this.collapsedGroups.clear(); this.collapsedSubgroups.clear(); if (this.selectionMode === 'main') { // Main mode: show Main, collapse Sub/Other/Alternative if (main.length > 0) { // Has main streams - collapse everything except Main this.collapsedGroups.add('alternative'); this.collapsedSubgroups.add('recommended-sub'); this.collapsedSubgroups.add('recommended-other'); } // If no main streams - leave everything open } else { // Sub mode: show Sub, collapse Main/Other/Alternative if (sub.length > 0) { // Has sub streams - collapse everything except Sub this.collapsedGroups.add('alternative'); this.collapsedSubgroups.add('recommended-main'); this.collapsedSubgroups.add('recommended-other'); } // If no sub streams - leave everything open } } // Stream types considered "recommended" (standard video streams) static RECOMMENDED_TYPES = ['FFMPEG', 'ONVIF']; // Minimum width threshold for Main streams (HD quality) static MIN_MAIN_WIDTH = 720; // Minimum gap between resolutions to split Main/Sub static MIN_GAP_FOR_SPLIT = 400; isRecommended(stream) { return StreamList.RECOMMENDED_TYPES.includes(stream.type); } /** * Parse resolution string "1920x1080" to width number * Returns null if resolution is missing or invalid */ parseResolutionWidth(resolution) { if (!resolution) return null; const match = resolution.match(/^(\d+)x(\d+)$/); if (!match) return null; return parseInt(match[1], 10); } /** * Classify recommended streams into Main/Sub/Other using clustering algorithm * * Algorithm: * 1. Streams with width >= 720 are candidates for Main * 2. Streams with width < 720 go to Sub * 3. Streams without resolution go to Other * 4. Among Main candidates, find max gap between sorted resolutions * 5. If gap > 400px, split into Main (higher) and Sub (lower) */ classifyRecommendedStreams(items) { const main = []; const sub = []; const other = []; // First pass: separate by resolution availability and threshold const mainCandidates = []; // width >= 720 items.forEach(item => { const width = this.parseResolutionWidth(item.stream.resolution); if (width === null) { other.push(item); } else if (width < StreamList.MIN_MAIN_WIDTH) { sub.push(item); } else { mainCandidates.push({ ...item, width }); } }); // If no main candidates or only one, no need to cluster if (mainCandidates.length <= 1) { mainCandidates.forEach(item => main.push({ stream: item.stream, index: item.index })); return { main, sub, other }; } // Sort candidates by width descending mainCandidates.sort((a, b) => b.width - a.width); // Find the largest gap between adjacent resolutions let maxGap = 0; let splitIndex = -1; for (let i = 0; i < mainCandidates.length - 1; i++) { const gap = mainCandidates[i].width - mainCandidates[i + 1].width; if (gap > maxGap) { maxGap = gap; splitIndex = i; } } // If max gap is significant, split into Main and Sub if (maxGap > StreamList.MIN_GAP_FOR_SPLIT && splitIndex >= 0) { mainCandidates.forEach((item, i) => { const cleanItem = { stream: item.stream, index: item.index }; if (i <= splitIndex) { main.push(cleanItem); } else { sub.push(cleanItem); } }); } else { // All candidates stay in Main mainCandidates.forEach(item => { main.push({ stream: item.stream, index: item.index }); }); } return { main, sub, other }; } render(streams, onUseCallback) { this.streams = streams; this.onUseCallback = onUseCallback; // Apply smart defaults on first render after reset if (this.needsSmartDefaults && streams.length > 0) { this.needsSmartDefaults = false; this.applySmartDefaults(); } // Split streams into groups while preserving original indices const recommended = []; const alternative = []; streams.forEach((stream, index) => { if (this.isRecommended(stream)) { recommended.push({ stream, index }); } else { alternative.push({ stream, index }); } }); // Render only non-empty groups let html = ''; if (recommended.length > 0) { html += this.renderRecommendedGroup(recommended); } if (alternative.length > 0) { html += this.renderGroup('Alternative', alternative, 'alternative'); } this.listContainer.innerHTML = html; // Attach event listeners this.attachEventListeners(); } /** * Render Recommended group with Main/Sub/Other subgroups */ renderRecommendedGroup(items) { const { main, sub, other } = this.classifyRecommendedStreams(items); const totalCount = items.length; const isCollapsed = this.collapsedGroups.has('recommended'); let subgroupsHtml = ''; if (main.length > 0) { subgroupsHtml += this.renderSubgroup('Main', main, 'recommended'); } if (sub.length > 0) { subgroupsHtml += this.renderSubgroup('Sub', sub, 'recommended'); } if (other.length > 0) { subgroupsHtml += this.renderSubgroup('Other', other, 'recommended'); } return ` `; } /** * Render a subgroup (Main/Sub/Other) within Recommended */ renderSubgroup(title, items, parentGroup) { const subgroupKey = `${parentGroup}-${title.toLowerCase()}`; const isCollapsed = this.collapsedSubgroups.has(subgroupKey); return `
${title} (${items.length})
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
`; } renderGroup(title, items, groupClass) { const count = items.length; const isCollapsed = this.collapsedGroups.has(groupClass); return `
${title} (${count})
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
`; } renderItem(stream, index) { const icon = this.getStreamIcon(stream.type); const isExpanded = this.expandedIndex === index; const truncatedUrl = this.truncateURL(stream.url, 60); return `
${icon} ${stream.type} ${this.getStreamTypeTooltip(stream.type)}
${truncatedUrl}
${stream.url}
${stream.resolution ? `
Resolution: ${stream.resolution}
` : ''} ${stream.codec ? `
Codec: ${stream.codec}${stream.fps ? ` • ${stream.fps} fps` : ''}${stream.bitrate ? ` • ${Math.round(stream.bitrate / 1000)} Kbps` : ''}
` : ''} ${stream.has_audio ? '
Audio: Yes
' : ''}
`; } truncateURL(url, maxLength = 60) { if (url.length <= maxLength) { return url; } return url.substring(0, maxLength) + '...'; } getStreamIcon(type) { const icons = { 'FFMPEG': '', 'ONVIF': '', 'JPEG': '', 'MJPEG': '', 'HLS': '', 'HTTP_VIDEO': '', 'BUBBLE': '' }; return icons[type] || icons['FFMPEG']; } getStreamTypeTooltip(type) { const tooltips = { 'FFMPEG': `
FFMPEG Stream

Standard video stream decoded by FFmpeg. Most compatible and widely supported format for RTSP, HTTP, and other protocols.

Features:
✓ Universal compatibility ✓ Supports H.264, H.265, MJPEG ✓ Works with most cameras ✓ Best for recording

Best for: Main streams, recording, high-quality playback. Default choice for most use cases.

`, 'ONVIF': `
ONVIF Stream

Industry standard protocol for IP cameras. Discovered via ONVIF specification, ensuring maximum compatibility with camera features.

Features:
✓ Standardized protocol ✓ Auto-discovery support ✓ PTZ control capable ✓ Vendor-independent

Best for: Enterprise cameras, systems requiring standardization, cameras with PTZ controls.

`, 'JPEG': `
JPEG Snapshot

Single static image endpoint. Can be converted to video stream by repeatedly fetching images.

Features:
✓ Low bandwidth ✓ Simple HTTP request ✓ No streaming protocol needed ⚠ Limited framerate (1-10 fps)

Best for: Thumbnails, snapshots, very low bandwidth scenarios. Not recommended for recording.

`, 'MJPEG': `
MJPEG Stream

Motion JPEG - sequence of JPEG images transmitted continuously. Simple but bandwidth-intensive.

Features:
✓ Simple HTTP streaming ✓ No complex codecs ✓ Frame-by-frame ⚠ High bandwidth usage

Best for: Sub streams, low-latency monitoring, simple camera integration. Higher bandwidth than H.264.

`, 'HLS': `
HLS Stream

HTTP Live Streaming - Apple's adaptive bitrate streaming protocol. Delivers video in small chunks over HTTP.

Features:
✓ Adaptive bitrate ✓ Wide browser support ✓ Firewall-friendly (HTTP) ⚠ Higher latency (5-30s)

Best for: Web playback, public streaming, CDN delivery. Not ideal for real-time monitoring.

`, 'HTTP_VIDEO': `
HTTP Video Stream

Generic HTTP-based video stream. Simple protocol that works over standard web connections.

Features:
✓ Simple HTTP protocol ✓ No special ports ✓ Firewall-friendly ✓ Direct browser playback

Best for: Quick viewing, simple setups, scenarios where RTSP ports are blocked.

`, 'BUBBLE': `
BUBBLE / DVRIP Protocol

Proprietary protocol for Chinese DVR/NVR cameras. Also known as: ESeeCloud, dvr163, DVR-IP, NetSurveillance, Sofia protocol, XMeye SDK.

Compatible brands:
XMEye, Floureon, ZOSI Sannce, Annke, DVR163 ESeeCloud, NetSurveillance
Features:
⚠ Proprietary protocol ✓ Go2RTC converts to standard ✓ Two-way audio support ⚠ TCP only (port 34567)

Note: Automatically converted to standard RTSP format by Go2RTC. Works seamlessly with Frigate without additional configuration.

` }; return tooltips[type] || ''; } attachEventListeners() { // Group header toggle (Recommended, Alternative) this.listContainer.querySelectorAll('.stream-group-header').forEach(header => { header.addEventListener('click', (e) => { const groupKey = header.dataset.group; if (groupKey) { this.toggleGroup(groupKey); } }); }); // Subgroup header toggle (Main, Sub, Other) this.listContainer.querySelectorAll('.stream-subgroup-header').forEach(header => { header.addEventListener('click', (e) => { e.stopPropagation(); // Don't bubble to group header const subgroupKey = header.dataset.subgroup; if (subgroupKey) { this.toggleSubgroup(subgroupKey); } }); }); // Click on stream item header to toggle details 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); } }); }); } toggleGroup(groupKey) { if (this.collapsedGroups.has(groupKey)) { this.collapsedGroups.delete(groupKey); } else { this.collapsedGroups.add(groupKey); } this.render(this.streams, this.onUseCallback); } toggleSubgroup(subgroupKey) { if (this.collapsedSubgroups.has(subgroupKey)) { this.collapsedSubgroups.delete(subgroupKey); } else { this.collapsedSubgroups.add(subgroupKey); } this.render(this.streams, this.onUseCallback); } 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); } }