Improve camera model search with per-model ranking and two-stage loading

- Split camera results into individual models (Brand: Model format)
- Add model-specific relevance scoring for better search results
- Implement two-stage autocomplete: 10 results immediately, 50 after 1 second
- Filter out "Other" models from search results
- Sort models by relevance score (exact matches first)
- Add auto.json brand for automatic detection fallback

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
eduard256
2025-11-06 00:43:03 +03:00
parent 1cfc2fa2e5
commit 74fe12bcf1
15 changed files with 2222 additions and 16 deletions
+27
View File
@@ -0,0 +1,27 @@
export class CameraSearchAPI {
constructor(baseURL = null) {
// Auto-detect API URL based on current host
if (!baseURL) {
const currentHost = window.location.hostname;
this.baseURL = `http://${currentHost}:8080`;
} else {
this.baseURL = baseURL;
}
}
async search(query, limit = 10) {
const response = await fetch(`${this.baseURL}/api/v1/cameras/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, limit }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
}
+101
View File
@@ -0,0 +1,101 @@
export class StreamDiscoveryAPI {
constructor(baseURL = null) {
// Auto-detect API URL based on current host
if (!baseURL) {
const currentHost = window.location.hostname;
this.baseURL = `http://${currentHost}:8080`;
} else {
this.baseURL = baseURL;
}
this.eventSource = null;
}
discover(request, callbacks) {
this.close();
const url = new URL(`${this.baseURL}/api/v1/streams/discover`);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify(request),
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const processStream = ({ done, value }) => {
if (done) {
return;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('event:')) {
const eventType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
try {
const parsed = JSON.parse(data);
this.handleEvent(parsed, callbacks);
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
return reader.read().then(processStream);
};
return reader.read().then(processStream);
}).catch(error => {
if (callbacks.onError) {
callbacks.onError(error.message);
}
});
}
handleEvent(data, callbacks) {
// Determine event type from data
if (data.tested !== undefined && data.found !== undefined) {
// Progress event
if (callbacks.onProgress) {
callbacks.onProgress(data);
}
} else if (data.stream) {
// Stream found event
if (callbacks.onStreamFound) {
callbacks.onStreamFound(data);
}
} else if (data.total_tested !== undefined) {
// Complete event
if (callbacks.onComplete) {
callbacks.onComplete(data);
}
} else if (data.error) {
// Error event
if (callbacks.onError) {
callbacks.onError(data.error);
}
}
}
close() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
@@ -0,0 +1,48 @@
export class FrigateGenerator {
static generate(stream) {
// For non-RTSP streams, suggest using Go2RTC
if (stream.type !== 'FFMPEG' || stream.protocol !== 'rtsp') {
return `# This stream type requires Go2RTC proxy\n\n` +
`# This ${stream.type} stream is not natively supported by Frigate.\n` +
`# Please use Go2RTC to convert it to RTSP first.\n\n` +
`# Steps:\n` +
`# 1. Add this stream to your Go2RTC configuration\n` +
`# 2. Use the Go2RTC RTSP endpoint in Frigate\n` +
`# 3. Example: rtsp://localhost:8554/camera_stream_0`;
}
// Generate RTSP config for Frigate
const cameraName = this.generateCameraName(stream);
const config = [];
config.push(`cameras:`);
config.push(` ${cameraName}:`);
config.push(` ffmpeg:`);
config.push(` inputs:`);
config.push(` - path: ${stream.url}`);
config.push(` roles:`);
config.push(` - detect`);
config.push(` - record`);
if (stream.resolution) {
config.push(` detect:`);
const [width, height] = stream.resolution.split('x').map(Number);
if (width && height) {
config.push(` width: ${width}`);
config.push(` height: ${height}`);
}
}
return config.join('\n');
}
static generateCameraName(stream) {
try {
const urlObj = new URL(stream.url);
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
return `camera_${ip}`;
} catch (e) {
return 'camera';
}
}
}
@@ -0,0 +1,87 @@
export class Go2RTCGenerator {
static generate(stream) {
const streamName = this.generateStreamName(stream);
switch (stream.type) {
case 'FFMPEG':
if (stream.protocol === 'rtsp') {
return this.generateRTSP(streamName, stream);
}
break;
case 'JPEG':
return this.generateJPEG(streamName, stream);
case 'MJPEG':
return this.generateMJPEG(streamName, stream);
case 'HTTP_VIDEO':
return this.generateHTTPVideo(streamName, stream);
case 'HLS':
return this.generateHLS(streamName, stream);
case 'ONVIF':
return `# ONVIF Device Service\n# This is a device management endpoint, not a stream\n# URL: ${stream.url}`;
default:
return this.generateRTSP(streamName, stream);
}
}
static generateStreamName(stream) {
try {
const urlObj = new URL(stream.url);
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
return `${ip}_0`;
} catch (e) {
return 'camera_stream_0';
}
}
static generateRTSP(streamName, stream) {
return `streams:\n '${streamName}':\n - ${stream.url}`;
}
static generateJPEG(streamName, stream) {
const framerate = 10;
const ffmpegCmd = [
'exec:ffmpeg',
'-loglevel quiet',
'-f image2',
'-loop 1',
`-framerate ${framerate}`,
`-i ${stream.url}`,
'-c:v libx264',
'-preset ultrafast',
'-tune zerolatency',
'-g 20',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateMJPEG(streamName, stream) {
const ffmpegCmd = [
'exec:ffmpeg',
'-loglevel quiet',
`-i ${stream.url}`,
'-c:v copy',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateHTTPVideo(streamName, stream) {
const ffmpegCmd = [
'exec:ffmpeg',
'-loglevel quiet',
`-i ${stream.url}`,
'-c:v copy',
'-c:a copy',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateHLS(streamName, stream) {
return `streams:\n '${streamName}':\n - ${stream.url}`;
}
}
+385
View File
@@ -0,0 +1,385 @@
import { CameraSearchAPI } from './api/camera-search.js';
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
import { SearchForm } from './ui/search-form.js';
import { StreamCarousel } from './ui/stream-carousel.js';
import { ConfigPanel } from './ui/config-panel.js';
import { showToast } from './utils/toast.js';
class StrixApp {
constructor() {
this.cameraAPI = new CameraSearchAPI();
this.streamAPI = new StreamDiscoveryAPI();
this.searchForm = new SearchForm();
this.carousel = new StreamCarousel();
this.configPanel = new ConfigPanel();
this.currentAddress = '';
this.currentStreams = [];
this.currentStream = null;
this.init();
}
init() {
this.setupEventListeners();
this.showScreen('address');
}
setupEventListeners() {
// Screen 1: Address input
document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress());
document.getElementById('network-address').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.checkAddress();
});
// Screen 2: Configuration form
document.getElementById('btn-back-to-address').addEventListener('click', () => {
this.showScreen('address');
});
document.getElementById('btn-discover').addEventListener('click', () => this.discoverStreams());
// Password toggle
document.querySelector('.btn-toggle-password').addEventListener('click', () => {
const input = document.getElementById('password');
input.type = input.type === 'password' ? 'text' : 'password';
});
// Camera model autocomplete
const modelInput = document.getElementById('camera-model');
let debounceTimer;
let extendedSearchTimer;
modelInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
clearTimeout(extendedSearchTimer);
const query = e.target.value.trim();
if (query.length >= 2) {
debounceTimer = setTimeout(() => {
this.searchCameraModels(query, 10);
extendedSearchTimer = setTimeout(() => {
this.searchCameraModels(query, 50, true);
}, 1000);
}, 300);
} else {
this.hideAutocomplete();
}
});
// Screen 3: Stream discovery
document.getElementById('btn-back-to-config').addEventListener('click', () => {
this.streamAPI.close();
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.showScreen('discovery');
});
document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
document.getElementById('btn-new-search').addEventListener('click', () => {
this.reset();
this.showScreen('address');
});
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
});
}
showScreen(screenName) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(`screen-${screenName}`).classList.add('active');
}
async checkAddress() {
const input = document.getElementById('network-address');
const address = input.value.trim();
if (!address) {
showToast('Please enter a network address');
return;
}
// Check if it's a full URL with credentials
if (this.isFullURL(address)) {
this.parseFullURL(address);
} else {
// Just an IP or hostname
this.currentAddress = address;
document.getElementById('address-validated').value = address;
}
this.showScreen('config');
}
isFullURL(str) {
return str.startsWith('rtsp://') || str.startsWith('http://') || str.startsWith('https://');
}
parseFullURL(url) {
try {
const urlObj = new URL(url);
// Extract credentials
if (urlObj.username) {
document.getElementById('username').value = urlObj.username;
}
if (urlObj.password) {
document.getElementById('password').value = urlObj.password;
}
// Extract IP/hostname
this.currentAddress = urlObj.hostname;
document.getElementById('address-validated').value = url;
// Disable model input
const modelInput = document.getElementById('camera-model');
modelInput.disabled = true;
modelInput.placeholder = 'Detected from URL';
document.getElementById('model-disabled-hint').classList.remove('hidden');
} catch (e) {
this.currentAddress = url;
document.getElementById('address-validated').value = url;
}
}
async searchCameraModels(query, limit = 10, append = false) {
const dropdown = document.getElementById('autocomplete-dropdown');
if (!append) {
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
dropdown.classList.remove('hidden');
}
try {
const response = await this.cameraAPI.search(query, limit);
if (response.cameras && response.cameras.length > 0) {
this.renderAutocomplete(response.cameras, append);
} else if (!append) {
dropdown.innerHTML = '<div class="autocomplete-loading">No cameras found</div>';
}
} catch (error) {
console.error('Search error:', error);
if (!append) {
dropdown.innerHTML = '<div class="autocomplete-loading">Search failed</div>';
}
}
}
renderAutocomplete(cameras, append = false) {
const dropdown = document.getElementById('autocomplete-dropdown');
const modelInput = document.getElementById('camera-model');
const existingValues = new Set();
if (append) {
dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
existingValues.add(item.dataset.value);
});
}
const newItems = cameras
.map(camera => {
const fullName = `${camera.brand}: ${camera.model}`;
if (append && existingValues.has(fullName)) {
return null;
}
return `<div class="autocomplete-item" data-value="${fullName}">${fullName}</div>`;
})
.filter(item => item !== null)
.join('');
if (append) {
dropdown.insertAdjacentHTML('beforeend', newItems);
} else {
dropdown.innerHTML = newItems;
}
dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
if (!item.hasAttribute('data-listener')) {
item.setAttribute('data-listener', 'true');
item.addEventListener('click', () => {
modelInput.value = item.dataset.value;
this.hideAutocomplete();
});
}
});
}
hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden');
}
async discoverStreams() {
const model = document.getElementById('camera-model').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const channel = parseInt(document.getElementById('channel').value) || 0;
const maxStreams = parseInt(document.getElementById('max-streams').value) || 10;
const request = {
target: this.currentAddress,
model: model || 'auto',
username: username,
password: password,
channel: channel,
max_streams: maxStreams,
timeout: 240
};
this.showScreen('discovery');
this.resetDiscoveryUI();
// Start SSE stream
this.streamAPI.discover(request, {
onProgress: (data) => this.handleProgress(data),
onStreamFound: (data) => this.handleStreamFound(data),
onComplete: (data) => this.handleComplete(data),
onError: (error) => this.handleError(error)
});
}
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 = [];
}
handleProgress(data) {
const total = data.tested + data.remaining;
const percentage = total > 0 ? (data.tested / total) * 100 : 0;
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) {
this.currentStreams.push(data.stream);
// Show streams section if hidden
const streamsSection = document.getElementById('streams-section');
if (streamsSection.classList.contains('hidden')) {
streamsSection.classList.remove('hidden');
}
// Update carousel
this.carousel.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index);
});
}
handleComplete(data) {
document.getElementById('progress-fill').style.width = '100%';
document.getElementById('progress-text').textContent =
`Scan complete! Found ${data.total_found} stream(s) in ${data.duration.toFixed(1)}s`;
if (this.currentStreams.length === 0) {
showToast('No streams found. Try different credentials or model.');
}
}
handleError(error) {
console.error('Discovery error:', error);
showToast(`Error: ${error}`);
}
selectStream(stream, index) {
this.currentStream = stream;
this.configPanel.render(stream);
this.showScreen('output');
}
switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
// Update tab panes
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active');
}
copyConfig() {
const activeTab = document.querySelector('.tab.active').dataset.tab;
const configElement = document.getElementById(`config-${activeTab}`);
const text = configElement.textContent;
navigator.clipboard.writeText(text).then(() => {
showToast('Copied to clipboard!');
}).catch(err => {
showToast('Failed to copy');
console.error('Copy error:', err);
});
}
downloadConfig() {
const activeTab = document.querySelector('.tab.active').dataset.tab;
const configElement = document.getElementById(`config-${activeTab}`);
const text = configElement.textContent;
const filename = activeTab === 'url' ? 'stream-url.txt' : `${activeTab}-config.yaml`;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Downloaded!');
}
reset() {
this.currentAddress = '';
this.currentStreams = [];
this.currentStream = null;
document.getElementById('network-address').value = '';
document.getElementById('camera-model').value = '';
document.getElementById('camera-model').disabled = false;
document.getElementById('camera-model').placeholder = 'Start typing...';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
document.getElementById('channel').value = '0';
document.getElementById('max-streams').value = '10';
document.getElementById('model-disabled-hint').classList.add('hidden');
this.hideAutocomplete();
this.streamAPI.close();
}
}
// Initialize app
const app = new StrixApp();
+39
View File
@@ -0,0 +1,39 @@
import { Go2RTCGenerator } from '../config-generators/go2rtc/index.js';
import { FrigateGenerator } from '../config-generators/frigate/index.js';
export class ConfigPanel {
constructor() {
this.stream = null;
}
render(stream) {
this.stream = stream;
// Update selected stream info
document.getElementById('selected-stream-type').textContent = stream.type;
document.getElementById('selected-stream-url').textContent = this.maskCredentials(stream.url);
// Generate configs
const urlConfig = stream.url;
const go2rtcConfig = Go2RTCGenerator.generate(stream);
const frigateConfig = FrigateGenerator.generate(stream);
// Update config displays
document.getElementById('config-url').textContent = urlConfig;
document.getElementById('config-go2rtc').textContent = go2rtcConfig;
document.getElementById('config-frigate').textContent = frigateConfig;
}
maskCredentials(url) {
try {
const urlObj = new URL(url);
if (urlObj.username || urlObj.password) {
urlObj.username = urlObj.username ? '***' : '';
urlObj.password = urlObj.password ? '***' : '';
}
return urlObj.toString();
} catch (e) {
return url;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Placeholder for future form-specific logic
export class SearchForm {
constructor() {
// Reserved for form validation and helpers
}
}
+157
View File
@@ -0,0 +1,157 @@
export class StreamCarousel {
constructor() {
this.track = document.getElementById('carousel-track');
this.prevBtn = document.getElementById('carousel-prev');
this.nextBtn = document.getElementById('carousel-next');
this.counter = document.getElementById('carousel-counter');
this.dotsContainer = document.getElementById('carousel-dots');
this.streams = [];
this.currentIndex = 0;
this.onUseCallback = null;
}
render(streams, onUseCallback) {
this.streams = streams;
this.onUseCallback = onUseCallback;
this.currentIndex = Math.min(this.currentIndex, streams.length - 1);
// Render stream cards
this.track.innerHTML = streams.map((stream, index) => this.renderCard(stream, index)).join('');
// Render dots
this.dotsContainer.innerHTML = streams.map((_, index) =>
`<button class="carousel-dot ${index === this.currentIndex ? 'active' : ''}"
data-index="${index}"
aria-label="Go to stream ${index + 1}"></button>`
).join('');
// Attach event listeners
this.attachEventListeners();
// Update view
this.updateView();
}
renderCard(stream, index) {
const icon = this.getStreamIcon(stream.type);
return `
<div class="stream-card" data-index="${index}">
<div class="stream-type">
${icon}
${stream.type}
</div>
<div class="stream-url">${this.truncateURL(stream.url)}</div>
${stream.resolution ? `<div class="stream-meta">Resolution: ${stream.resolution}</div>` : ''}
${stream.codec ? `<div class="stream-meta">Codec: ${stream.codec}${stream.fps ? `${stream.fps} fps` : ''}${stream.bitrate ? `${Math.round(stream.bitrate / 1000)} Kbps` : ''}</div>` : ''}
${stream.has_audio ? `<div class="stream-meta">Audio: Yes</div>` : ''}
<div class="stream-actions">
<button class="btn btn-primary btn-use" data-index="${index}">Use Stream</button>
</div>
</div>
`;
}
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'];
}
truncateURL(url) {
if (url.length > 50) {
return url.substring(0, 47) + '...';
}
return url;
}
attachEventListeners() {
// Use buttons
this.track.querySelectorAll('.btn-use').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(e.target.dataset.index);
if (this.onUseCallback) {
this.onUseCallback(this.streams[index], index);
}
});
});
// Dots
this.dotsContainer.querySelectorAll('.carousel-dot').forEach(dot => {
dot.addEventListener('click', (e) => {
const index = parseInt(e.target.dataset.index);
this.goTo(index);
});
});
// Touch gestures
let touchStartX = 0;
let touchEndX = 0;
this.track.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
});
this.track.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
});
}
handleSwipe(startX, endX) {
const swipeThreshold = 50;
const diff = startX - endX;
if (Math.abs(diff) > swipeThreshold) {
if (diff > 0) {
this.next();
} else {
this.prev();
}
}
}
prev() {
if (this.currentIndex > 0) {
this.goTo(this.currentIndex - 1);
}
}
next() {
if (this.currentIndex < this.streams.length - 1) {
this.goTo(this.currentIndex + 1);
}
}
goTo(index) {
if (index < 0 || index >= this.streams.length) return;
this.currentIndex = index;
this.updateView();
}
updateView() {
// Update track position
const offset = -100 * this.currentIndex;
this.track.style.transform = `translateX(${offset}%)`;
// Update counter
this.counter.textContent = `Stream ${this.currentIndex + 1} of ${this.streams.length}`;
// Update dots
this.dotsContainer.querySelectorAll('.carousel-dot').forEach((dot, i) => {
dot.classList.toggle('active', i === this.currentIndex);
});
// Update arrow buttons
this.prevBtn.disabled = this.currentIndex === 0;
this.nextBtn.disabled = this.currentIndex === this.streams.length - 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
export function showToast(message, duration = 3000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.remove('hidden');
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
toast.classList.add('hidden');
}, 250);
}, duration);
}