// SVG path constants (avoid rebuilding innerHTML on every state update)
const SVG_PLAY = '';
const SVG_PAUSE = '';
const SVG_STOP = '';
const SVG_IDLE = '';
const SVG_MUTED = '';
const SVG_UNMUTED = '';
// Empty state illustration SVGs
const EMPTY_SVG_FOLDER = '';
const EMPTY_SVG_FILE = '';
function emptyStateHtml(svgStr, text) {
return `
`;
}
// Cached DOM references (populated once after DOMContentLoaded)
const dom = {};
function cacheDom() {
dom.trackTitle = document.getElementById('track-title');
dom.artist = document.getElementById('artist');
dom.album = document.getElementById('album');
dom.miniTrackTitle = document.getElementById('mini-track-title');
dom.miniArtist = document.getElementById('mini-artist');
dom.albumArt = document.getElementById('album-art');
dom.albumArtGlow = document.getElementById('album-art-glow');
dom.miniAlbumArt = document.getElementById('mini-album-art');
dom.volumeSlider = document.getElementById('volume-slider');
dom.volumeDisplay = document.getElementById('volume-display');
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
dom.progressFill = document.getElementById('progress-fill');
dom.currentTime = document.getElementById('current-time');
dom.totalTime = document.getElementById('total-time');
dom.progressBar = document.getElementById('progress-bar');
dom.miniProgressFill = document.getElementById('mini-progress-fill');
dom.miniCurrentTime = document.getElementById('mini-current-time');
dom.miniTotalTime = document.getElementById('mini-total-time');
dom.playbackState = document.getElementById('playback-state');
dom.stateIcon = document.getElementById('state-icon');
dom.playPauseIcon = document.getElementById('play-pause-icon');
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
dom.muteIcon = document.getElementById('mute-icon');
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
dom.statusDot = document.getElementById('status-dot');
dom.source = document.getElementById('source');
dom.btnPlayPause = document.getElementById('btn-play-pause');
dom.btnNext = document.getElementById('btn-next');
dom.btnPrevious = document.getElementById('btn-previous');
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
dom.miniPlayer = document.getElementById('mini-player');
}
// Timing constants
const VOLUME_THROTTLE_MS = 16;
const POSITION_INTERPOLATION_MS = 100;
const SEARCH_DEBOUNCE_MS = 200;
const TOAST_DURATION_MS = 3000;
const WS_BACKOFF_BASE_MS = 3000;
const WS_BACKOFF_MAX_MS = 30000;
const WS_MAX_RECONNECT_ATTEMPTS = 20;
const WS_PING_INTERVAL_MS = 30000;
const VOLUME_RELEASE_DELAY_MS = 500;
// 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 updateTabIndicator(btn, animate = true) {
const indicator = document.getElementById('tabIndicator');
if (!indicator || !btn) return;
const tabBar = document.getElementById('tabBar');
const barRect = tabBar.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0);
if (!animate) indicator.style.transition = 'none';
indicator.style.width = btnRect.width + 'px';
indicator.style.transform = `translateX(${offset}px)`;
if (!animate) {
// Force reflow, then re-enable transition
indicator.offsetHeight;
indicator.style.transition = '';
}
}
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 and ARIA state
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('tabindex', '-1');
});
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
activeBtn.setAttribute('tabindex', '0');
updateTabIndicator(activeBtn);
}
// Load display monitors when switching to display tab
if (tabName === 'display') {
loadDisplayMonitors();
}
// 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);
}
// Accent color management
const accentPresets = [
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
{ name: 'Pink', color: '#ec4899', hover: '#f472b6' },
{ name: 'Orange', color: '#f97316', hover: '#fb923c' },
{ name: 'Red', color: '#ef4444', hover: '#f87171' },
{ name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
{ name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
];
function lightenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
const preset = accentPresets.find(p => p.color === saved);
if (preset) {
applyAccentColor(preset.color, preset.hover);
} else {
applyAccentColor(saved, lightenColor(saved, 15));
}
}
renderAccentSwatches();
}
function applyAccentColor(color, hover) {
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-hover', hover);
localStorage.setItem('accentColor', color);
const dot = document.getElementById('accentDot');
if (dot) dot.style.background = color;
}
function renderAccentSwatches() {
const dropdown = document.getElementById('accentDropdown');
if (!dropdown) return;
const current = localStorage.getItem('accentColor') || '#1db954';
const isCustom = !accentPresets.some(p => p.color === current);
const swatches = accentPresets.map(p =>
``
).join('');
const customRow = `
${t('accent.custom')}
`;
dropdown.innerHTML = swatches + customRow;
}
function selectAccentColor(color, hover) {
applyAccentColor(color, hover);
renderAccentSwatches();
document.getElementById('accentDropdown').classList.remove('open');
}
function toggleAccentPicker() {
document.getElementById('accentDropdown').classList.toggle('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.accent-picker')) {
document.getElementById('accentDropdown')?.classList.remove('open');
}
});
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
let currentPlayState = 'idle';
function toggleVinylMode() {
vinylMode = !vinylMode;
localStorage.setItem('vinylMode', vinylMode);
applyVinylMode();
}
function applyVinylMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('vinylToggle');
if (!container) return;
if (vinylMode) {
container.classList.add('vinyl');
if (btn) btn.classList.add('active');
updateVinylSpin();
} else {
container.classList.remove('vinyl', 'spinning', 'paused');
if (btn) btn.classList.remove('active');
}
}
function updateVinylSpin() {
const container = document.querySelector('.album-art-container');
if (!container || !vinylMode) return;
container.classList.remove('spinning', 'paused');
if (currentPlayState === 'playing') {
container.classList.add('spinning');
} else if (currentPlayState === 'paused') {
container.classList.add('paused');
}
}
// 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 and quick access to get translated content
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
displayQuickAccess();
}
renderAccentSwatches();
}
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 pingInterval = null;
let wsReconnectAttempts = 0;
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
let lastArtworkKey = null; // Track artwork identity to skip redundant loads
let currentArtworkBlobUrl = null; // Track current blob URL for safe revocation
// Dialog dirty state tracking
let scriptFormDirty = false;
let callbackFormDirty = false;
// Position interpolation
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
function setupProgressDrag(bar, fill) {
let dragging = false;
function getPercent(clientX) {
const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
function updatePreview(percent) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging');
updatePreview(getPercent(clientX));
}
function handleMove(clientX) {
if (!dragging) return;
updatePreview(getPercent(clientX));
}
function handleEnd(clientX) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
// Mouse events
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
// Touch events
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
}
});
// Simple click (mousedown + mouseup without move)
bar.addEventListener('click', (e) => {
if (currentDuration > 0) {
seek(getPercent(e.clientX) * currentDuration);
}
});
}
// Initialize on page load
window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references
cacheDom();
// Initialize theme and accent color
initTheme();
initAccentColor();
// Initialize vinyl mode
applyVinylMode();
// 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();
loadLinksTable();
} else {
showAuthForm();
}
// Shared volume slider setup (avoids duplicate handler code)
function setupVolumeSlider(sliderId) {
const slider = document.getElementById(sliderId);
slider.addEventListener('input', (e) => {
isUserAdjustingVolume = true;
const volume = parseInt(e.target.value);
// Sync both sliders and displays
dom.volumeDisplay.textContent = `${volume}%`;
dom.miniVolumeDisplay.textContent = `${volume}%`;
dom.volumeSlider.value = volume;
dom.miniVolumeSlider.value = volume;
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = setTimeout(() => {
setVolume(volume);
volumeUpdateTimer = null;
}, VOLUME_THROTTLE_MS);
});
slider.addEventListener('change', (e) => {
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
const volume = parseInt(e.target.value);
setVolume(volume);
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
});
}
setupVolumeSlider('volume-slider');
setupVolumeSlider('mini-volume-slider');
// Restore saved tab (migrate old tab names)
let savedTab = localStorage.getItem('activeTab') || 'player';
if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings';
switchTab(savedTab);
// Snap indicator to initial position without animation
const initialActiveBtn = document.querySelector('.tab-btn.active');
if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false);
// Re-position tab indicator on window resize
window.addEventListener('resize', () => {
const activeBtn = document.querySelector('.tab-btn.active');
if (activeBtn) updateTabIndicator(activeBtn, false);
});
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view
const playerContainer = document.querySelector('.player-container');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (activeTab !== 'player') return;
setMiniPlayerVisible(!entry.isIntersecting);
});
}, { threshold: 0.1 });
observer.observe(playerContainer);
// Drag-to-seek for progress bars
setupProgressDrag(
document.getElementById('mini-progress-bar'),
document.getElementById('mini-progress-fill')
);
setupProgressDrag(
document.getElementById('progress-bar'),
document.getElementById('progress-fill')
);
// 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();
}
});
// Delegated click handlers for script table actions (XSS-safe)
document.getElementById('scriptsTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.scriptName;
if (action === 'execute') executeScriptDebug(name);
else if (action === 'edit') showEditScriptDialog(name);
else if (action === 'delete') deleteScriptConfirm(name);
});
// Delegated click handlers for callback table actions (XSS-safe)
document.getElementById('callbacksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.callbackName;
if (action === 'execute') executeCallbackDebug(name);
else if (action === 'edit') showEditCallbackDialog(name);
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
if (e.target === linkDialog) {
closeLinkDialog();
}
});
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.linkName;
if (action === 'edit') showEditLinkDialog(name);
else if (action === 'delete') deleteLinkConfirm(name);
});
// Track link form dirty state
const linkForm = document.getElementById('linkForm');
linkForm.addEventListener('input', () => {
linkFormDirty = true;
});
linkForm.addEventListener('change', () => {
linkFormDirty = true;
});
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
loadMediaFolders();
}
// Icon preview for script and link dialogs
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Settings sections: restore collapse state and persist on toggle
document.querySelectorAll('.settings-section').forEach(details => {
const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`;
const saved = localStorage.getItem(key);
if (saved === 'closed') details.removeAttribute('open');
else if (saved === 'open') details.setAttribute('open', '');
details.addEventListener('toggle', () => {
localStorage.setItem(key, details.open ? 'open' : 'closed');
});
});
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
thumbnailCache.clear();
});
// Tab bar keyboard navigation (WAI-ARIA Tabs pattern)
document.getElementById('tabBar').addEventListener('keydown', (e) => {
const tabs = Array.from(document.querySelectorAll('.tab-btn'));
const currentIdx = tabs.indexOf(document.activeElement);
if (currentIdx === -1) return;
let newIdx;
if (e.key === 'ArrowRight') {
newIdx = (currentIdx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
newIdx = (currentIdx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
newIdx = 0;
} else if (e.key === 'End') {
newIdx = tabs.length - 1;
} else {
return;
}
e.preventDefault();
tabs[newIdx].focus();
switchTab(tabs[newIdx].dataset.tab);
});
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Skip when typing in inputs, textareas, selects, or when a dialog is open
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;
switch (e.key) {
case ' ':
e.preventDefault();
togglePlayPause();
break;
case 'ArrowLeft':
e.preventDefault();
if (currentDuration > 0) seek(Math.max(0, currentPosition - 5));
break;
case 'ArrowRight':
e.preventDefault();
if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5));
break;
case 'ArrowUp':
e.preventDefault();
setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5));
break;
case 'ArrowDown':
e.preventDefault();
setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5));
break;
case 'm':
case 'M':
toggleMute();
break;
}
});
});
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) {
// Clear previous ping interval to prevent stacking
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
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');
wsReconnectAttempts = 0;
updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
};
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 === 'links_changed') {
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} 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 with exponential backoff
wsReconnectAttempts++;
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
WS_BACKOFF_MAX_MS
);
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
if (wsReconnectAttempts >= 3) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
}
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
connectWebSocket(savedToken);
}
}, delay);
} else {
// Exhausted retries - show manual reconnect
showConnectionBanner(t('connection.lost'), true);
}
}
};
// Send keepalive ping
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, WS_PING_INTERVAL_MS);
}
function updateConnectionStatus(connected) {
if (connected) {
dom.statusDot.classList.add('connected');
} else {
dom.statusDot.classList.remove('connected');
}
}
function showConnectionBanner(message, showButton) {
const banner = document.getElementById('connectionBanner');
const text = document.getElementById('connectionBannerText');
const btn = document.getElementById('connectionBannerBtn');
text.textContent = message;
btn.style.display = showButton ? '' : 'none';
banner.classList.remove('hidden');
}
function hideConnectionBanner() {
const banner = document.getElementById('connectionBanner');
banner.classList.add('hidden');
}
function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken);
}
}
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');
dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || '';
dom.album.textContent = status.album || '';
// Update mini player info
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
dom.miniArtist.textContent = status.artist || '';
// Update state
const previousState = currentState;
currentState = status.state;
updatePlaybackState(status.state);
// Update album art (skip if same track to avoid redundant network requests)
const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
if (artworkKey !== lastArtworkKey) {
lastArtworkKey = artworkKey;
const placeholderArt = "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";
const placeholderGlow = "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%3C/svg%3E";
if (artworkSource) {
// Fetch artwork with Authorization header (avoid token in URL)
const token = localStorage.getItem('media_server_token');
fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
if (!blob) return;
const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
dom.albumArt.src = url;
dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
// Revoke old blob URL after a delay to let pending loads finish
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
} else {
if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl);
currentArtworkBlobUrl = null;
}
dom.albumArt.src = placeholderArt;
dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
}
}
// 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) {
dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume;
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
}
// Update mute state
updateMuteIcon(status.muted);
// Update source
dom.source.textContent = status.source || t('player.unknown_source');
// Enable/disable controls based on state
const hasMedia = status.state !== 'idle';
dom.btnPlayPause.disabled = !hasMedia;
dom.btnNext.disabled = !hasMedia;
dom.btnPrevious.disabled = !hasMedia;
dom.miniBtnPlayPause.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) {
currentPlayState = state;
switch(state) {
case 'playing':
dom.playbackState.textContent = t('state.playing');
dom.stateIcon.innerHTML = SVG_PLAY;
dom.playPauseIcon.innerHTML = SVG_PAUSE;
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
break;
case 'paused':
dom.playbackState.textContent = t('state.paused');
dom.stateIcon.innerHTML = SVG_PAUSE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
case 'stopped':
dom.playbackState.textContent = t('state.stopped');
dom.stateIcon.innerHTML = SVG_STOP;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
default:
dom.playbackState.textContent = t('state.idle');
dom.stateIcon.innerHTML = SVG_IDLE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
}
updateVinylSpin();
}
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar');
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound);
}
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) {
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
updateProgress(interpolatedPosition, currentDuration);
}
}, POSITION_INTERPOLATION_MS);
}
function stopPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function updateMuteIcon(muted) {
const path = muted ? SVG_MUTED : SVG_UNMUTED;
dom.muteIcon.innerHTML = path;
dom.miniMuteIcon.innerHTML = path;
}
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) {
const data = await response.json().catch(() => ({}));
console.error(`Command ${endpoint} failed:`, response.status);
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
}
} catch (error) {
console.error(`Error sending command ${endpoint}:`, error);
showToast(`Connection error: ${endpoint}`, 'error');
}
}
function togglePlayPause() {
if (currentState === 'playing') {
sendCommand('pause');
} else {
sendCommand('play');
}
}
function nextTrack() {
sendCommand('next');
}
function previousTrack() {
sendCommand('previous');
}
let lastSentVolume = -1;
function setVolume(volume) {
if (volume === lastSentVolume) return;
lastSentVolume = 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();
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
let _quickAccessGen = 0;
async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
// Build everything into a fragment before touching the DOM
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
// Render script buttons
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
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);
}
fragment.appendChild(button);
});
// Fetch link cards
try {
const token = localStorage.getItem('media_server_token');
if (token) {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (gen !== _quickAccessGen) return; // stale call, discard
if (response.ok) {
const links = await response.json();
hasLinks = links.length > 0;
links.forEach(link => {
const card = document.createElement('a');
card.className = 'script-btn link-card';
card.href = link.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
if (link.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', link.icon);
card.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = link.label || link.name;
card.appendChild(label);
if (link.description) {
const desc = document.createElement('div');
desc.className = 'script-description';
desc.textContent = link.description;
card.appendChild(desc);
}
fragment.appendChild(card);
});
}
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
// Show empty state if nothing
if (!hasScripts && !hasLinks) {
const empty = document.createElement('div');
empty.className = 'scripts-empty empty-state-illustration';
empty.innerHTML = `${t('quick_access.no_items')}
`;
fragment.prepend(empty);
}
// Replace grid content atomically
grid.innerHTML = '';
grid.appendChild(fragment);
// Resolve MDI icons
resolveMdiIcons(grid);
}
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 container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
// Trigger reflow then show
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
// Fallback removal if transitionend doesn't fire
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
}, TOAST_DURATION_MS);
}
function showConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('confirmDialog');
const msg = document.getElementById('confirmDialogMessage');
const btnCancel = document.getElementById('confirmDialogCancel');
const btnConfirm = document.getElementById('confirmDialogConfirm');
msg.textContent = message;
function cleanup() {
btnCancel.removeEventListener('click', onCancel);
btnConfirm.removeEventListener('click', onConfirm);
dialog.removeEventListener('close', onClose);
dialog.close();
}
function onCancel() { cleanup(); resolve(false); }
function onConfirm() { cleanup(); resolve(true); }
function onClose() { cleanup(); resolve(false); }
btnCancel.addEventListener('click', onCancel);
btnConfirm.addEventListener('click', onConfirm);
dialog.addEventListener('close', onClose);
dialog.showModal();
});
}
// Script Management Functions
let _loadScriptsPromise = null;
async function loadScriptsTable() {
if (_loadScriptsPromise) return _loadScriptsPromise;
_loadScriptsPromise = _loadScriptsTableImpl();
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
return _loadScriptsPromise;
}
async function _loadScriptsTableImpl() {
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 = '' + t('scripts.empty') + ' |
';
return;
}
tbody.innerHTML = scriptsList.map(script => `
${script.icon ? `` : ''}${escapeHtml(script.name)} |
${escapeHtml(script.label || script.name)} |
${escapeHtml(script.command || 'N/A')} |
${script.timeout}s |
|
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '| Failed to load scripts |
';
}
}
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;
document.getElementById('scriptIconPreview').innerHTML = '';
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;
// Update icon preview
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
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');
}
}
async function closeScriptDialog() {
// Check if form has unsaved changes
if (scriptFormDirty) {
if (!await showConfirm(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 submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
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;
closeScriptDialog();
} 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');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteScriptConfirm(scriptName) {
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', 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
let _loadCallbacksPromise = null;
async function loadCallbacksTable() {
if (_loadCallbacksPromise) return _loadCallbacksPromise;
_loadCallbacksPromise = _loadCallbacksTableImpl();
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
return _loadCallbacksPromise;
}
async function _loadCallbacksTableImpl() {
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 = '' + t('callbacks.empty') + ' |
';
return;
}
tbody.innerHTML = callbacksList.map(callback => `
${escapeHtml(callback.name)} |
${escapeHtml(callback.command)} |
${callback.timeout}s |
|
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '| Failed to load callbacks |
';
}
}
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');
}
}
async function closeCallbackDialog() {
// Check if form has unsaved changes
if (callbackFormDirty) {
if (!await showConfirm(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 submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
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;
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');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteCallbackConfirm(callbackName) {
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', 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 = `
${statusText}
${result.exit_code !== undefined ? result.exit_code : 'N/A'}
${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}
`;
// 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 = `
Running...
`;
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 = `
Running...
`;
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;
const thumbnailCache = new Map();
const THUMBNAIL_CACHE_MAX = 200;
// 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 = '';
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');
revokeBlobUrls(container);
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 = `
📁
${folder.label}
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.innerHTML = `
`;
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 = '';
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) {
let errorMsg = 'Failed to browse path';
if (response.status === 503) {
const errorData = await response.json().catch(() => ({}));
errorMsg = errorData.detail || 'Folder is temporarily unavailable (network share not accessible)';
}
throw new Error(errorMsg);
}
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);
const errorMsg = error.message || t('browser.error_loading');
showToast(errorMsg, '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 = '';
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 revokeBlobUrls(container) {
const cachedUrls = new Set(thumbnailCache.values());
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
// Don't revoke URLs managed by the thumbnail cache
if (!cachedUrls.has(img.src)) {
URL.revokeObjectURL(img.src);
}
});
}
function renderBrowserItems(items) {
const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
// 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 = `${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`;
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 = '';
icon.appendChild(overlay);
}
row.appendChild(icon);
// Name (show media title if available)
const name = document.createElement('div');
name.className = 'browser-list-name';
name.textContent = item.title || item.name;
row.appendChild(name);
// Bitrate
const br = document.createElement('div');
br.className = 'browser-list-bitrate';
br.textContent = formatBitrate(item.bitrate) || '';
row.appendChild(br);
// 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: show filename when title is displayed, or when name is ellipsed
row.addEventListener('mouseenter', () => {
if (item.title || 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 = `${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`;
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 = '';
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.title || 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);
const bitrate = formatBitrate(item.bitrate);
if (bitrate) parts.push(bitrate);
if (item.size !== null) parts.push(formatFileSize(item.size));
meta.textContent = parts.join(' \u00B7 ');
if (parts.length) info.appendChild(meta);
}
div.appendChild(info);
// Tooltip: show filename when title is displayed, or when name is ellipsed
div.addEventListener('mouseenter', () => {
if (item.title || 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': '',
'video': '',
};
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')}`;
}
function formatBitrate(bps) {
if (bps == null || bps <= 0) return null;
return Math.round(bps / 1000) + ' kbps';
}
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
// Check cache first
if (thumbnailCache.has(absolutePath)) {
const cachedUrl = thumbnailCache.get(absolutePath);
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
imgElement.src = cachedUrl;
return;
}
const encodedPath = encodeURIComponent(absolutePath);
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);
thumbnailCache.set(absolutePath, url);
// Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
const oldest = thumbnailCache.keys().next().value;
URL.revokeObjectURL(thumbnailCache.get(oldest));
thumbnailCache.delete(oldest);
}
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
// Revoke previous blob URL if not managed by cache
// (Cache is keyed by path, so check values)
if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false;
for (const url of thumbnailCache.values()) {
if (url === imgElement.src) { isCached = true; break; }
}
if (!isCached) URL.revokeObjectURL(imgElement.src);
}
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');
}
}
function buildAbsolutePath(folderId, relativePath, fileName) {
const folderPath = mediaFolders[folderId].path;
// Detect separator from folder path
const sep = folderPath.includes('/') ? '/' : '\\';
const fullRelative = relativePath === '/'
? sep + fileName
: relativePath.replace(/[/\\]/g, sep) + sep + fileName;
return folderPath + fullRelative;
}
let playInProgress = false;
async function playMediaFile(fileName) {
if (playInProgress) return;
playInProgress = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
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');
showToast(t('browser.play_success', { filename: fileName }), 'success');
} catch (error) {
console.error('Error playing file:', error);
showToast(t('browser.play_error'), 'error');
} finally {
playInProgress = false;
}
}
async function playAllFolder() {
if (playInProgress) return;
playInProgress = true;
const btn = document.getElementById('playAllBtn');
if (btn) btn.disabled = true;
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');
} finally {
playInProgress = false;
if (btn) btn.disabled = false;
}
}
async 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);
try {
const response = await fetch(
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
showToast(t('browser.download_error'), 'error');
}
}
function createDownloadBtn(fileName, cssClass) {
const btn = document.createElement('button');
btn.className = cssClass;
btn.innerHTML = '';
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();
}, SEARCH_DEBOUNCE_MS);
}
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) ||
(item.title && item.title.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 = `${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}
`;
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();
}
// ============================================================
// Display Brightness & Power Control
// ============================================================
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
container.innerHTML = ``;
return;
}
const monitors = await response.json();
if (monitors.length === 0) {
container.innerHTML = ``;
return;
}
container.innerHTML = '';
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
card.id = `monitor-card-${monitor.id}`;
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
let powerBtn = '';
if (monitor.power_supported) {
powerBtn = `
`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' · ');
const detailsHtml = details ? `${details}` : '';
const primaryBadge = monitor.is_primary ? `${t('display.primary')}` : '';
card.innerHTML = `
`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load display monitors:', e);
}
}
function onDisplayBrightnessInput(monitorId, value) {
const label = document.getElementById(`brightness-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = setTimeout(() => {
sendDisplayBrightness(monitorId, parseInt(value));
displayBrightnessTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
function onDisplayBrightnessChange(monitorId, value) {
if (displayBrightnessTimers[monitorId]) {
clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = null;
}
sendDisplayBrightness(monitorId, parseInt(value));
}
async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ brightness })
});
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
async function toggleDisplayPower(monitorId, monitorName) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ on: newState })
});
const data = await response.json();
if (data.success) {
if (btn) {
btn.classList.toggle('on', newState);
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
} else {
showToast('Failed to change monitor power', 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
}
}
// ============================================================
// Header Quick Links
// ============================================================
const mdiIconCache = {};
async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name"
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
return svg;
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
// Fallback: generic link icon
return '';
}
// Resolve all data-mdi-icon placeholders in a container
async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
// Debounced icon preview updater
function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
// Re-check value hasn't changed during fetch
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}
async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
let linkFormDirty = false;
async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = ' |
';
return;
}
tbody.innerHTML = linksList.map(link => `
${escapeHtml(link.name)} |
${escapeHtml(link.url)} |
${escapeHtml(link.label || '')} |
|
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '| Failed to load links |
';
}
}
function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
document.getElementById('linkDescription').value = link.description || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
dialog.close();
document.body.classList.remove('dialog-open');
}
async function saveLink(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || '',
description: document.getElementById('linkDescription').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
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(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}