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": "Ошибка загрузки каталога",