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 `