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:
+128
-103
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user