Merge develop into main for v1.0.9 release

This commit is contained in:
eduard256
2025-12-11 16:40:39 +00:00
10 changed files with 772 additions and 58 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.9] - 2025-12-11
### Fixed
- Fixed real-time SSE streaming in Home Assistant Ingress mode
- SSE events now arrive immediately instead of being buffered until completion
### Technical
- Added automatic detection of Home Assistant Ingress via X-Ingress-Path header
- Implemented 64KB padding for SSE events to overcome aiohttp buffer in HA Supervisor
- Adjusted progress update interval to 3 seconds in Ingress mode to reduce traffic
- Normal mode (Docker/direct access) remains unchanged
## [1.0.8] - 2025-11-26
### Changed
+1 -1
View File
@@ -20,7 +20,7 @@ import (
const (
// Version is the application version
Version = "1.0.7"
Version = "1.0.9"
// Banner is the application banner
Banner = `
+9 -2
View File
@@ -411,7 +411,14 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
defer cancelProgress()
go func() {
ticker := time.NewTicker(1 * time.Second)
// Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
// Normal mode: 1 second, Ingress mode: 3 seconds
progressInterval := 1 * time.Second
if streamWriter.IsIngress() {
progressInterval = 3 * time.Second
}
ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for {
@@ -419,7 +426,7 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
case <-progressCtx.Done():
return
case <-ticker.C:
// Send progress every second to prevent WriteTimeout
// Send progress to prevent WriteTimeout and show scanning activity
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
Tested: int(atomic.LoadInt32(&tested)),
Found: int(atomic.LoadInt32(&found)),
+67 -5
View File
@@ -5,9 +5,20 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
const (
// IngressPaddingSize is the padding size for Home Assistant Ingress mode.
// HA Supervisor uses aiohttp with 64KB buffer for StreamResponse.
// We need to fill this buffer to force immediate delivery of SSE events.
IngressPaddingSize = 64 * 1024 // 64KB
// IngressHeader is the header that Home Assistant Ingress adds to requests
IngressHeader = "X-Ingress-Path"
)
// Event represents a Server-Sent Event
type Event struct {
ID string
@@ -253,8 +264,9 @@ func generateClientID() string {
// StreamWriter provides a simple interface for writing SSE events
type StreamWriter struct {
client *Client
server *Server
client *Client
server *Server
isIngress bool // True when running through Home Assistant Ingress proxy
}
// NewStreamWriter creates a new stream writer for a client
@@ -275,6 +287,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
// Send initial flush to establish connection
flusher.Flush()
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
isIngress := r.Header.Get(IngressHeader) != ""
// Create client
ctx, cancel := context.WithCancel(r.Context())
client := &Client{
@@ -287,8 +302,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
}
return &StreamWriter{
client: client,
server: s,
client: client,
server: s,
isIngress: isIngress,
}, nil
}
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
return fmt.Errorf("response does not support flushing")
}
return sw.server.writeEvent(sw.client.Response, flusher, event)
// Use Ingress-aware write method
return sw.writeEventWithIngress(sw.client.Response, flusher, event)
}
// writeEventWithIngress writes an event and adds padding for Ingress mode
func (sw *StreamWriter) writeEventWithIngress(w http.ResponseWriter, flusher http.Flusher, event Event) error {
// Write the event using standard method
if err := sw.server.writeEvent(w, flusher, event); err != nil {
return err
}
// In Ingress mode, add padding to fill the 64KB buffer and force immediate delivery
if sw.isIngress {
if err := sw.writePadding(w, flusher); err != nil {
return err
}
}
return nil
}
// writePadding writes SSE comment padding to fill proxy buffers.
// SSE comments (lines starting with ':') are ignored by clients.
func (sw *StreamWriter) writePadding(w http.ResponseWriter, flusher http.Flusher) error {
// Create padding using SSE comments which are ignored by clients
// Each line is ": " + padding content + "\n"
// We need ~64KB to fill the aiohttp StreamResponse buffer
const lineSize = 1024 // 1KB per line
const numLines = 64 // 64 lines = 64KB
paddingLine := ": " + strings.Repeat(".", lineSize-4) + "\n" // -4 for ": " and "\n"
for i := 0; i < numLines; i++ {
if _, err := fmt.Fprint(w, paddingLine); err != nil {
return err
}
}
// Flush the padding
flusher.Flush()
return nil
}
// SendJSON sends JSON data as an event
@@ -312,6 +369,11 @@ func (sw *StreamWriter) SendJSON(eventType string, v interface{}) error {
return sw.SendEvent(eventType, v)
}
// IsIngress returns true if running through Home Assistant Ingress proxy
func (sw *StreamWriter) IsIngress() bool {
return sw.isIngress
}
// SendMessage sends a simple message
func (sw *StreamWriter) SendMessage(message string) error {
return sw.SendEvent("message", map[string]string{"message": message})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "webui",
"version": "1.0.4",
"version": "1.0.9",
"type": "module",
"description": "",
"main": "index.js",
+146 -1
View File
@@ -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;
@@ -252,6 +252,15 @@ export class FrigateGenerator {
return lines;
}
/**
* Build RTSP path with optional ?mp4 suffix for BUBBLE streams
*/
static buildRtspPath(streamName, streamType) {
const basePath = `rtsp://127.0.0.1:8554/${streamName}`;
// Add ?mp4 parameter only for BUBBLE streams to enable recording in Frigate
return streamType === 'BUBBLE' ? `${basePath}?mp4` : basePath;
}
/**
* Generate camera lines for cameras section
*/
@@ -264,11 +273,14 @@ export class FrigateGenerator {
if (cameraInfo.subStream) {
// Use sub for detect, main for record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.subStreamName}`);
const subPath = this.buildRtspPath(cameraInfo.subStreamName, cameraInfo.subStream.type);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${subPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - record');
@@ -280,7 +292,9 @@ export class FrigateGenerator {
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
} else {
// Use main for both detect and record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
+12 -1
View File
@@ -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');
}
+207 -41
View File
@@ -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() {
+300 -3
View File
@@ -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