Refactor monolithic app.js into 8 modular files
Split 3803-line app.js into focused modules: - core.js: shared state, utilities, i18n, API commands, MDI icons - player.js: tabs, theme, accent, vinyl, visualizer, UI updates - websocket.js: connection, auth, reconnection - scripts.js: scripts CRUD, quick access, execution dialog - callbacks.js: callbacks CRUD - browser.js: media file browser, thumbnails, pagination, search - links.js: links CRUD, header links, display controls - main.js: DOMContentLoaded init orchestrator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
880
media_server/static/js/browser.js
Normal file
880
media_server/static/js/browser.js
Normal file
@@ -0,0 +1,880 @@
|
||||
// ============================================================
|
||||
// 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 = '<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">\u{1F4C1}</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">\u{1F4C1}</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 = '\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 = `<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': '\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 = '<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) ||
|
||||
(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 = `<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();
|
||||
}
|
||||
Reference in New Issue
Block a user