Add stream categorization: Recommended/Alternative with Main/Sub/Other subgroups
- Split discovered streams into Recommended (FFMPEG, ONVIF) and Alternative groups - Add Main/Sub/Other classification within Recommended based on resolution: - Main: streams with width >= 720px (after clustering analysis) - Sub: streams with width < 720px or lower cluster - Other: streams without resolution info - Implement smart auto-collapse based on selection mode: - Main selection: shows Recommended/Main, collapses rest - Sub selection: shows Recommended/Sub, collapses rest - Falls back to showing all if target category is empty - Add collapsible groups/subgroups with chevron toggles - User manual expand/collapse preserved until mode change - Add stream count badges to all group headers
This commit is contained in:
+146
-1
@@ -590,10 +590,155 @@ body {
|
||||
.streams-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
/* ===== STREAM GROUPS ===== */
|
||||
.stream-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stream-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-group-header:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.stream-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-group-toggle .chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-group.collapsed .stream-group-toggle .chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.stream-group.collapsed .stream-group-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-group-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stream-group-count {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stream-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stream-group-empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== STREAM SUBGROUPS (Main/Sub/Other within Recommended) ===== */
|
||||
.stream-subgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.stream-subgroup:not(:last-child) {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.stream-subgroup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: var(--space-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-subgroup-header:hover {
|
||||
color: var(--purple-primary);
|
||||
}
|
||||
|
||||
.stream-subgroup-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-subgroup-toggle .chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.stream-subgroup.collapsed .stream-subgroup-toggle .chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.stream-subgroup.collapsed .stream-subgroup-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-subgroup-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stream-subgroup-count {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.stream-subgroup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.streams-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
+12
-1
@@ -315,6 +315,11 @@ class StrixApp {
|
||||
document.getElementById('progress-text').textContent = 'Starting scan...';
|
||||
document.getElementById('streams-section').classList.add('hidden');
|
||||
this.currentStreams = [];
|
||||
// Reset stream list state for fresh discovery
|
||||
this.streamList.selectionMode = 'main';
|
||||
this.streamList.collapsedGroups.clear();
|
||||
this.streamList.collapsedSubgroups.clear();
|
||||
this.streamList.needsSmartDefaults = true;
|
||||
}
|
||||
|
||||
handleProgress(data) {
|
||||
@@ -334,7 +339,7 @@ class StrixApp {
|
||||
streamsSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update stream list
|
||||
// Update stream list (smart defaults applied automatically on first render)
|
||||
this.streamList.render(this.currentStreams, (stream, index) => {
|
||||
this.selectStream(stream, index);
|
||||
});
|
||||
@@ -391,6 +396,12 @@ class StrixApp {
|
||||
document.getElementById('frigate-output-section').classList.add('hidden');
|
||||
document.getElementById('config-frigate').textContent = '';
|
||||
|
||||
// Set stream list to sub selection mode (will collapse Main, show Sub)
|
||||
this.streamList.setSelectionMode('sub');
|
||||
this.streamList.render(this.currentStreams, (stream, index) => {
|
||||
this.selectStream(stream, index);
|
||||
});
|
||||
|
||||
showToast('Select a sub stream from available streams');
|
||||
this.showScreen('discovery');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export class MockStreamAPI {
|
||||
constructor() {
|
||||
this.mockStreams = [
|
||||
// RTSP Main streams (1920x1080)
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
||||
path: "/Streaming/Channels/101",
|
||||
@@ -12,6 +13,27 @@ export class MockStreamAPI {
|
||||
bitrate: 4096000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/live/main",
|
||||
path: "/live/main",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 30,
|
||||
bitrate: 4608000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/stream1",
|
||||
path: "/stream1",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.265",
|
||||
fps: 25,
|
||||
bitrate: 3584000,
|
||||
has_audio: true
|
||||
},
|
||||
// JPEG snapshots (5 items in different positions)
|
||||
{
|
||||
url: "http://192.168.1.100/snap.jpg",
|
||||
path: "/snap.jpg",
|
||||
@@ -22,16 +44,124 @@ export class MockStreamAPI {
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
// RTSP Sub streams (640x480)
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/Streaming/Channels/102",
|
||||
path: "/Streaming/Channels/102",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 5,
|
||||
bitrate: 512000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/live/sub",
|
||||
path: "/live/sub",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 10,
|
||||
bitrate: 768000,
|
||||
has_audio: false
|
||||
},
|
||||
// JPEG #2
|
||||
{
|
||||
url: "http://192.168.1.100/cgi-bin/snapshot.cgi",
|
||||
path: "/cgi-bin/snapshot.cgi",
|
||||
type: "JPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "JPEG",
|
||||
fps: 1,
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/stream2",
|
||||
path: "/stream2",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 15,
|
||||
bitrate: 640000,
|
||||
has_audio: false
|
||||
},
|
||||
// ONVIF streams
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/onvif/profile0",
|
||||
path: "/onvif/profile0",
|
||||
type: "ONVIF",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 4096000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/onvif/profile1",
|
||||
path: "/onvif/profile1",
|
||||
type: "ONVIF",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 15,
|
||||
bitrate: 512000,
|
||||
has_audio: false
|
||||
},
|
||||
// JPEG #3
|
||||
{
|
||||
url: "http://192.168.1.100/image/jpeg.cgi",
|
||||
path: "/image/jpeg.cgi",
|
||||
type: "JPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "JPEG",
|
||||
fps: 1,
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
// More RTSP variants
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
||||
path: "/cam/realmonitor?channel=1&subtype=0",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.265",
|
||||
fps: 30,
|
||||
bitrate: 5120000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=1",
|
||||
path: "/cam/realmonitor?channel=1&subtype=1",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 10,
|
||||
bitrate: 512000,
|
||||
has_audio: false
|
||||
},
|
||||
// MJPEG
|
||||
{
|
||||
url: "http://192.168.1.100/video.mjpg",
|
||||
path: "/video.mjpg",
|
||||
type: "MJPEG",
|
||||
resolution: "1280x720",
|
||||
resolution: "1920x1080",
|
||||
codec: "MJPEG",
|
||||
fps: 10,
|
||||
bitrate: 2048000,
|
||||
bitrate: 3072000,
|
||||
has_audio: false
|
||||
},
|
||||
// JPEG #4
|
||||
{
|
||||
url: "http://192.168.1.100/Streaming/channels/1/picture",
|
||||
path: "/Streaming/channels/1/picture",
|
||||
type: "JPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "JPEG",
|
||||
fps: 1,
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
// HLS
|
||||
{
|
||||
url: "http://192.168.1.100/stream/live.m3u8",
|
||||
path: "/stream/live.m3u8",
|
||||
@@ -42,16 +172,18 @@ export class MockStreamAPI {
|
||||
bitrate: 3072000,
|
||||
has_audio: true
|
||||
},
|
||||
// HTTP Video
|
||||
{
|
||||
url: "http://192.168.1.100/videostream.cgi?user=admin&pwd=12345",
|
||||
path: "/videostream.cgi?user=admin&pwd=12345",
|
||||
type: "HTTP_VIDEO",
|
||||
resolution: "1280x960",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 20,
|
||||
bitrate: 2048000,
|
||||
bitrate: 2560000,
|
||||
has_audio: false
|
||||
},
|
||||
// BUBBLE
|
||||
{
|
||||
url: "bubble://192.168.1.100:34567/bubble/live?ch=0&stream=0",
|
||||
path: "/bubble/live?ch=0&stream=0",
|
||||
@@ -59,33 +191,75 @@ export class MockStreamAPI {
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 3072000,
|
||||
bitrate: 3584000,
|
||||
has_audio: true
|
||||
},
|
||||
// JPEG #5
|
||||
{
|
||||
url: "http://192.168.1.100/tmpfs/auto.jpg",
|
||||
path: "/tmpfs/auto.jpg",
|
||||
type: "JPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "JPEG",
|
||||
fps: 1,
|
||||
bitrate: 0,
|
||||
has_audio: false
|
||||
},
|
||||
// Additional RTSP
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/h264_stream",
|
||||
path: "/h264_stream",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 30,
|
||||
bitrate: 4096000,
|
||||
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",
|
||||
url: "rtsp://192.168.1.100:554/av0_0",
|
||||
path: "/av0_0",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.264",
|
||||
fps: 25,
|
||||
bitrate: 3840000,
|
||||
has_audio: true
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/av0_1",
|
||||
path: "/av0_1",
|
||||
type: "FFMPEG",
|
||||
resolution: "640x480",
|
||||
codec: "H.264",
|
||||
fps: 10,
|
||||
bitrate: 512000,
|
||||
has_audio: false
|
||||
},
|
||||
{
|
||||
url: "rtsp://192.168.1.100:554/unicast/c1/s0/live",
|
||||
path: "/unicast/c1/s0/live",
|
||||
type: "FFMPEG",
|
||||
resolution: "1920x1080",
|
||||
codec: "H.265",
|
||||
fps: 30,
|
||||
bitrate: 6144000,
|
||||
fps: 25,
|
||||
bitrate: 4608000,
|
||||
has_audio: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
discover(request, callbacks) {
|
||||
const totalToScan = 150;
|
||||
const totalToScan = 450;
|
||||
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;
|
||||
// Simulate progressive discovery - 1 stream per second
|
||||
const progressInterval = setInterval(() => {
|
||||
const increment = Math.floor(Math.random() * 15) + 10;
|
||||
tested = Math.min(tested + increment, totalToScan);
|
||||
const remaining = totalToScan - tested;
|
||||
|
||||
@@ -98,33 +272,9 @@ export class MockStreamAPI {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
clearInterval(progressInterval);
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
@@ -136,7 +286,23 @@ export class MockStreamAPI {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
}, 300);
|
||||
|
||||
// Find streams at ~1 per second
|
||||
const streamInterval = setInterval(() => {
|
||||
if (found < streamsToFind.length) {
|
||||
const stream = streamsToFind[found];
|
||||
found++;
|
||||
|
||||
if (callbacks.onStreamFound) {
|
||||
callbacks.onStreamFound({
|
||||
stream: stream
|
||||
});
|
||||
}
|
||||
} else {
|
||||
clearInterval(streamInterval);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@@ -4,19 +4,277 @@ export class StreamList {
|
||||
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;
|
||||
|
||||
// Render stream items
|
||||
this.listContainer.innerHTML = streams.map((stream, index) => this.renderItem(stream, index)).join('');
|
||||
// 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 `
|
||||
<div class="stream-group stream-group-recommended ${isCollapsed ? 'collapsed' : ''}">
|
||||
<div class="stream-group-header" data-group="recommended">
|
||||
<button class="stream-group-toggle" aria-label="Toggle group">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
||||
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="stream-group-title">Recommended</span>
|
||||
<span class="stream-group-count">(${totalCount})</span>
|
||||
</div>
|
||||
<div class="stream-group-content">
|
||||
${subgroupsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a subgroup (Main/Sub/Other) within Recommended
|
||||
*/
|
||||
renderSubgroup(title, items, parentGroup) {
|
||||
const subgroupKey = `${parentGroup}-${title.toLowerCase()}`;
|
||||
const isCollapsed = this.collapsedSubgroups.has(subgroupKey);
|
||||
|
||||
return `
|
||||
<div class="stream-subgroup ${isCollapsed ? 'collapsed' : ''}" data-subgroup="${subgroupKey}">
|
||||
<div class="stream-subgroup-header" data-subgroup="${subgroupKey}">
|
||||
<button class="stream-subgroup-toggle" aria-label="Toggle subgroup">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" class="chevron">
|
||||
<path d="M2.5 3.75l2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="stream-subgroup-title">${title}</span>
|
||||
<span class="stream-subgroup-count">(${items.length})</span>
|
||||
</div>
|
||||
<div class="stream-subgroup-content">
|
||||
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderGroup(title, items, groupClass) {
|
||||
const count = items.length;
|
||||
const isCollapsed = this.collapsedGroups.has(groupClass);
|
||||
|
||||
return `
|
||||
<div class="stream-group stream-group-${groupClass} ${isCollapsed ? 'collapsed' : ''}">
|
||||
<div class="stream-group-header" data-group="${groupClass}">
|
||||
<button class="stream-group-toggle" aria-label="Toggle group">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
||||
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="stream-group-title">${title}</span>
|
||||
<span class="stream-group-count">(${count})</span>
|
||||
</div>
|
||||
<div class="stream-group-content">
|
||||
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderItem(stream, index) {
|
||||
const icon = this.getStreamIcon(stream.type);
|
||||
const isExpanded = this.expandedIndex === index;
|
||||
@@ -225,7 +483,28 @@ export class StreamList {
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
// Click on header to toggle
|
||||
// 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
|
||||
@@ -250,6 +529,24 @@ export class StreamList {
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user