- Compact header and footer (reduced padding, margins, font sizes) - Remove separator borders from header and footer - Fix color contrast on hover states (white text on accent backgrounds) - Fix album art not updating on track change (composite cache key) - Slow vinyl spin animation (4s → 12s) - Replace Add buttons with dashed-border add-cards - Fix dialog header text color - Make theme toggle button transparent Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2424 lines
94 KiB
JavaScript
2424 lines
94 KiB
JavaScript
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||
|
||
// Empty state illustration SVGs
|
||
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||
function emptyStateHtml(svgStr, text) {
|
||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||
}
|
||
|
||
// 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_RECONNECT_MS = 3000;
|
||
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
|
||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||
if (activeBtn) {
|
||
activeBtn.classList.add('active');
|
||
updateTabIndicator(activeBtn);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// 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 to get translated content
|
||
const token = localStorage.getItem('media_server_token');
|
||
if (token) {
|
||
loadScriptsTable();
|
||
loadCallbacksTable();
|
||
}
|
||
}
|
||
|
||
async function fetchVersion() {
|
||
try {
|
||
const response = await fetch('/api/health');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const label = document.getElementById('version-label');
|
||
if (data.version) {
|
||
label.textContent = `v${data.version}`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching version:', error);
|
||
}
|
||
}
|
||
|
||
let ws = null;
|
||
let reconnectTimeout = null;
|
||
let pingInterval = null;
|
||
let currentState = 'idle';
|
||
let currentDuration = 0;
|
||
let currentPosition = 0;
|
||
let isUserAdjustingVolume = false;
|
||
let volumeUpdateTimer = null; // Timer for throttling volume updates
|
||
let scripts = [];
|
||
let lastStatus = null; // Store last status for locale switching
|
||
let lastArtworkKey = null; // Track artwork identity to skip redundant loads
|
||
|
||
// Dialog dirty state tracking
|
||
let scriptFormDirty = false;
|
||
let callbackFormDirty = false;
|
||
|
||
// Position interpolation
|
||
let lastPositionUpdate = 0;
|
||
let lastPositionValue = 0;
|
||
let interpolationInterval = null;
|
||
|
||
// Initialize on page load
|
||
window.addEventListener('DOMContentLoaded', async () => {
|
||
// Cache DOM references
|
||
cacheDom();
|
||
|
||
// Initialize theme
|
||
initTheme();
|
||
|
||
// 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();
|
||
} 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
|
||
const savedTab = localStorage.getItem('activeTab') || 'player';
|
||
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);
|
||
|
||
// Mini player progress bar click to seek
|
||
const miniProgressBar = document.getElementById('mini-progress-bar');
|
||
miniProgressBar.addEventListener('click', (e) => {
|
||
const rect = miniProgressBar.getBoundingClientRect();
|
||
const percent = (e.clientX - rect.left) / rect.width;
|
||
const position = percent * currentDuration;
|
||
seek(position);
|
||
});
|
||
|
||
// Progress bar click to seek
|
||
const progressBar = document.getElementById('progress-bar');
|
||
progressBar.addEventListener('click', (e) => {
|
||
if (currentDuration > 0) {
|
||
const rect = progressBar.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const percent = x / rect.width;
|
||
const seekPos = percent * currentDuration;
|
||
seek(seekPos);
|
||
}
|
||
});
|
||
|
||
// Enter key in token input
|
||
document.getElementById('token-input').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
authenticate();
|
||
}
|
||
});
|
||
|
||
// Script form dirty state tracking
|
||
const scriptForm = document.getElementById('scriptForm');
|
||
scriptForm.addEventListener('input', () => {
|
||
scriptFormDirty = true;
|
||
});
|
||
scriptForm.addEventListener('change', () => {
|
||
scriptFormDirty = true;
|
||
});
|
||
|
||
// Callback form dirty state tracking
|
||
const callbackForm = document.getElementById('callbackForm');
|
||
callbackForm.addEventListener('input', () => {
|
||
callbackFormDirty = true;
|
||
});
|
||
callbackForm.addEventListener('change', () => {
|
||
callbackFormDirty = true;
|
||
});
|
||
|
||
// Script dialog backdrop click to close
|
||
const scriptDialog = document.getElementById('scriptDialog');
|
||
scriptDialog.addEventListener('click', (e) => {
|
||
// Check if click is on the backdrop (not the dialog content)
|
||
if (e.target === scriptDialog) {
|
||
closeScriptDialog();
|
||
}
|
||
});
|
||
|
||
// Callback dialog backdrop click to close
|
||
const callbackDialog = document.getElementById('callbackDialog');
|
||
callbackDialog.addEventListener('click', (e) => {
|
||
// Check if click is on the backdrop (not the dialog content)
|
||
if (e.target === callbackDialog) {
|
||
closeCallbackDialog();
|
||
}
|
||
});
|
||
});
|
||
|
||
function showAuthForm(errorMessage = '') {
|
||
const overlay = document.getElementById('auth-overlay');
|
||
overlay.classList.remove('hidden');
|
||
|
||
const errorEl = document.getElementById('auth-error');
|
||
if (errorMessage) {
|
||
errorEl.textContent = errorMessage;
|
||
errorEl.classList.add('visible');
|
||
} else {
|
||
errorEl.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
function hideAuthForm() {
|
||
document.getElementById('auth-overlay').classList.add('hidden');
|
||
}
|
||
|
||
function authenticate() {
|
||
const token = document.getElementById('token-input').value.trim();
|
||
if (!token) {
|
||
showAuthForm(t('auth.required'));
|
||
return;
|
||
}
|
||
|
||
localStorage.setItem('media_server_token', token);
|
||
connectWebSocket(token);
|
||
}
|
||
|
||
function clearToken() {
|
||
localStorage.removeItem('media_server_token');
|
||
if (ws) {
|
||
ws.close();
|
||
}
|
||
showAuthForm(t('auth.cleared'));
|
||
}
|
||
|
||
function connectWebSocket(token) {
|
||
// 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');
|
||
updateConnectionStatus(true);
|
||
hideAuthForm();
|
||
loadScripts();
|
||
loadScriptsTable();
|
||
loadCallbacksTable();
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
|
||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||
updateUI(msg.data);
|
||
} else if (msg.type === 'scripts_changed') {
|
||
console.log('Scripts changed, reloading...');
|
||
loadScripts(); // Reload Quick Actions
|
||
loadScriptsTable(); // Reload Script Management table
|
||
} else if (msg.type === 'error') {
|
||
console.error('WebSocket error:', msg.message);
|
||
}
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
updateConnectionStatus(false);
|
||
};
|
||
|
||
ws.onclose = (event) => {
|
||
console.log('WebSocket closed:', event.code);
|
||
updateConnectionStatus(false);
|
||
stopPositionInterpolation();
|
||
|
||
if (event.code === 4001) {
|
||
// Invalid token
|
||
localStorage.removeItem('media_server_token');
|
||
showAuthForm(t('auth.invalid'));
|
||
} else if (event.code !== 1000) {
|
||
// Abnormal closure - attempt reconnect
|
||
reconnectTimeout = setTimeout(() => {
|
||
const savedToken = localStorage.getItem('media_server_token');
|
||
if (savedToken) {
|
||
console.log('Attempting to reconnect...');
|
||
connectWebSocket(savedToken);
|
||
}
|
||
}, WS_RECONNECT_MS);
|
||
}
|
||
};
|
||
|
||
// 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 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 artworkUrl = artworkSource
|
||
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}`
|
||
: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
|
||
dom.albumArt.src = artworkUrl;
|
||
dom.miniAlbumArt.src = artworkUrl;
|
||
if (dom.albumArtGlow) {
|
||
dom.albumArtGlow.src = artworkSource
|
||
? artworkUrl
|
||
: "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";
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
|
||
dom.progressFill.style.width = widthStr;
|
||
dom.currentTime.textContent = currentStr;
|
||
dom.totalTime.textContent = totalStr;
|
||
dom.progressBar.dataset.duration = duration;
|
||
|
||
dom.miniProgressFill.style.width = widthStr;
|
||
dom.miniCurrentTime.textContent = currentStr;
|
||
dom.miniTotalTime.textContent = totalStr;
|
||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||
}
|
||
|
||
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();
|
||
displayScripts();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading scripts:', error);
|
||
}
|
||
}
|
||
|
||
function displayScripts() {
|
||
const container = document.getElementById('scripts-container');
|
||
const grid = document.getElementById('scripts-grid');
|
||
|
||
grid.innerHTML = '';
|
||
|
||
if (scripts.length === 0) {
|
||
grid.innerHTML = `<div class="scripts-empty empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('scripts.no_scripts')}</p></div>`;
|
||
} else {
|
||
scripts.forEach(script => {
|
||
const button = document.createElement('button');
|
||
button.className = 'script-btn';
|
||
button.onclick = () => executeScript(script.name, button);
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'script-label';
|
||
label.textContent = script.label || script.name;
|
||
|
||
button.appendChild(label);
|
||
|
||
if (script.description) {
|
||
const description = document.createElement('div');
|
||
description.className = 'script-description';
|
||
description.textContent = script.description;
|
||
button.appendChild(description);
|
||
}
|
||
|
||
grid.appendChild(button);
|
||
});
|
||
}
|
||
|
||
// Add "+" card at the end
|
||
const addCard = document.createElement('div');
|
||
addCard.className = 'script-btn add-card-grid';
|
||
addCard.onclick = () => showAddScriptDialog();
|
||
addCard.innerHTML = '<span class="add-card-icon">+</span>';
|
||
grid.appendChild(addCard);
|
||
}
|
||
|
||
async function executeScript(scriptName, buttonElement) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
|
||
// Add executing state
|
||
buttonElement.classList.add('executing');
|
||
|
||
try {
|
||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ args: [] })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
showToast(`${scriptName} executed successfully`, 'success');
|
||
} else {
|
||
showToast(`Failed to execute ${scriptName}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error executing script ${scriptName}:`, error);
|
||
showToast(`Error executing ${scriptName}`, 'error');
|
||
} finally {
|
||
// Remove executing state
|
||
buttonElement.classList.remove('executing');
|
||
}
|
||
}
|
||
|
||
function showToast(message, type = 'success') {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type} show`;
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
}, TOAST_DURATION_MS);
|
||
}
|
||
|
||
// 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 = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = scriptsList.map(script => `
|
||
<tr>
|
||
<td><code>${script.name}</code></td>
|
||
<td>${script.label || script.name}</td>
|
||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
||
<td>${script.timeout}s</td>
|
||
<td>
|
||
<div class="action-buttons">
|
||
<button class="action-btn execute" onclick="executeScriptDebug('${script.name}')" title="Execute script">
|
||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||
</button>
|
||
<button class="action-btn" onclick="showEditScriptDialog('${script.name}')" title="Edit script">
|
||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||
</button>
|
||
<button class="action-btn delete" onclick="deleteScriptConfirm('${script.name}')" title="Delete script">
|
||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} catch (error) {
|
||
console.error('Error loading scripts:', error);
|
||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function showAddScriptDialog() {
|
||
const dialog = document.getElementById('scriptDialog');
|
||
const form = document.getElementById('scriptForm');
|
||
const title = document.getElementById('dialogTitle');
|
||
|
||
// Reset form
|
||
form.reset();
|
||
document.getElementById('scriptOriginalName').value = '';
|
||
document.getElementById('scriptIsEdit').value = 'false';
|
||
document.getElementById('scriptName').disabled = false;
|
||
title.textContent = t('scripts.dialog.add');
|
||
|
||
// Reset dirty state
|
||
scriptFormDirty = false;
|
||
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
}
|
||
|
||
async function showEditScriptDialog(scriptName) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
const dialog = document.getElementById('scriptDialog');
|
||
const title = document.getElementById('dialogTitle');
|
||
|
||
try {
|
||
// Fetch current script details
|
||
const response = await fetch('/api/scripts/list', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch script details');
|
||
}
|
||
|
||
const scriptsList = await response.json();
|
||
const script = scriptsList.find(s => s.name === scriptName);
|
||
|
||
if (!script) {
|
||
showToast('Script not found', 'error');
|
||
return;
|
||
}
|
||
|
||
// Populate form
|
||
document.getElementById('scriptOriginalName').value = scriptName;
|
||
document.getElementById('scriptIsEdit').value = 'true';
|
||
document.getElementById('scriptName').value = scriptName;
|
||
document.getElementById('scriptName').disabled = true; // Can't change name
|
||
document.getElementById('scriptLabel').value = script.label || '';
|
||
document.getElementById('scriptCommand').value = script.command || '';
|
||
document.getElementById('scriptDescription').value = script.description || '';
|
||
document.getElementById('scriptIcon').value = script.icon || '';
|
||
document.getElementById('scriptTimeout').value = script.timeout || 30;
|
||
|
||
title.textContent = t('scripts.dialog.edit');
|
||
|
||
// Reset dirty state
|
||
scriptFormDirty = false;
|
||
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
} catch (error) {
|
||
console.error('Error loading script for edit:', error);
|
||
showToast('Failed to load script details', 'error');
|
||
}
|
||
}
|
||
|
||
function closeScriptDialog() {
|
||
// Check if form has unsaved changes
|
||
if (scriptFormDirty) {
|
||
if (!confirm(t('scripts.confirm.unsaved'))) {
|
||
return; // User cancelled, don't close
|
||
}
|
||
}
|
||
|
||
const dialog = document.getElementById('scriptDialog');
|
||
scriptFormDirty = false; // Reset dirty state
|
||
dialog.close();
|
||
document.body.classList.remove('dialog-open');
|
||
}
|
||
|
||
async function saveScript(event) {
|
||
event.preventDefault();
|
||
|
||
const 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 (!confirm(`Are you sure you want to delete the script "${scriptName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
const token = localStorage.getItem('media_server_token');
|
||
|
||
try {
|
||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
showToast('Script deleted successfully', 'success');
|
||
// Don't reload manually - WebSocket will trigger it
|
||
} else {
|
||
showToast(result.detail || 'Failed to delete script', 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting script:', error);
|
||
showToast('Error deleting script', 'error');
|
||
}
|
||
}
|
||
|
||
// Callback Management Functions
|
||
|
||
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 = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = callbacksList.map(callback => `
|
||
<tr>
|
||
<td><code>${callback.name}</code></td>
|
||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
|
||
<td>${callback.timeout}s</td>
|
||
<td>
|
||
<div class="action-buttons">
|
||
<button class="action-btn execute" onclick="executeCallbackDebug('${callback.name}')" title="Execute callback">
|
||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||
</button>
|
||
<button class="action-btn" onclick="showEditCallbackDialog('${callback.name}')" title="Edit callback">
|
||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||
</button>
|
||
<button class="action-btn delete" onclick="deleteCallbackConfirm('${callback.name}')" title="Delete callback">
|
||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} catch (error) {
|
||
console.error('Error loading callbacks:', error);
|
||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
|
||
}
|
||
}
|
||
|
||
function showAddCallbackDialog() {
|
||
const dialog = document.getElementById('callbackDialog');
|
||
const form = document.getElementById('callbackForm');
|
||
const title = document.getElementById('callbackDialogTitle');
|
||
|
||
// Reset form
|
||
form.reset();
|
||
document.getElementById('callbackIsEdit').value = 'false';
|
||
document.getElementById('callbackName').disabled = false;
|
||
title.textContent = t('callbacks.dialog.add');
|
||
|
||
// Reset dirty state
|
||
callbackFormDirty = false;
|
||
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
}
|
||
|
||
async function showEditCallbackDialog(callbackName) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
const dialog = document.getElementById('callbackDialog');
|
||
const title = document.getElementById('callbackDialogTitle');
|
||
|
||
try {
|
||
// Fetch current callback details
|
||
const response = await fetch('/api/callbacks/list', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch callback details');
|
||
}
|
||
|
||
const callbacksList = await response.json();
|
||
const callback = callbacksList.find(c => c.name === callbackName);
|
||
|
||
if (!callback) {
|
||
showToast('Callback not found', 'error');
|
||
return;
|
||
}
|
||
|
||
// Populate form
|
||
document.getElementById('callbackIsEdit').value = 'true';
|
||
document.getElementById('callbackName').value = callbackName;
|
||
document.getElementById('callbackName').disabled = true; // Can't change event name
|
||
document.getElementById('callbackCommand').value = callback.command;
|
||
document.getElementById('callbackTimeout').value = callback.timeout;
|
||
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
|
||
|
||
title.textContent = t('callbacks.dialog.edit');
|
||
|
||
// Reset dirty state
|
||
callbackFormDirty = false;
|
||
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
} catch (error) {
|
||
console.error('Error loading callback for edit:', error);
|
||
showToast('Failed to load callback details', 'error');
|
||
}
|
||
}
|
||
|
||
function closeCallbackDialog() {
|
||
// Check if form has unsaved changes
|
||
if (callbackFormDirty) {
|
||
if (!confirm(t('callbacks.confirm.unsaved'))) {
|
||
return; // User cancelled, don't close
|
||
}
|
||
}
|
||
|
||
const dialog = document.getElementById('callbackDialog');
|
||
callbackFormDirty = false; // Reset dirty state
|
||
dialog.close();
|
||
document.body.classList.remove('dialog-open');
|
||
}
|
||
|
||
async function saveCallback(event) {
|
||
event.preventDefault();
|
||
|
||
const 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 (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
const token = localStorage.getItem('media_server_token');
|
||
|
||
try {
|
||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
showToast('Callback deleted successfully', 'success');
|
||
loadCallbacksTable();
|
||
} else {
|
||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting callback:', error);
|
||
showToast('Error deleting callback', 'error');
|
||
}
|
||
}
|
||
|
||
// Execution Result Dialog Functions
|
||
|
||
function closeExecutionDialog() {
|
||
const dialog = document.getElementById('executionDialog');
|
||
dialog.close();
|
||
document.body.classList.remove('dialog-open');
|
||
}
|
||
|
||
function showExecutionResult(name, result, type = 'script') {
|
||
const dialog = document.getElementById('executionDialog');
|
||
const title = document.getElementById('executionDialogTitle');
|
||
const statusDiv = document.getElementById('executionStatus');
|
||
const outputSection = document.getElementById('outputSection');
|
||
const errorSection = document.getElementById('errorSection');
|
||
const outputPre = document.getElementById('executionOutput');
|
||
const errorPre = document.getElementById('executionError');
|
||
|
||
// Set title
|
||
title.textContent = `Execution Result: ${name}`;
|
||
|
||
// Build status display
|
||
const success = result.success && result.exit_code === 0;
|
||
const statusClass = success ? 'success' : 'error';
|
||
const statusText = success ? 'Success' : 'Failed';
|
||
|
||
statusDiv.innerHTML = `
|
||
<div class="status-item ${statusClass}">
|
||
<label>Status</label>
|
||
<value>${statusText}</value>
|
||
</div>
|
||
<div class="status-item">
|
||
<label>Exit Code</label>
|
||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||
</div>
|
||
<div class="status-item">
|
||
<label>Duration</label>
|
||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||
</div>
|
||
`;
|
||
|
||
// Always show output section
|
||
outputSection.style.display = 'block';
|
||
if (result.stdout && result.stdout.trim()) {
|
||
outputPre.textContent = result.stdout;
|
||
} else {
|
||
outputPre.textContent = '(no output)';
|
||
outputPre.style.fontStyle = 'italic';
|
||
outputPre.style.color = 'var(--text-secondary)';
|
||
}
|
||
|
||
// Show error output if present
|
||
if (result.stderr && result.stderr.trim()) {
|
||
errorSection.style.display = 'block';
|
||
errorPre.textContent = result.stderr;
|
||
errorPre.style.fontStyle = 'normal';
|
||
errorPre.style.color = 'var(--error)';
|
||
} else if (!success && result.error) {
|
||
errorSection.style.display = 'block';
|
||
errorPre.textContent = result.error;
|
||
errorPre.style.fontStyle = 'normal';
|
||
errorPre.style.color = 'var(--error)';
|
||
} else {
|
||
errorSection.style.display = 'none';
|
||
}
|
||
|
||
dialog.showModal();
|
||
}
|
||
|
||
async function executeScriptDebug(scriptName) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
const dialog = document.getElementById('executionDialog');
|
||
const title = document.getElementById('executionDialogTitle');
|
||
const statusDiv = document.getElementById('executionStatus');
|
||
|
||
// Show dialog with loading state
|
||
title.textContent = `Executing: ${scriptName}`;
|
||
statusDiv.innerHTML = `
|
||
<div class="status-item">
|
||
<label>Status</label>
|
||
<value><span class="loading-spinner"></span> Running...</value>
|
||
</div>
|
||
`;
|
||
document.getElementById('outputSection').style.display = 'none';
|
||
document.getElementById('errorSection').style.display = 'none';
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
|
||
try {
|
||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ args: [] })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
showExecutionResult(scriptName, result, 'script');
|
||
} else {
|
||
showExecutionResult(scriptName, {
|
||
success: false,
|
||
exit_code: -1,
|
||
error: result.detail || 'Execution failed',
|
||
stderr: result.detail || 'Unknown error'
|
||
}, 'script');
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error executing script ${scriptName}:`, error);
|
||
showExecutionResult(scriptName, {
|
||
success: false,
|
||
exit_code: -1,
|
||
error: error.message,
|
||
stderr: `Network error: ${error.message}`
|
||
}, 'script');
|
||
}
|
||
}
|
||
|
||
async function executeCallbackDebug(callbackName) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
const dialog = document.getElementById('executionDialog');
|
||
const title = document.getElementById('executionDialogTitle');
|
||
const statusDiv = document.getElementById('executionStatus');
|
||
|
||
// Show dialog with loading state
|
||
title.textContent = `Executing: ${callbackName}`;
|
||
statusDiv.innerHTML = `
|
||
<div class="status-item">
|
||
<label>Status</label>
|
||
<value><span class="loading-spinner"></span> Running...</value>
|
||
</div>
|
||
`;
|
||
document.getElementById('outputSection').style.display = 'none';
|
||
document.getElementById('errorSection').style.display = 'none';
|
||
document.body.classList.add('dialog-open');
|
||
dialog.showModal();
|
||
|
||
try {
|
||
// For callbacks, we'll execute them directly via the callback endpoint
|
||
// We need to trigger the callback as if the event occurred
|
||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
showExecutionResult(callbackName, result, 'callback');
|
||
} else {
|
||
showExecutionResult(callbackName, {
|
||
success: false,
|
||
exit_code: -1,
|
||
error: result.detail || 'Execution failed',
|
||
stderr: result.detail || 'Unknown error'
|
||
}, 'callback');
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error executing callback ${callbackName}:`, error);
|
||
showExecutionResult(callbackName, {
|
||
success: false,
|
||
exit_code: -1,
|
||
error: error.message,
|
||
stderr: `Network error: ${error.message}`
|
||
}, 'callback');
|
||
}
|
||
}
|
||
|
||
|
||
// ========================================
|
||
// Media Browser Functionality
|
||
// ========================================
|
||
|
||
// Browser state
|
||
let currentFolderId = null;
|
||
let currentPath = '';
|
||
let currentOffset = 0;
|
||
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
|
||
let totalItems = 0;
|
||
let mediaFolders = {};
|
||
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||
let cachedItems = null;
|
||
let browserSearchTerm = '';
|
||
let browserSearchTimer = null;
|
||
|
||
// Load media folders on page load
|
||
async function loadMediaFolders() {
|
||
try {
|
||
const token = localStorage.getItem('media_server_token');
|
||
if (!token) {
|
||
console.error('No API token found');
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('/api/browser/folders', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to load folders');
|
||
|
||
mediaFolders = await response.json();
|
||
|
||
// Load last browsed path or show root folder list
|
||
loadLastBrowserPath();
|
||
} catch (error) {
|
||
console.error('Error loading media folders:', error);
|
||
showToast(t('browser.error_loading_folders'), 'error');
|
||
}
|
||
}
|
||
|
||
function showRootFolders() {
|
||
currentFolderId = '';
|
||
currentPath = '';
|
||
currentOffset = 0;
|
||
cachedItems = null;
|
||
|
||
// Hide search at root level
|
||
showBrowserSearch(false);
|
||
|
||
// Render breadcrumb with just "Home" (not clickable at root)
|
||
const breadcrumb = document.getElementById('breadcrumb');
|
||
breadcrumb.innerHTML = '';
|
||
const root = document.createElement('span');
|
||
root.className = 'breadcrumb-item breadcrumb-home';
|
||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||
breadcrumb.appendChild(root);
|
||
|
||
// Hide play all button and pagination
|
||
document.getElementById('playAllBtn').style.display = 'none';
|
||
document.getElementById('browserPagination').style.display = 'none';
|
||
|
||
// Render folders as grid cards
|
||
const container = document.getElementById('browserGrid');
|
||
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 = `
|
||
<div class="browser-list-icon">📁</div>
|
||
<div class="browser-list-name">${folder.label}</div>
|
||
`;
|
||
container.appendChild(row);
|
||
} else {
|
||
const card = document.createElement('div');
|
||
card.className = 'browser-item';
|
||
card.onclick = () => {
|
||
currentFolderId = id;
|
||
browsePath(id, '');
|
||
};
|
||
card.innerHTML = `
|
||
<div class="browser-thumb-wrapper">
|
||
<div class="browser-icon">📁</div>
|
||
</div>
|
||
<div class="browser-item-info">
|
||
<div class="browser-item-name">${folder.label}</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(card);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||
// Clear search when navigating
|
||
showBrowserSearch(false);
|
||
|
||
try {
|
||
const token = localStorage.getItem('media_server_token');
|
||
if (!token) {
|
||
console.error('No API token found');
|
||
return;
|
||
}
|
||
|
||
// Show loading spinner
|
||
const container = document.getElementById('browserGrid');
|
||
container.className = 'browser-grid';
|
||
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
|
||
|
||
const encodedPath = encodeURIComponent(path);
|
||
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
|
||
if (nocache) url += '&nocache=true';
|
||
const response = await fetch(
|
||
url,
|
||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||
);
|
||
|
||
if (!response.ok) {
|
||
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 = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||
home.onclick = () => showRootFolders();
|
||
breadcrumb.appendChild(home);
|
||
|
||
// Separator + Folder name
|
||
const sep = document.createElement('span');
|
||
sep.className = 'breadcrumb-separator';
|
||
sep.textContent = '›';
|
||
breadcrumb.appendChild(sep);
|
||
|
||
const folderItem = document.createElement('span');
|
||
folderItem.className = 'breadcrumb-item';
|
||
folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root';
|
||
if (parts.length > 0) {
|
||
folderItem.onclick = () => browsePath(currentFolderId, '');
|
||
}
|
||
breadcrumb.appendChild(folderItem);
|
||
|
||
// Path parts
|
||
parts.forEach((part, index) => {
|
||
// Separator
|
||
const separator = document.createElement('span');
|
||
separator.className = 'breadcrumb-separator';
|
||
separator.textContent = '›';
|
||
breadcrumb.appendChild(separator);
|
||
|
||
// Part
|
||
path += (path === '/' ? '' : '/') + part;
|
||
const item = document.createElement('span');
|
||
item.className = 'breadcrumb-item';
|
||
item.textContent = part;
|
||
const itemPath = path;
|
||
item.onclick = () => browsePath(currentFolderId, itemPath);
|
||
breadcrumb.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function revokeBlobUrls(container) {
|
||
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
|
||
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 = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||
return;
|
||
}
|
||
|
||
items.forEach(item => {
|
||
const row = document.createElement('div');
|
||
row.className = 'browser-list-item';
|
||
row.dataset.name = item.name;
|
||
row.dataset.type = item.type;
|
||
|
||
// Icon (small) with play overlay
|
||
const icon = document.createElement('div');
|
||
icon.className = 'browser-list-icon';
|
||
|
||
if (item.is_media && item.type === 'audio') {
|
||
const thumbnail = document.createElement('img');
|
||
thumbnail.className = 'browser-list-thumbnail loading';
|
||
thumbnail.alt = item.name;
|
||
icon.appendChild(thumbnail);
|
||
loadThumbnail(thumbnail, item.name);
|
||
} else {
|
||
icon.textContent = getFileIcon(item.type);
|
||
}
|
||
|
||
if (item.is_media) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'browser-list-play-overlay';
|
||
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
|
||
icon.appendChild(overlay);
|
||
}
|
||
row.appendChild(icon);
|
||
|
||
// Name (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 = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||
return;
|
||
}
|
||
|
||
items.forEach(item => {
|
||
const div = document.createElement('div');
|
||
div.className = 'browser-item';
|
||
div.dataset.name = item.name;
|
||
div.dataset.type = item.type;
|
||
|
||
// Type badge
|
||
if (item.type !== 'folder') {
|
||
const typeBadge = document.createElement('div');
|
||
typeBadge.className = `browser-item-type ${item.type}`;
|
||
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
|
||
div.appendChild(typeBadge);
|
||
}
|
||
|
||
// Thumbnail wrapper (for play overlay)
|
||
const thumbWrapper = document.createElement('div');
|
||
thumbWrapper.className = 'browser-thumb-wrapper';
|
||
|
||
// Thumbnail or icon
|
||
if (item.is_media && item.type === 'audio') {
|
||
const thumbnail = document.createElement('img');
|
||
thumbnail.className = 'browser-thumbnail loading';
|
||
thumbnail.alt = item.name;
|
||
thumbWrapper.appendChild(thumbnail);
|
||
|
||
// Lazy load thumbnail
|
||
loadThumbnail(thumbnail, item.name);
|
||
} else {
|
||
const icon = document.createElement('div');
|
||
icon.className = 'browser-icon';
|
||
icon.textContent = getFileIcon(item.type);
|
||
thumbWrapper.appendChild(icon);
|
||
}
|
||
|
||
// Play overlay for media files
|
||
if (item.is_media) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'browser-play-overlay';
|
||
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
|
||
thumbWrapper.appendChild(overlay);
|
||
}
|
||
|
||
div.appendChild(thumbWrapper);
|
||
|
||
// Info
|
||
const info = document.createElement('div');
|
||
info.className = 'browser-item-info';
|
||
|
||
const name = document.createElement('div');
|
||
name.className = 'browser-item-name';
|
||
name.textContent = item.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': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
|
||
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
|
||
};
|
||
return svgs[type] || '';
|
||
}
|
||
|
||
function getFileIcon(type) {
|
||
const icons = {
|
||
'folder': '📁',
|
||
'audio': '🎵',
|
||
'video': '🎬',
|
||
'other': '📄'
|
||
};
|
||
return icons[type] || icons.other;
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
if (seconds == null || seconds <= 0) return null;
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
if (h > 0) {
|
||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
return `${m}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
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 fullPath = currentPath === '/'
|
||
? '/' + fileName
|
||
: currentPath + '/' + fileName;
|
||
const encodedPath = encodeURIComponent(
|
||
mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\')
|
||
);
|
||
|
||
const response = await fetch(
|
||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||
);
|
||
|
||
if (response.status === 200) {
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
// Wait for image to actually load before showing it
|
||
imgElement.onload = () => {
|
||
imgElement.classList.remove('loading');
|
||
imgElement.classList.add('loaded');
|
||
};
|
||
|
||
// Revoke previous blob URL if any
|
||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||
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');
|
||
}
|
||
}
|
||
|
||
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 fullPath = currentPath === '/'
|
||
? '/' + fileName
|
||
: currentPath + '/' + fileName;
|
||
const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\');
|
||
|
||
const response = await fetch('/api/browser/play', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ path: absolutePath })
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to play file');
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
function downloadFile(fileName, event) {
|
||
if (event) event.stopPropagation();
|
||
const token = localStorage.getItem('media_server_token');
|
||
if (!token) return;
|
||
|
||
const fullPath = currentPath === '/'
|
||
? '/' + fileName
|
||
: currentPath + '/' + fileName;
|
||
const encodedPath = encodeURIComponent(fullPath);
|
||
const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`;
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
|
||
function createDownloadBtn(fileName, cssClass) {
|
||
const btn = document.createElement('button');
|
||
btn.className = cssClass;
|
||
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
|
||
btn.title = t('browser.download');
|
||
btn.onclick = (e) => downloadFile(fileName, e);
|
||
return btn;
|
||
}
|
||
|
||
function renderPagination() {
|
||
const pagination = document.getElementById('browserPagination');
|
||
const prevBtn = document.getElementById('prevPage');
|
||
const nextBtn = document.getElementById('nextPage');
|
||
const pageInput = document.getElementById('pageInput');
|
||
const pageTotal = document.getElementById('pageTotal');
|
||
|
||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
|
||
|
||
if (totalPages <= 1) {
|
||
pagination.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
pagination.style.display = 'flex';
|
||
pageInput.value = currentPage;
|
||
pageInput.max = totalPages;
|
||
pageTotal.textContent = `/ ${totalPages}`;
|
||
|
||
prevBtn.disabled = currentPage === 1;
|
||
nextBtn.disabled = currentPage === totalPages;
|
||
}
|
||
|
||
function previousPage() {
|
||
if (currentOffset >= itemsPerPage) {
|
||
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
|
||
}
|
||
}
|
||
|
||
function nextPage() {
|
||
if (currentOffset + itemsPerPage < totalItems) {
|
||
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
|
||
}
|
||
}
|
||
|
||
function refreshBrowser() {
|
||
if (currentFolderId) {
|
||
browsePath(currentFolderId, currentPath, currentOffset, true);
|
||
} else {
|
||
loadMediaFolders();
|
||
}
|
||
}
|
||
|
||
// Browser search
|
||
function onBrowserSearch() {
|
||
const input = document.getElementById('browserSearchInput');
|
||
const clearBtn = document.getElementById('browserSearchClear');
|
||
const term = input.value.trim();
|
||
|
||
clearBtn.style.display = term ? 'flex' : 'none';
|
||
|
||
// Debounce: wait 200ms after typing stops
|
||
if (browserSearchTimer) clearTimeout(browserSearchTimer);
|
||
browserSearchTimer = setTimeout(() => {
|
||
browserSearchTerm = term.toLowerCase();
|
||
applyBrowserSearch();
|
||
}, 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)
|
||
);
|
||
renderBrowserItems(filtered);
|
||
}
|
||
|
||
function showBrowserSearch(visible) {
|
||
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
|
||
if (!visible) {
|
||
document.getElementById('browserSearchInput').value = '';
|
||
document.getElementById('browserSearchClear').style.display = 'none';
|
||
browserSearchTerm = '';
|
||
}
|
||
}
|
||
|
||
function setViewMode(mode) {
|
||
if (mode === viewMode) return;
|
||
viewMode = mode;
|
||
localStorage.setItem('mediaBrowser.viewMode', mode);
|
||
|
||
// Update toggle buttons
|
||
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
|
||
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
|
||
document.getElementById(btnId).classList.add('active');
|
||
|
||
// Re-render current view from cache (no network request)
|
||
if (currentFolderId && cachedItems) {
|
||
applyBrowserSearch();
|
||
} else {
|
||
showRootFolders();
|
||
}
|
||
}
|
||
|
||
function onItemsPerPageChanged() {
|
||
const select = document.getElementById('itemsPerPageSelect');
|
||
itemsPerPage = parseInt(select.value);
|
||
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
|
||
|
||
// Reset to first page and reload
|
||
if (currentFolderId) {
|
||
currentOffset = 0;
|
||
browsePath(currentFolderId, currentPath, 0);
|
||
}
|
||
}
|
||
|
||
function goToPage() {
|
||
const pageInput = document.getElementById('pageInput');
|
||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||
let page = parseInt(pageInput.value);
|
||
|
||
if (isNaN(page) || page < 1) page = 1;
|
||
if (page > totalPages) page = totalPages;
|
||
|
||
pageInput.value = page;
|
||
const newOffset = (page - 1) * itemsPerPage;
|
||
if (newOffset !== currentOffset) {
|
||
browsePath(currentFolderId, currentPath, newOffset);
|
||
}
|
||
}
|
||
|
||
function initBrowserToolbar() {
|
||
// Restore view mode
|
||
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||
viewMode = savedViewMode;
|
||
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
|
||
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
|
||
document.getElementById(btnId).classList.add('active');
|
||
|
||
// Restore items per page
|
||
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
|
||
if (savedItemsPerPage) {
|
||
itemsPerPage = parseInt(savedItemsPerPage);
|
||
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
|
||
}
|
||
}
|
||
|
||
function clearBrowserGrid() {
|
||
const grid = document.getElementById('browserGrid');
|
||
grid.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}</div>`;
|
||
document.getElementById('breadcrumb').innerHTML = '';
|
||
document.getElementById('browserPagination').style.display = 'none';
|
||
document.getElementById('playAllBtn').style.display = 'none';
|
||
}
|
||
|
||
// LocalStorage for last path
|
||
function saveLastBrowserPath(folderId, path) {
|
||
try {
|
||
localStorage.setItem('mediaBrowser.lastFolderId', folderId);
|
||
localStorage.setItem('mediaBrowser.lastPath', path);
|
||
} catch (e) {
|
||
console.error('Failed to save last browser path:', e);
|
||
}
|
||
}
|
||
|
||
function loadLastBrowserPath() {
|
||
try {
|
||
const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId');
|
||
const lastPath = localStorage.getItem('mediaBrowser.lastPath');
|
||
|
||
if (lastFolderId && mediaFolders[lastFolderId]) {
|
||
currentFolderId = lastFolderId;
|
||
browsePath(lastFolderId, lastPath || '');
|
||
} else {
|
||
showRootFolders();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load last browser path:', e);
|
||
showRootFolders();
|
||
}
|
||
}
|
||
|
||
// Folder Management
|
||
function showManageFoldersDialog() {
|
||
// TODO: Implement folder management UI
|
||
// For now, show a simple alert
|
||
showToast(t('browser.manage_folders_hint'), 'info');
|
||
}
|
||
|
||
function closeFolderDialog() {
|
||
document.getElementById('folderDialog').close();
|
||
}
|
||
|
||
async function saveFolder(event) {
|
||
event.preventDefault();
|
||
// TODO: Implement folder save functionality
|
||
closeFolderDialog();
|
||
}
|
||
|
||
// Initialize browser on page load
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
initBrowserToolbar();
|
||
|
||
// Load media folders after authentication
|
||
const token = localStorage.getItem('media_server_token');
|
||
if (token) {
|
||
loadMediaFolders();
|
||
}
|
||
});
|