Add media browser with grid/compact/list views and single-click playback

- Add browser UI with three view modes (grid, compact, list) and pagination
- Add file browsing, thumbnail loading, download, and play endpoints
- Add duration extraction via mutagen for media files
- Single-click plays media or navigates folders, with play overlay on hover
- Add type badges, file size display, and duration metadata
- Add localization keys for browser UI (en/ru)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:34:38 +03:00
parent 32b058c5fb
commit e16674c658
7 changed files with 784 additions and 66 deletions

View File

@@ -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")

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -157,6 +157,35 @@
<!-- Breadcrumb Navigation -->
<div class="breadcrumb" id="breadcrumb"></div>
<!-- Browser Toolbar -->
<div class="browser-toolbar" id="browserToolbar">
<div class="browser-toolbar-left">
<div class="view-toggle">
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
</button>
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
</button>
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button>
</div>
</div>
<div class="browser-toolbar-right">
<label class="items-per-page-label">
<span data-i18n="browser.items_per_page">Items per page:</span>
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
<option value="25">25</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</label>
</div>
</div>
<!-- File/Folder Grid -->
<div class="browser-grid" id="browserGrid">
<div class="browser-empty" data-i18n="browser.no_folder_selected">Select a folder to browse media files</div>
@@ -165,7 +194,11 @@
<!-- Pagination -->
<div class="pagination" id="browserPagination" style="display: none;">
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
<span id="pageInfo">1 / 1</span>
<div class="pagination-center">
<span data-i18n="browser.page">Page</span>
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
</div>
</div>

View File

@@ -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 = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${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
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 = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
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 = '<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';
@@ -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': '<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': '📁',
@@ -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 = '<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 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) {

View File

@@ -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",

View File

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