Redesign stream discovery UI with vertical list layout

- Replace carousel navigation with scrollable vertical list
- Remove statistics counters (Tested/Found/Remaining)
- Add collapsible stream details with expand/collapse toggle
- Show stream URL preview in header, full URL in details
- Position URL below stream type badge for better readability
- Add new StreamList component replacing StreamCarousel
- Update CSS with improved layout and hover effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
eduard256
2025-11-21 23:25:30 +03:00
parent 71d6f2aac8
commit 779ae33bac
4 changed files with 244 additions and 167 deletions
+128 -103
View File
@@ -586,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 ===== */
+1 -36
View File
@@ -216,46 +216,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>
+4 -28
View File
@@ -3,7 +3,7 @@ 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';
@@ -30,7 +30,7 @@ class StrixApp {
}
this.searchForm = new SearchForm();
this.carousel = new StreamCarousel();
this.streamList = new StreamList();
this.configPanel = new ConfigPanel();
this.currentAddress = '';
@@ -95,24 +95,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;
@@ -303,9 +285,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 = [];
}
@@ -316,9 +295,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) {
@@ -330,8 +306,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);
});
}
+111
View File
@@ -0,0 +1,111 @@
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>
</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>'
};
return icons[type] || icons['FFMPEG'];
}
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);
}
}