// ============================================================ // Media Browser: Navigation, rendering, search, pagination // ============================================================ // 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 = `
\u{1F4C1}
${folder.label}
`; container.appendChild(row); } else { const card = document.createElement('div'); card.className = 'browser-item'; card.onclick = () => { currentFolderId = id; browsePath(id, ''); }; card.innerHTML = `
\u{1F4C1}
${folder.label}
`; 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 = '\u203A'; 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 = '\u203A'; 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, idx) => { const row = document.createElement('div'); row.className = 'browser-list-item'; row.style.setProperty('--item-index', Math.min(idx, 20)); 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, idx) => { const div = document.createElement('div'); div.className = 'browser-item'; div.style.setProperty('--item-index', Math.min(idx, 20)); 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': '\u{1F4C1}', 'audio': '\u{1F3B5}', 'video': '\u{1F3AC}', 'other': '\u{1F4C4}' }; 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 = '\u{1F3B5}'; } else { const icon = document.createElement('div'); icon.className = 'browser-icon'; icon.textContent = '\u{1F3B5}'; 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() { closeDialog(document.getElementById('folderDialog')); } async function saveFolder(event) { event.preventDefault(); // TODO: Implement folder save functionality closeFolderDialog(); }