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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Placeholder for future form-specific logic
|
||||
export class SearchForm {
|
||||
constructor() {
|
||||
// Reserved for form validation and helpers
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user