Files
media-player-server/media_server/static/js/app.js
alexei.dolgolyov 5f474d6c9f Add browser search/filter for media items
- Search bar appears when browsing inside a folder
- Client-side filtering with 200ms debounce
- Clear button and search icon
- Hides at root level, resets on navigation
- Localized placeholder (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 02:44:31 +03:00

2258 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tab management
let activeTab = 'player';
function setMiniPlayerVisible(visible) {
const miniPlayer = document.getElementById('mini-player');
if (visible) {
miniPlayer.classList.remove('hidden');
document.body.classList.add('mini-player-visible');
} else {
miniPlayer.classList.add('hidden');
document.body.classList.remove('mini-player-visible');
}
}
function switchTab(tabName) {
activeTab = tabName;
// Hide all tab content
document.querySelectorAll('[data-tab-content]').forEach(el => {
el.classList.remove('active');
el.style.display = '';
});
// Show selected tab content
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
if (target) {
target.classList.add('active');
}
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) activeBtn.classList.add('active');
// Save to localStorage
localStorage.setItem('activeTab', tabName);
// Mini-player: show when not on player tab
if (tabName !== 'player') {
setMiniPlayerVisible(true);
} else {
// Restore scroll-based behavior: check if player is in view
const playerContainer = document.querySelector('.player-container');
const rect = playerContainer.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
setMiniPlayerVisible(!inView);
}
}
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
if (theme === 'light') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
// Locale management
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'Media Server',
'auth.connect': 'Connect',
'auth.placeholder': 'Enter API Token',
'player.status.connected': 'Connected',
'player.status.disconnected': 'Disconnected'
};
// Translation function
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
// Replace parameters like {name}, {value}, etc.
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
// Load translation file
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
// Fallback to English if loading fails
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
// Detect browser locale
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
// Only return if we support it
return supportedLocales[langCode] ? langCode : 'en';
}
// Initialize locale
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
// Set locale
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
// Load translations for the locale
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
// Update all text
updateAllText();
// Update locale select dropdown (if visible)
updateLocaleSelect();
// Remove loading class and show content
document.body.classList.remove('loading-translations');
document.body.classList.add('translations-loaded');
}
// Change locale from dropdown
function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
// Update locale select dropdown
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
// Update all text on page
function updateAllText() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update all elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-apply dynamic content with new translations
// Update playback state
updatePlaybackState(currentState);
// Update connection status
const connected = ws && ws.readyState === WebSocket.OPEN;
updateConnectionStatus(connected);
// Re-apply last media status if available
if (lastStatus) {
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
document.getElementById('source').textContent = lastStatus.source || t('player.unknown_source');
}
// Reload tables to get translated content
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
}
}
async function fetchVersion() {
try {
const response = await fetch('/api/health');
if (response.ok) {
const data = await response.json();
const label = document.getElementById('version-label');
if (data.version) {
label.textContent = `v${data.version}`;
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
let ws = null;
let reconnectTimeout = null;
let currentState = 'idle';
let currentDuration = 0;
let currentPosition = 0;
let isUserAdjustingVolume = false;
let volumeUpdateTimer = null; // Timer for throttling volume updates
let scripts = [];
let lastStatus = null; // Store last status for locale switching
// Dialog dirty state tracking
let scriptFormDirty = false;
let callbackFormDirty = false;
// Position interpolation
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
// Initialize on page load
window.addEventListener('DOMContentLoaded', async () => {
// Initialize theme
initTheme();
// Initialize locale (async - loads JSON file)
await initLocale();
// Load version from health endpoint
fetchVersion();
const token = localStorage.getItem('media_server_token');
if (token) {
connectWebSocket(token);
loadScripts();
loadScriptsTable();
loadCallbacksTable();
} else {
showAuthForm();
}
// Volume slider event
const volumeSlider = document.getElementById('volume-slider');
volumeSlider.addEventListener('input', (e) => {
isUserAdjustingVolume = true;
const volume = parseInt(e.target.value);
document.getElementById('volume-display').textContent = `${volume}%`;
// Throttle volume updates while dragging (update every 16ms via WebSocket)
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
}
volumeUpdateTimer = setTimeout(() => {
setVolume(volume);
volumeUpdateTimer = null;
}, 16);
});
volumeSlider.addEventListener('change', (e) => {
// Clear any pending throttled update
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
// Send final volume update immediately
const volume = parseInt(e.target.value);
setVolume(volume);
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
});
// Restore saved tab
const savedTab = localStorage.getItem('activeTab') || 'player';
switchTab(savedTab);
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view
const playerContainer = document.querySelector('.player-container');
const miniPlayer = document.getElementById('mini-player');
const observerOptions = {
root: null, // viewport
threshold: 0.1, // trigger when 10% visible
rootMargin: '0px'
};
const observerCallback = (entries) => {
entries.forEach(entry => {
// Only use scroll-based logic when on the player tab
if (activeTab !== 'player') return;
setMiniPlayerVisible(!entry.isIntersecting);
});
};
const observer = new IntersectionObserver(observerCallback, observerOptions);
observer.observe(playerContainer);
// Mini player progress bar click to seek
const miniProgressBar = document.getElementById('mini-progress-bar');
miniProgressBar.addEventListener('click', (e) => {
const rect = miniProgressBar.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const position = percent * currentDuration;
seek(position);
});
// Mini player volume slider
const miniVolumeSlider = document.getElementById('mini-volume-slider');
miniVolumeSlider.addEventListener('input', (e) => {
isUserAdjustingVolume = true;
const volume = parseInt(e.target.value);
document.getElementById('mini-volume-display').textContent = `${volume}%`;
document.getElementById('volume-display').textContent = `${volume}%`;
document.getElementById('volume-slider').value = volume;
// Throttle volume updates while dragging (update every 16ms via WebSocket)
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
}
volumeUpdateTimer = setTimeout(() => {
setVolume(volume);
volumeUpdateTimer = null;
}, 16);
});
miniVolumeSlider.addEventListener('change', (e) => {
// Clear any pending throttled update
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
// Send final volume update immediately
const volume = parseInt(e.target.value);
setVolume(volume);
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
});
// Progress bar click to seek
const progressBar = document.getElementById('progress-bar');
progressBar.addEventListener('click', (e) => {
if (currentDuration > 0) {
const rect = progressBar.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const seekPos = percent * currentDuration;
seek(seekPos);
}
});
// Enter key in token input
document.getElementById('token-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
authenticate();
}
});
// Script form dirty state tracking
const scriptForm = document.getElementById('scriptForm');
scriptForm.addEventListener('input', () => {
scriptFormDirty = true;
});
scriptForm.addEventListener('change', () => {
scriptFormDirty = true;
});
// Callback form dirty state tracking
const callbackForm = document.getElementById('callbackForm');
callbackForm.addEventListener('input', () => {
callbackFormDirty = true;
});
callbackForm.addEventListener('change', () => {
callbackFormDirty = true;
});
// Script dialog backdrop click to close
const scriptDialog = document.getElementById('scriptDialog');
scriptDialog.addEventListener('click', (e) => {
// Check if click is on the backdrop (not the dialog content)
if (e.target === scriptDialog) {
closeScriptDialog();
}
});
// Callback dialog backdrop click to close
const callbackDialog = document.getElementById('callbackDialog');
callbackDialog.addEventListener('click', (e) => {
// Check if click is on the backdrop (not the dialog content)
if (e.target === callbackDialog) {
closeCallbackDialog();
}
});
});
function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay');
overlay.classList.remove('hidden');
const errorEl = document.getElementById('auth-error');
if (errorMessage) {
errorEl.textContent = errorMessage;
errorEl.classList.add('visible');
} else {
errorEl.classList.remove('visible');
}
}
function hideAuthForm() {
document.getElementById('auth-overlay').classList.add('hidden');
}
function authenticate() {
const token = document.getElementById('token-input').value.trim();
if (!token) {
showAuthForm(t('auth.required'));
return;
}
localStorage.setItem('media_server_token', token);
connectWebSocket(token);
}
function clearToken() {
localStorage.removeItem('media_server_token');
if (ws) {
ws.close();
}
showAuthForm(t('auth.cleared'));
}
function connectWebSocket(token) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus(true);
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data);
} else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...');
loadScripts(); // Reload Quick Actions
loadScriptsTable(); // Reload Script Management table
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code);
updateConnectionStatus(false);
stopPositionInterpolation();
if (event.code === 4001) {
// Invalid token
localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) {
// Abnormal closure - attempt reconnect
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
console.log('Attempting to reconnect...');
connectWebSocket(savedToken);
}
}, 3000);
}
};
// Send keepalive ping every 30 seconds
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
function updateConnectionStatus(connected) {
const dot = document.getElementById('status-dot');
if (connected) {
dot.classList.add('connected');
} else {
dot.classList.remove('connected');
}
}
function updateUI(status) {
// Store status for locale switching
lastStatus = status;
// Update track info
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = status.title || fallbackTitle;
document.getElementById('artist').textContent = status.artist || '';
document.getElementById('album').textContent = status.album || '';
// Update mini player info
document.getElementById('mini-track-title').textContent = status.title || fallbackTitle;
document.getElementById('mini-artist').textContent = status.artist || '';
// Update state
const previousState = currentState;
currentState = status.state;
updatePlaybackState(status.state);
// Update album art
const artImg = document.getElementById('album-art');
const miniArtImg = document.getElementById('mini-album-art');
const artworkUrl = status.album_art_url
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}`
: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
artImg.src = artworkUrl;
miniArtImg.src = artworkUrl;
// Update progress
if (status.duration && status.position !== null) {
currentDuration = status.duration;
currentPosition = status.position;
// Track position update for interpolation
lastPositionUpdate = Date.now();
lastPositionValue = status.position;
updateProgress(status.position, status.duration);
}
// Update volume
if (!isUserAdjustingVolume) {
document.getElementById('volume-slider').value = status.volume;
document.getElementById('volume-display').textContent = `${status.volume}%`;
document.getElementById('mini-volume-slider').value = status.volume;
document.getElementById('mini-volume-display').textContent = `${status.volume}%`;
}
// Update mute state
updateMuteIcon(status.muted);
// Update source
document.getElementById('source').textContent = status.source || t('player.unknown_source');
// Enable/disable controls based on state
const hasMedia = status.state !== 'idle';
document.getElementById('btn-play-pause').disabled = !hasMedia;
document.getElementById('btn-next').disabled = !hasMedia;
document.getElementById('btn-previous').disabled = !hasMedia;
document.getElementById('mini-btn-play-pause').disabled = !hasMedia;
// Start/stop position interpolation based on playback state
if (status.state === 'playing' && previousState !== 'playing') {
startPositionInterpolation();
} else if (status.state !== 'playing' && previousState === 'playing') {
stopPositionInterpolation();
}
}
function updatePlaybackState(state) {
const stateText = document.getElementById('playback-state');
const stateIcon = document.getElementById('state-icon');
const playPauseIcon = document.getElementById('play-pause-icon');
const miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
switch(state) {
case 'playing':
stateText.textContent = t('state.playing');
stateIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
miniPlayPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
break;
case 'paused':
stateText.textContent = t('state.paused');
stateIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
break;
case 'stopped':
stateText.textContent = t('state.stopped');
stateIcon.innerHTML = '<path d="M6 6h12v12H6z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
break;
default:
stateText.textContent = t('state.idle');
stateIcon.innerHTML = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
}
}
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
document.getElementById('progress-fill').style.width = `${percent}%`;
document.getElementById('current-time').textContent = formatTime(position);
document.getElementById('total-time').textContent = formatTime(duration);
document.getElementById('progress-bar').dataset.duration = duration;
// Update mini player progress
document.getElementById('mini-progress-fill').style.width = `${percent}%`;
document.getElementById('mini-current-time').textContent = formatTime(position);
document.getElementById('mini-total-time').textContent = formatTime(duration);
}
function startPositionInterpolation() {
// Clear any existing interval
if (interpolationInterval) {
clearInterval(interpolationInterval);
}
// Update position every 100ms for smooth animation
interpolationInterval = setInterval(() => {
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
// Calculate elapsed time since last position update
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
// Update UI with interpolated position
updateProgress(interpolatedPosition, currentDuration);
}
}, 100);
}
function stopPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function updateMuteIcon(muted) {
const muteIcon = document.getElementById('mute-icon');
const miniMuteIcon = document.getElementById('mini-mute-icon');
const mutedPath = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
const unmutedPath = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
if (muted) {
muteIcon.innerHTML = mutedPath;
miniMuteIcon.innerHTML = mutedPath;
} else {
muteIcon.innerHTML = unmutedPath;
miniMuteIcon.innerHTML = unmutedPath;
}
}
function formatTime(seconds) {
if (!seconds || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// API Commands
async function sendCommand(endpoint, body = null) {
const token = localStorage.getItem('media_server_token');
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(`/api/media/${endpoint}`, options);
if (!response.ok) {
console.error(`Command ${endpoint} failed:`, response.status);
}
} catch (error) {
console.error(`Error sending command ${endpoint}:`, error);
}
}
function togglePlayPause() {
if (currentState === 'playing') {
sendCommand('pause');
} else {
sendCommand('play');
}
}
function nextTrack() {
sendCommand('next');
}
function previousTrack() {
sendCommand('previous');
}
function setVolume(volume) {
// Use WebSocket for low-latency volume updates
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
} else {
sendCommand('volume', { volume: volume });
}
}
function toggleMute() {
sendCommand('mute');
}
function seek(position) {
sendCommand('seek', { position: position });
}
// Scripts functionality
async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch('/api/scripts/list', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
scripts = await response.json();
displayScripts();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
function displayScripts() {
const container = document.getElementById('scripts-container');
const grid = document.getElementById('scripts-grid');
if (scripts.length === 0) {
grid.innerHTML = `<div class="scripts-empty">${t('scripts.no_scripts')}</div>`;
return;
}
grid.innerHTML = '';
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
grid.appendChild(button);
});
}
async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
// Add executing state
buttonElement.classList.add('executing');
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
// Remove executing state
buttonElement.classList.remove('executing');
}
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Script Management Functions
async function loadScriptsTable() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch scripts');
}
const scriptsList = await response.json();
if (scriptsList.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No scripts configured. Click "Add Script" to create one.</td></tr>';
return;
}
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><code>${script.name}</code></td>
<td>${script.label || script.name}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" onclick="executeScriptDebug('${script.name}')" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" onclick="showEditScriptDialog('${script.name}')" title="Edit script">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" onclick="deleteScriptConfirm('${script.name}')" title="Delete script">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showAddScriptDialog() {
const dialog = document.getElementById('scriptDialog');
const form = document.getElementById('scriptForm');
const title = document.getElementById('dialogTitle');
// Reset form
form.reset();
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
title.textContent = t('scripts.dialog.add');
// Reset dirty state
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
// Fetch current script details
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch script details');
}
const scriptsList = await response.json();
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
return;
}
// Populate form
document.getElementById('scriptOriginalName').value = scriptName;
document.getElementById('scriptIsEdit').value = 'true';
document.getElementById('scriptName').value = scriptName;
document.getElementById('scriptName').disabled = true; // Can't change name
document.getElementById('scriptLabel').value = script.label || '';
document.getElementById('scriptCommand').value = script.command || '';
document.getElementById('scriptDescription').value = script.description || '';
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
title.textContent = t('scripts.dialog.edit');
// Reset dirty state
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
}
}
function closeScriptDialog() {
// Check if form has unsaved changes
if (scriptFormDirty) {
if (!confirm(t('scripts.confirm.unsaved'))) {
return; // User cancelled, don't close
}
}
const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false; // Reset dirty state
dialog.close();
document.body.classList.remove('dialog-open');
}
async function saveScript(event) {
event.preventDefault();
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value :
document.getElementById('scriptName').value;
const data = {
command: document.getElementById('scriptCommand').value,
label: document.getElementById('scriptLabel').value || null,
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
};
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false; // Reset dirty state before closing
closeScriptDialog();
// Don't reload manually - WebSocket will trigger it
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
}
} catch (error) {
console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
}
}
async function deleteScriptConfirm(scriptName) {
if (!confirm(`Are you sure you want to delete the script "${scriptName}"?`)) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
// Don't reload manually - WebSocket will trigger it
} else {
showToast(result.detail || 'Failed to delete script', 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
}
}
// Callback Management Functions
async function loadCallbacksTable() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callbacks');
}
const callbacksList = await response.json();
if (callbacksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td></tr>';
return;
}
tbody.innerHTML = callbacksList.map(callback => `
<tr>
<td><code>${callback.name}</code></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" onclick="executeCallbackDebug('${callback.name}')" title="Execute callback">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" onclick="showEditCallbackDialog('${callback.name}')" title="Edit callback">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" onclick="deleteCallbackConfirm('${callback.name}')" title="Delete callback">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
}
}
function showAddCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
const form = document.getElementById('callbackForm');
const title = document.getElementById('callbackDialogTitle');
// Reset form
form.reset();
document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
// Reset dirty state
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
// Fetch current callback details
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callback details');
}
const callbacksList = await response.json();
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
return;
}
// Populate form
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true; // Can't change event name
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = t('callbacks.dialog.edit');
// Reset dirty state
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
}
}
function closeCallbackDialog() {
// Check if form has unsaved changes
if (callbackFormDirty) {
if (!confirm(t('callbacks.confirm.unsaved'))) {
return; // User cancelled, don't close
}
}
const dialog = document.getElementById('callbackDialog');
callbackFormDirty = false; // Reset dirty state
dialog.close();
document.body.classList.remove('dialog-open');
}
async function saveCallback(event) {
event.preventDefault();
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value;
const data = {
command: document.getElementById('callbackCommand').value,
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
working_dir: document.getElementById('callbackWorkingDir').value || null,
shell: true
};
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false; // Reset dirty state before closing
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
}
}
async function deleteCallbackConfirm(callbackName) {
if (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
}
}
// Execution Result Dialog Functions
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
dialog.close();
document.body.classList.remove('dialog-open');
}
function showExecutionResult(name, result, type = 'script') {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
const outputSection = document.getElementById('outputSection');
const errorSection = document.getElementById('errorSection');
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
// Set title
title.textContent = `Execution Result: ${name}`;
// Build status display
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
// Always show output section
outputSection.style.display = 'block';
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
// Show error output if present
if (result.stderr && result.stderr.trim()) {
errorSection.style.display = 'block';
errorPre.textContent = result.stderr;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else if (!success && result.error) {
errorSection.style.display = 'block';
errorPre.textContent = result.error;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else {
errorSection.style.display = 'none';
}
dialog.showModal();
}
async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
// Show dialog with loading state
title.textContent = `Executing: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok) {
showExecutionResult(scriptName, result, 'script');
} else {
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'script');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'script');
}
}
async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
// Show dialog with loading state
title.textContent = `Executing: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
// For callbacks, we'll execute them directly via the callback endpoint
// We need to trigger the callback as if the event occurred
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showExecutionResult(callbackName, result, 'callback');
} else {
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'callback');
}
} catch (error) {
console.error(`Error executing callback ${callbackName}:`, error);
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'callback');
}
}
// ========================================
// Media Browser Functionality
// ========================================
// Browser state
let currentFolderId = null;
let currentPath = '';
let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
let browserSearchTimer = null;
// Load media folders on page load
async function loadMediaFolders() {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const response = await fetch('/api/browser/folders', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
// Load last browsed path or show root folder list
loadLastBrowserPath();
} catch (error) {
console.error('Error loading media folders:', error);
showToast(t('browser.error_loading_folders'), 'error');
}
}
function showRootFolders() {
currentFolderId = '';
currentPath = '';
currentOffset = 0;
cachedItems = null;
// Hide search at root level
showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root)
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home';
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
breadcrumb.appendChild(root);
// Hide play all button and pagination
document.getElementById('playAllBtn').style.display = 'none';
document.getElementById('browserPagination').style.display = 'none';
// Render folders as grid cards
const container = document.getElementById('browserGrid');
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
}
container.innerHTML = '';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.innerHTML = `
<div class="browser-list-icon">📁</div>
<div class="browser-list-name">${folder.label}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">📁</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
</div>
`;
container.appendChild(card);
}
});
}
async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating
showBrowserSearch(false);
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
// Show loading spinner
const container = document.getElementById('browserGrid');
container.className = 'browser-grid';
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
const encodedPath = encodeURIComponent(path);
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
if (nocache) url += '&nocache=true';
const response = await fetch(
url,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) throw new Error('Failed to browse path');
const data = await response.json();
currentPath = data.current_path;
currentOffset = offset;
totalItems = data.total;
cachedItems = data.items;
renderBreadcrumbs(data.current_path, data.parent_path);
renderBrowserItems(cachedItems);
renderPagination();
// Show search bar when inside a folder
showBrowserSearch(true);
// Show/hide Play All button based on whether media items exist
const hasMedia = data.items.some(item => item.is_media);
document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none';
// Save last path
saveLastBrowserPath(folderId, currentPath);
} catch (error) {
console.error('Error browsing path:', error);
showToast(t('browser.error_loading'), 'error');
clearBrowserGrid();
}
}
function renderBreadcrumbs(currentPath, parentPath) {
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const parts = (currentPath || '').split('/').filter(p => p);
let path = '/';
// Home link (back to folder list)
const home = document.createElement('span');
home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders();
breadcrumb.appendChild(home);
// Separator + Folder name
const sep = document.createElement('span');
sep.className = 'breadcrumb-separator';
sep.textContent = '';
breadcrumb.appendChild(sep);
const folderItem = document.createElement('span');
folderItem.className = 'breadcrumb-item';
folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root';
if (parts.length > 0) {
folderItem.onclick = () => browsePath(currentFolderId, '');
}
breadcrumb.appendChild(folderItem);
// Path parts
parts.forEach((part, index) => {
// Separator
const separator = document.createElement('span');
separator.className = 'breadcrumb-separator';
separator.textContent = '';
breadcrumb.appendChild(separator);
// Part
path += (path === '/' ? '' : '/') + part;
const item = document.createElement('span');
item.className = 'breadcrumb-item';
item.textContent = part;
const itemPath = path;
item.onclick = () => browsePath(currentFolderId, itemPath);
breadcrumb.appendChild(item);
});
}
function renderBrowserItems(items) {
const container = document.getElementById('browserGrid');
// Switch container class based on view mode
if (viewMode === 'list') {
container.className = 'browser-list';
renderBrowserList(items, container);
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
renderBrowserGrid(items, container);
} else {
container.className = 'browser-grid';
renderBrowserGrid(items, container);
}
}
function renderBrowserList(items, container) {
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
return;
}
items.forEach(item => {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.dataset.name = item.name;
row.dataset.type = item.type;
// Icon (small) with play overlay
const icon = document.createElement('div');
icon.className = 'browser-list-icon';
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-list-thumbnail loading';
thumbnail.alt = item.name;
icon.appendChild(thumbnail);
loadThumbnail(thumbnail, item.name);
} else {
icon.textContent = getFileIcon(item.type);
}
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-list-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
icon.appendChild(overlay);
}
row.appendChild(icon);
// Name
const name = document.createElement('div');
name.className = 'browser-list-name';
name.textContent = item.name;
row.appendChild(name);
// Type badge
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-list-type ${item.type}`;
typeBadge.textContent = item.type;
row.appendChild(typeBadge);
} else {
row.appendChild(document.createElement('div'));
}
// Duration
const dur = document.createElement('div');
dur.className = 'browser-list-duration';
dur.textContent = formatDuration(item.duration) || '';
row.appendChild(dur);
// Size
const size = document.createElement('div');
size.className = 'browser-list-size';
size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : '';
row.appendChild(size);
// Download button
if (item.is_media) {
row.appendChild(createDownloadBtn(item.name, 'browser-list-download'));
} else {
row.appendChild(document.createElement('div'));
}
// Tooltip on row when name is ellipsed
row.addEventListener('mouseenter', () => {
if (name.scrollWidth > name.clientWidth) {
row.title = item.name;
} else {
row.title = '';
}
});
// Single click: play media or navigate folder
row.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(row);
});
}
function renderBrowserGrid(items, container) {
container = container || document.getElementById('browserGrid');
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
return;
}
items.forEach(item => {
const div = document.createElement('div');
div.className = 'browser-item';
div.dataset.name = item.name;
div.dataset.type = item.type;
// Type badge
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-item-type ${item.type}`;
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
div.appendChild(typeBadge);
}
// Thumbnail wrapper (for play overlay)
const thumbWrapper = document.createElement('div');
thumbWrapper.className = 'browser-thumb-wrapper';
// Thumbnail or icon
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-thumbnail loading';
thumbnail.alt = item.name;
thumbWrapper.appendChild(thumbnail);
// Lazy load thumbnail
loadThumbnail(thumbnail, item.name);
} else {
const icon = document.createElement('div');
icon.className = 'browser-icon';
icon.textContent = getFileIcon(item.type);
thumbWrapper.appendChild(icon);
}
// Play overlay for media files
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
thumbWrapper.appendChild(overlay);
}
div.appendChild(thumbWrapper);
// Info
const info = document.createElement('div');
info.className = 'browser-item-info';
const name = document.createElement('div');
name.className = 'browser-item-name';
name.textContent = item.name;
info.appendChild(name);
if (item.type !== 'folder') {
const meta = document.createElement('div');
meta.className = 'browser-item-meta';
const parts = [];
const duration = formatDuration(item.duration);
if (duration) parts.push(duration);
if (item.size !== null) parts.push(formatFileSize(item.size));
meta.textContent = parts.join(' \u00B7 ');
if (parts.length) info.appendChild(meta);
}
div.appendChild(info);
// Tooltip on card when name is ellipsed
div.addEventListener('mouseenter', () => {
if (name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) {
div.title = item.name;
} else {
div.title = '';
}
});
// Single click: play media or navigate folder
div.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(div);
});
}
function getTypeBadgeIcon(type) {
const svgs = {
'audio': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
};
return svgs[type] || '';
}
function getFileIcon(type) {
const icons = {
'folder': '📁',
'audio': '🎵',
'video': '🎬',
'other': '📄'
};
return icons[type] || icons.other;
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (seconds == null || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(
mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\')
);
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (response.status === 200) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
imgElement.src = url;
} else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement;
const isList = parent.classList.contains('browser-list-icon');
imgElement.remove();
if (isList) {
parent.textContent = '🎵';
} else {
const icon = document.createElement('div');
icon.className = 'browser-icon';
icon.textContent = '🎵';
parent.insertBefore(icon, parent.firstChild);
}
}
} catch (error) {
console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading');
}
}
async function playMediaFile(fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\');
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: absolutePath })
});
if (!response.ok) throw new Error('Failed to play file');
const data = await response.json();
showToast(t('browser.play_success', { filename: fileName }), 'success');
} catch (error) {
console.error('Error playing file:', error);
showToast(t('browser.play_error'), 'error');
}
}
async function playAllFolder() {
try {
const token = localStorage.getItem('media_server_token');
if (!token || !currentFolderId) return;
const response = await fetch('/api/browser/play-folder', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to play folder');
}
const data = await response.json();
showToast(t('browser.play_all_success', { count: data.count }), 'success');
} catch (error) {
console.error('Error playing folder:', error);
showToast(t('browser.play_all_error'), 'error');
}
}
function downloadFile(fileName, event) {
if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token');
if (!token) return;
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(fullPath);
const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`;
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function createDownloadBtn(fileName, cssClass) {
const btn = document.createElement('button');
btn.className = cssClass;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
btn.title = t('browser.download');
btn.onclick = (e) => downloadFile(fileName, e);
return btn;
}
function renderPagination() {
const pagination = document.getElementById('browserPagination');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
pageInput.value = currentPage;
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function previousPage() {
if (currentOffset >= itemsPerPage) {
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
}
}
function nextPage() {
if (currentOffset + itemsPerPage < totalItems) {
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
}
}
function refreshBrowser() {
if (currentFolderId) {
browsePath(currentFolderId, currentPath, currentOffset, true);
} else {
loadMediaFolders();
}
}
// Browser search
function onBrowserSearch() {
const input = document.getElementById('browserSearchInput');
const clearBtn = document.getElementById('browserSearchClear');
const term = input.value.trim();
clearBtn.style.display = term ? 'flex' : 'none';
// Debounce: wait 200ms after typing stops
if (browserSearchTimer) clearTimeout(browserSearchTimer);
browserSearchTimer = setTimeout(() => {
browserSearchTerm = term.toLowerCase();
applyBrowserSearch();
}, 200);
}
function clearBrowserSearch() {
const input = document.getElementById('browserSearchInput');
input.value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
applyBrowserSearch();
input.focus();
}
function applyBrowserSearch() {
if (!cachedItems) return;
if (!browserSearchTerm) {
renderBrowserItems(cachedItems);
return;
}
const filtered = cachedItems.filter(item =>
item.name.toLowerCase().includes(browserSearchTerm)
);
renderBrowserItems(filtered);
}
function showBrowserSearch(visible) {
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
if (!visible) {
document.getElementById('browserSearchInput').value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
}
}
function setViewMode(mode) {
if (mode === viewMode) return;
viewMode = mode;
localStorage.setItem('mediaBrowser.viewMode', mode);
// Update toggle buttons
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Re-render current view from cache (no network request)
if (currentFolderId && cachedItems) {
applyBrowserSearch();
} else {
showRootFolders();
}
}
function onItemsPerPageChanged() {
const select = document.getElementById('itemsPerPageSelect');
itemsPerPage = parseInt(select.value);
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
// Reset to first page and reload
if (currentFolderId) {
currentOffset = 0;
browsePath(currentFolderId, currentPath, 0);
}
}
function goToPage() {
const pageInput = document.getElementById('pageInput');
const totalPages = Math.ceil(totalItems / itemsPerPage);
let page = parseInt(pageInput.value);
if (isNaN(page) || page < 1) page = 1;
if (page > totalPages) page = totalPages;
pageInput.value = page;
const newOffset = (page - 1) * itemsPerPage;
if (newOffset !== currentOffset) {
browsePath(currentFolderId, currentPath, newOffset);
}
}
function initBrowserToolbar() {
// Restore view mode
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
viewMode = savedViewMode;
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Restore items per page
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
if (savedItemsPerPage) {
itemsPerPage = parseInt(savedItemsPerPage);
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
}
}
function clearBrowserGrid() {
const grid = document.getElementById('browserGrid');
grid.innerHTML = `<div class="browser-empty" data-i18n="browser.no_folder_selected">${t('browser.no_folder_selected')}</div>`;
document.getElementById('breadcrumb').innerHTML = '';
document.getElementById('browserPagination').style.display = 'none';
document.getElementById('playAllBtn').style.display = 'none';
}
// LocalStorage for last path
function saveLastBrowserPath(folderId, path) {
try {
localStorage.setItem('mediaBrowser.lastFolderId', folderId);
localStorage.setItem('mediaBrowser.lastPath', path);
} catch (e) {
console.error('Failed to save last browser path:', e);
}
}
function loadLastBrowserPath() {
try {
const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId');
const lastPath = localStorage.getItem('mediaBrowser.lastPath');
if (lastFolderId && mediaFolders[lastFolderId]) {
currentFolderId = lastFolderId;
browsePath(lastFolderId, lastPath || '');
} else {
showRootFolders();
}
} catch (e) {
console.error('Failed to load last browser path:', e);
showRootFolders();
}
}
// Folder Management
function showManageFoldersDialog() {
// TODO: Implement folder management UI
// For now, show a simple alert
showToast(t('browser.manage_folders_hint'), 'info');
}
function closeFolderDialog() {
document.getElementById('folderDialog').close();
}
async function saveFolder(event) {
event.preventDefault();
// TODO: Implement folder save functionality
closeFolderDialog();
}
// Initialize browser on page load
window.addEventListener('DOMContentLoaded', () => {
initBrowserToolbar();
// Load media folders after authentication
const token = localStorage.getItem('media_server_token');
if (token) {
loadMediaFolders();
}
});