diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index c3cedcb..1a52a8c 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -7,10 +7,10 @@ from typing import Optional from urllib.parse import unquote from fastapi import APIRouter, Depends, HTTPException, Query, Response -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel, Field -from ..auth import verify_token +from ..auth import verify_token, verify_token_or_query from ..config import MediaFolderConfig, settings from ..config_manager import config_manager from ..services.browser_service import BrowserService @@ -421,3 +421,49 @@ async def play_file( except Exception as e: logger.error(f"Error playing file: {e}") raise HTTPException(status_code=500, detail="Failed to play file") + + +# Download Endpoint +@router.get("/download") +async def download_file( + folder_id: str = Query(..., description="Media folder ID"), + path: str = Query(..., description="File path relative to folder root (URL-encoded)"), + _: str = Depends(verify_token_or_query), +): + """Download a media file. + + Args: + folder_id: ID of the media folder. + path: Path to the file (URL-encoded, relative to folder root). + + Returns: + File download response. + + Raises: + HTTPException: If file not found or not a media file. + """ + try: + decoded_path = unquote(path) + file_path = BrowserService.validate_path(folder_id, decoded_path) + + if not file_path.is_file(): + raise HTTPException(status_code=400, detail="Path is not a file") + + if not BrowserService.is_media_file(file_path): + raise HTTPException(status_code=400, detail="File is not a media file") + + return FileResponse( + path=file_path, + filename=file_path.name, + media_type="application/octet-stream", + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error downloading file: {e}") + raise HTTPException(status_code=500, detail="Failed to download file") diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py index 9ef5b51..a7b7211 100644 --- a/media_server/services/browser_service.py +++ b/media_server/services/browser_service.py @@ -8,6 +8,12 @@ from typing import Optional from ..config import settings +try: + from mutagen import File as MutagenFile + HAS_MUTAGEN = True +except ImportError: + HAS_MUTAGEN = False + logger = logging.getLogger(__name__) # Media file extensions @@ -109,6 +115,26 @@ class BrowserService: else: return "other" + @staticmethod + def get_duration(file_path: Path) -> Optional[float]: + """Get duration of a media file in seconds (header-only read). + + Args: + file_path: Path to the media file. + + Returns: + Duration in seconds, or None if unavailable. + """ + if not HAS_MUTAGEN: + return None + try: + audio = MutagenFile(str(file_path)) + if audio is not None and hasattr(audio, "info") and hasattr(audio.info, "length"): + return round(audio.info.length, 2) + except Exception: + pass + return None + @staticmethod def browse_directory( folder_id: str, @@ -202,6 +228,14 @@ class BrowserService: total = len(all_items) items = all_items[offset:offset + limit] + # Extract duration for media files in the current page + for item in items: + if item["is_media"]: + item_path = full_path / item["name"] + item["duration"] = BrowserService.get_duration(item_path) + else: + item["duration"] = None + return { "folder_id": folder_id, "current_path": current_path, diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 975b9ef..90c5246 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -1189,6 +1189,82 @@ margin: 0 0.25rem; } +/* Browser Toolbar */ +.browser-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + gap: 1rem; +} + +.browser-toolbar-left, +.browser-toolbar-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.view-toggle { + display: flex; + background: var(--bg-tertiary); + border-radius: 6px; + border: 1px solid var(--border); + overflow: hidden; +} + +.view-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.6rem; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + width: auto; + height: auto; + border-radius: 0; +} + +.view-toggle-btn:hover { + color: var(--text-primary); + background: var(--border); + transform: none; +} + +.view-toggle-btn.active { + color: var(--accent); + background: var(--bg-primary); +} + +.view-toggle-btn svg { + fill: currentColor; +} + +.items-per-page-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.813rem; + color: var(--text-secondary); +} + +.items-per-page-label select { + padding: 0.3rem 0.5rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.813rem; + cursor: pointer; +} + +.items-per-page-label select:hover { + border-color: var(--accent); +} + /* Browser Grid */ .browser-grid { display: grid; @@ -1198,6 +1274,164 @@ min-height: 200px; } +/* Compact Grid */ +.browser-grid.browser-grid-compact { + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 0.5rem; +} + +.browser-grid-compact .browser-item { + padding: 0.4rem; + gap: 0.3rem; +} + + +.browser-grid-compact .browser-icon { + font-size: 2rem; +} + +.browser-grid-compact .browser-item-name { + font-size: 0.688rem; + -webkit-line-clamp: 1; +} + +.browser-grid-compact .browser-item-meta { + font-size: 0.625rem; +} + +.browser-grid-compact .browser-item-type { + font-size: 0.5rem; + padding: 0.15rem 0.35rem; + top: 0.25rem; + right: 0.25rem; +} + +/* Browser List View */ +.browser-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 1.5rem; + min-height: 200px; +} + +.browser-list-item { + display: grid; + grid-template-columns: 40px 1fr auto auto auto auto; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.browser-list-item:hover { + background: var(--border); + border-color: var(--accent); +} + + +.browser-list-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + border-radius: 4px; + background: var(--bg-primary); + flex-shrink: 0; + overflow: hidden; + position: relative; +} + +.browser-list-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; +} + +.browser-list-play-overlay svg { + width: 16px; + height: 16px; + color: #fff; +} + +.browser-list-item:hover .browser-list-play-overlay { + opacity: 1; +} + +.browser-list-thumbnail { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 4px; +} + +.browser-list-thumbnail.loading { + opacity: 0; +} + +.browser-list-thumbnail.loaded { + animation: fadeIn 0.3s ease-out forwards; +} + +.browser-list-name { + font-size: 0.813rem; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.browser-list-type { + font-size: 0.625rem; + text-transform: uppercase; + font-weight: 600; + color: var(--text-secondary); + padding: 0.15rem 0.5rem; + background: var(--bg-primary); + border-radius: 4px; + white-space: nowrap; +} + +.browser-list-type.audio { + color: var(--accent); +} + +.browser-list-type.video { + color: #3b82f6; +} + +.browser-list-duration { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + min-width: 45px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.browser-list-size { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + min-width: 60px; + text-align: right; +} + .browser-empty { grid-column: 1 / -1; text-align: center; @@ -1227,10 +1461,6 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } -.browser-item.selected { - border-color: var(--accent); - background: var(--border); -} /* Thumbnail Display */ .browser-thumbnail { @@ -1328,16 +1558,18 @@ .browser-item-type { position: absolute; - top: 0.5rem; - right: 0.5rem; + top: 0.35rem; + right: 0.35rem; background: var(--bg-primary); - padding: 0.25rem 0.5rem; + padding: 0.2rem; border-radius: 4px; - font-size: 0.625rem; - text-transform: uppercase; - font-weight: 600; color: var(--text-secondary); z-index: 10; + line-height: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; } .browser-item-type.audio { @@ -1348,6 +1580,79 @@ color: #3b82f6; } +/* Thumbnail Wrapper & Play Overlay */ +.browser-thumb-wrapper { + position: relative; + width: 120px; + height: 120px; + flex-shrink: 0; +} + +.browser-thumb-wrapper .browser-thumbnail, +.browser-thumb-wrapper .browser-icon { + width: 100%; + height: 100%; +} + +.browser-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + border-radius: 6px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.browser-play-overlay svg { + width: 40px; + height: 40px; + color: #fff; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); +} + +.browser-item:hover .browser-play-overlay { + opacity: 1; +} + +/* Compact grid overrides */ +.browser-grid-compact .browser-thumb-wrapper { + width: 100%; + height: auto; + aspect-ratio: 1; +} + +.browser-grid-compact .browser-play-overlay svg { + width: 24px; + height: 24px; +} + +/* Download Button (list view only) */ +.browser-list-download { + background: transparent; + border: none; + border-radius: 4px; + padding: 0.2rem; + color: var(--text-muted); + cursor: pointer; + transition: color 0.15s; + line-height: 0; + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; +} + +.browser-list-download:hover { + color: var(--accent); + background: transparent !important; + transform: none; +} + /* Pagination */ .pagination { display: flex; @@ -1383,11 +1688,37 @@ cursor: not-allowed; } -.pagination #pageInfo { +.pagination-center { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.875rem; color: var(--text-secondary); } +.page-input { + width: 3.5rem; + padding: 0.3rem 0.4rem; + text-align: center; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.875rem; + -moz-appearance: textfield; +} + +.page-input::-webkit-outer-spin-button, +.page-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.page-input:focus { + outline: none; + border-color: var(--accent); +} + /* Responsive Design */ @media (max-width: 600px) { .browser-grid { @@ -1395,8 +1726,11 @@ gap: 0.75rem; } - .browser-thumbnail, - .browser-icon { + .browser-grid.browser-grid-compact { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + } + + .browser-thumb-wrapper { width: 100px; height: 100px; } @@ -1418,4 +1752,31 @@ .browser-header-section button { width: 100%; } + + .browser-toolbar { + flex-direction: column; + align-items: stretch; + } + + .browser-toolbar-right { + justify-content: flex-end; + } + + .browser-list-item { + grid-template-columns: 32px 1fr auto auto; + gap: 0.5rem; + padding: 0.4rem 0.5rem; + } + + .browser-list-duration { + display: none; + } + + .browser-list-size { + display: none; + } + + .browser-list-type { + display: none; + } } diff --git a/media_server/static/index.html b/media_server/static/index.html index 4164913..4d1b4be 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -157,6 +157,35 @@ + +
+
+
+ + + +
+
+
+ +
+
+
Select a folder to browse media files
@@ -165,7 +194,11 @@
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 86cf907..0eec419 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -1406,10 +1406,10 @@ let currentFolderId = null; let currentPath = ''; let currentOffset = 0; -const ITEMS_PER_PAGE = 100; +let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100; let totalItems = 0; let mediaFolders = {}; -let selectedItem = null; +let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; // Load media folders on page load async function loadMediaFolders() { @@ -1474,7 +1474,7 @@ async function browsePath(folderId, path, offset = 0) { const encodedPath = encodeURIComponent(path); const response = await fetch( - `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${ITEMS_PER_PAGE}`, + `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`, { headers: { 'Authorization': `Bearer ${token}` } } ); @@ -1486,7 +1486,7 @@ async function browsePath(folderId, path, offset = 0) { totalItems = data.total; renderBreadcrumbs(data.current_path, data.parent_path); - renderBrowserGrid(data.items); + renderBrowserItems(data.items); renderPagination(); // Save last path @@ -1533,12 +1533,123 @@ function renderBreadcrumbs(currentPath, parentPath) { }); } -function renderBrowserGrid(items) { - const grid = document.getElementById('browserGrid'); - grid.innerHTML = ''; +function renderBrowserItems(items) { + const container = document.getElementById('browserGrid'); + // Switch container class based on view mode + if (viewMode === 'list') { + container.className = 'browser-list'; + renderBrowserList(items, container); + } else if (viewMode === 'compact') { + container.className = 'browser-grid browser-grid-compact'; + renderBrowserGrid(items, container); + } else { + container.className = 'browser-grid'; + renderBrowserGrid(items, container); + } +} + +function renderBrowserList(items, container) { + container.innerHTML = ''; if (!items || items.length === 0) { - grid.innerHTML = `
${t('browser.no_items')}
`; + container.innerHTML = `
${t('browser.no_items')}
`; + return; + } + + items.forEach(item => { + const row = document.createElement('div'); + row.className = 'browser-list-item'; + row.dataset.name = item.name; + row.dataset.type = item.type; + + // Icon (small) with play overlay + const icon = document.createElement('div'); + icon.className = 'browser-list-icon'; + + if (item.is_media && item.type === 'audio') { + const thumbnail = document.createElement('img'); + thumbnail.className = 'browser-list-thumbnail loading'; + thumbnail.alt = item.name; + icon.appendChild(thumbnail); + loadThumbnail(thumbnail, item.name); + } else { + icon.textContent = getFileIcon(item.type); + } + + if (item.is_media) { + const overlay = document.createElement('div'); + overlay.className = 'browser-list-play-overlay'; + overlay.innerHTML = ''; + icon.appendChild(overlay); + } + row.appendChild(icon); + + // Name + const name = document.createElement('div'); + name.className = 'browser-list-name'; + name.textContent = item.name; + row.appendChild(name); + + // Type badge + if (item.type !== 'folder') { + const typeBadge = document.createElement('div'); + typeBadge.className = `browser-list-type ${item.type}`; + typeBadge.textContent = item.type; + row.appendChild(typeBadge); + } else { + row.appendChild(document.createElement('div')); + } + + // Duration + const dur = document.createElement('div'); + dur.className = 'browser-list-duration'; + dur.textContent = formatDuration(item.duration) || ''; + row.appendChild(dur); + + // Size + const size = document.createElement('div'); + size.className = 'browser-list-size'; + size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : ''; + row.appendChild(size); + + // Download button + if (item.is_media) { + row.appendChild(createDownloadBtn(item.name, 'browser-list-download')); + } else { + row.appendChild(document.createElement('div')); + } + + // Tooltip on row when name is ellipsed + row.addEventListener('mouseenter', () => { + if (name.scrollWidth > name.clientWidth) { + row.title = item.name; + } else { + row.title = ''; + } + }); + + // Single click: play media or navigate folder + row.onclick = () => { + if (item.type === 'folder') { + const newPath = currentPath === '/' + ? '/' + item.name + : currentPath + '/' + item.name; + browsePath(currentFolderId, newPath); + } else if (item.is_media) { + playMediaFile(item.name); + } + }; + + container.appendChild(row); + }); +} + +function renderBrowserGrid(items, container) { + container = container || document.getElementById('browserGrid'); + container.innerHTML = ''; + + if (!items || items.length === 0) { + container.innerHTML = `
${t('browser.no_items')}
`; return; } @@ -1552,16 +1663,20 @@ function renderBrowserGrid(items) { if (item.type !== 'folder') { const typeBadge = document.createElement('div'); typeBadge.className = `browser-item-type ${item.type}`; - typeBadge.textContent = 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; - div.appendChild(thumbnail); + thumbWrapper.appendChild(thumbnail); // Lazy load thumbnail loadThumbnail(thumbnail, item.name); @@ -1569,9 +1684,19 @@ function renderBrowserGrid(items) { const icon = document.createElement('div'); icon.className = 'browser-icon'; icon.textContent = getFileIcon(item.type); - div.appendChild(icon); + 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'; @@ -1581,23 +1706,52 @@ function renderBrowserGrid(items) { name.textContent = item.name; info.appendChild(name); - if (item.size !== null && item.type !== 'folder') { + if (item.type !== 'folder') { const meta = document.createElement('div'); meta.className = 'browser-item-meta'; - meta.textContent = formatFileSize(item.size); - info.appendChild(meta); + const parts = []; + const duration = formatDuration(item.duration); + if (duration) parts.push(duration); + if (item.size !== null) parts.push(formatFileSize(item.size)); + meta.textContent = parts.join(' \u00B7 '); + if (parts.length) info.appendChild(meta); } div.appendChild(info); - // Events - div.onclick = () => handleItemClick(item, div); - div.ondblclick = () => handleItemDoubleClick(item); + // Tooltip on card when name is ellipsed + div.addEventListener('mouseenter', () => { + if (name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) { + div.title = item.name; + } else { + div.title = ''; + } + }); - grid.appendChild(div); + // 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': '📁', @@ -1616,6 +1770,17 @@ function formatFileSize(bytes) { return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]; } +function formatDuration(seconds) { + if (seconds == null || seconds <= 0) return null; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + } + return `${m}:${String(s).padStart(2, '0')}`; +} + async function loadThumbnail(imgElement, fileName) { try { const token = localStorage.getItem('media_server_token'); @@ -1662,30 +1827,6 @@ async function loadThumbnail(imgElement, fileName) { } } -function handleItemClick(item, element) { - // Clear previous selection - document.querySelectorAll('.browser-item.selected').forEach(el => { - el.classList.remove('selected'); - }); - - // Select current item - element.classList.add('selected'); - selectedItem = item; -} - -function handleItemDoubleClick(item) { - if (item.type === 'folder') { - // Navigate into folder - const newPath = currentPath === '/' - ? '/' + item.name - : currentPath + '/' + item.name; - browsePath(currentFolderId, newPath); - } else if (item.is_media) { - // Play media file - playMediaFile(item.name); - } -} - async function playMediaFile(fileName) { try { const token = localStorage.getItem('media_server_token'); @@ -1718,14 +1859,43 @@ async function playMediaFile(fileName) { } } +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 = ''; + 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 pageInfo = document.getElementById('pageInfo'); + const pageInput = document.getElementById('pageInput'); + const pageTotal = document.getElementById('pageTotal'); - const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); - const currentPage = Math.floor(currentOffset / ITEMS_PER_PAGE) + 1; + const totalPages = Math.ceil(totalItems / itemsPerPage); + const currentPage = Math.floor(currentOffset / itemsPerPage) + 1; if (totalPages <= 1) { pagination.style.display = 'none'; @@ -1733,21 +1903,81 @@ function renderPagination() { } pagination.style.display = 'flex'; - pageInfo.textContent = `${currentPage} / ${totalPages}`; + pageInput.value = currentPage; + pageInput.max = totalPages; + pageTotal.textContent = `/ ${totalPages}`; prevBtn.disabled = currentPage === 1; nextBtn.disabled = currentPage === totalPages; } function previousPage() { - if (currentOffset >= ITEMS_PER_PAGE) { - browsePath(currentFolderId, currentPath, currentOffset - ITEMS_PER_PAGE); + if (currentOffset >= itemsPerPage) { + browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage); } } function nextPage() { - if (currentOffset + ITEMS_PER_PAGE < totalItems) { - browsePath(currentFolderId, currentPath, currentOffset + ITEMS_PER_PAGE); + if (currentOffset + itemsPerPage < totalItems) { + browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage); + } +} + +function setViewMode(mode) { + 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 items if we have a folder selected + if (currentFolderId) { + browsePath(currentFolderId, currentPath, currentOffset); + } +} + +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; } } @@ -1802,6 +2032,8 @@ async function saveFolder(event) { // Initialize browser on page load window.addEventListener('DOMContentLoaded', () => { + initBrowserToolbar(); + // Load media folders after authentication const token = localStorage.getItem('media_server_token'); if (token) { diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index 4abbc6a..8fd75b5 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -115,8 +115,14 @@ "browser.select_folder_option": "Select a folder...", "browser.no_folder_selected": "Select a folder to browse media files", "browser.no_items": "No media files found in this folder", + "browser.view_grid": "Grid view", + "browser.view_compact": "Compact view", + "browser.view_list": "List view", + "browser.items_per_page": "Items per page:", + "browser.page": "Page", "browser.previous": "Previous", "browser.next": "Next", + "browser.download": "Download", "browser.play_success": "Playing {filename}", "browser.play_error": "Failed to play file", "browser.error_loading": "Error loading directory", diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index 1cc4b37..24d105c 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -115,8 +115,14 @@ "browser.select_folder_option": "Выберите папку...", "browser.no_folder_selected": "Выберите папку для просмотра медиафайлов", "browser.no_items": "В этой папке не найдено медиафайлов", + "browser.view_grid": "Сетка", + "browser.view_compact": "Компактный вид", + "browser.view_list": "Список", + "browser.items_per_page": "Элементов на странице:", + "browser.page": "Страница", "browser.previous": "Предыдущая", "browser.next": "Следующая", + "browser.download": "Скачать", "browser.play_success": "Воспроизведение {filename}", "browser.play_error": "Не удалось воспроизвести файл", "browser.error_loading": "Ошибка загрузки каталога",