Add Play All, home navigation, and UI improvements

- Add Play All button with M3U playlist generation (local temp file with absolute paths)
- Replace folder combobox with root folder cards and home icon breadcrumb
- Fix compact grid card sizing (64x64 thumbnails, align-items: start)
- Add loading spinner when browsing folders
- Cache browse items to avoid re-fetching on view mode switch
- Remove unused browser-controls CSS
- Add localization keys for Play All and Home (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 01:57:32 +03:00
parent e16674c658
commit f275240e59
6 changed files with 277 additions and 72 deletions

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import unquote from urllib.parse import unquote
@@ -48,6 +49,13 @@ class PlayRequest(BaseModel):
path: str = Field(..., description="Full path to the media file") path: str = Field(..., description="Full path to the media file")
class PlayFolderRequest(BaseModel):
"""Request model for playing all media files in a folder."""
folder_id: str = Field(..., description="Media folder ID")
path: str = Field(default="", description="Path relative to folder root")
# Folder Management Endpoints # Folder Management Endpoints
@router.get("/folders") @router.get("/folders")
async def list_folders(_: str = Depends(verify_token)): async def list_folders(_: str = Depends(verify_token)):
@@ -423,6 +431,89 @@ async def play_file(
raise HTTPException(status_code=500, detail="Failed to play file") raise HTTPException(status_code=500, detail="Failed to play file")
# Play Folder Endpoint (M3U playlist)
@router.post("/play-folder")
async def play_folder(
request: PlayFolderRequest,
_: str = Depends(verify_token),
):
"""Play all media files in a folder by generating an M3U playlist.
Args:
request: Play folder request with folder_id and path.
Returns:
Success message with file count.
Raises:
HTTPException: If folder not found or playback fails.
"""
try:
decoded_path = unquote(request.path)
full_path = BrowserService.validate_path(request.folder_id, decoded_path)
if not full_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
# Collect all media files sorted by name
media_files = sorted(
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
key=lambda f: f.name.lower(),
)
if not media_files:
raise HTTPException(status_code=404, detail="No media files found in this folder")
# Generate M3U playlist with absolute paths and EXTINF entries
# Written to local temp dir to avoid extra SMB file handle on network shares
# Uses utf-8-sig (BOM) so players detect encoding properly
m3u_content = "#EXTM3U\r\n"
for f in media_files:
m3u_content += f"#EXTINF:-1,{f.stem}\r\n"
m3u_content += f"{f}\r\n"
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
# Open playlist with default player
controller = get_media_controller()
success = await controller.open_file(playlist_path)
if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist")
# Wait for media player to start
await asyncio.sleep(1.5)
# Broadcast status update
try:
status = await controller.get_status()
status_dict = status.model_dump()
await ws_manager.broadcast({
"type": "status",
"data": status_dict
})
logger.info(f"Broadcasted status after opening playlist with {len(media_files)} files")
except Exception as e:
logger.warning(f"Failed to broadcast status after opening playlist: {e}")
return {
"success": True,
"message": f"Playing {len(media_files)} files",
"count": len(media_files),
}
except HTTPException:
raise
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 Exception as e:
logger.error(f"Error playing folder: {e}")
raise HTTPException(status_code=500, detail="Failed to play folder")
# Download Endpoint # Download Endpoint
@router.get("/download") @router.get("/download")
async def download_file( async def download_file(

View File

@@ -1130,31 +1130,6 @@
margin: 0; margin: 0;
} }
.browser-controls {
margin-bottom: 1rem;
}
.browser-controls select {
width: 100%;
padding: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.3s;
}
.browser-controls select:hover {
border-color: var(--accent);
}
.browser-controls select:focus {
outline: none;
border-color: var(--accent);
}
/* Breadcrumb Navigation */ /* Breadcrumb Navigation */
.breadcrumb { .breadcrumb {
display: flex; display: flex;
@@ -1184,6 +1159,15 @@
text-decoration: underline; text-decoration: underline;
} }
.breadcrumb-home {
display: flex;
align-items: center;
}
.breadcrumb-home:hover {
text-decoration: none;
}
.breadcrumb-separator { .breadcrumb-separator {
color: var(--text-muted); color: var(--text-muted);
margin: 0 0.25rem; margin: 0 0.25rem;
@@ -1243,6 +1227,29 @@
fill: currentColor; fill: currentColor;
} }
.browser-play-all-btn {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
width: auto;
height: auto;
margin-left: 0.5rem;
}
.browser-play-all-btn:hover {
background: var(--accent-hover);
transform: none;
}
.items-per-page-label { .items-per-page-label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1272,6 +1279,7 @@
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
align-items: start;
} }
/* Compact Grid */ /* Compact Grid */
@@ -1432,6 +1440,18 @@
text-align: right; text-align: right;
} }
.browser-loading {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: 3rem;
}
.browser-loading .loading-spinner {
width: 28px;
height: 28px;
}
.browser-empty { .browser-empty {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
@@ -1620,9 +1640,8 @@
/* Compact grid overrides */ /* Compact grid overrides */
.browser-grid-compact .browser-thumb-wrapper { .browser-grid-compact .browser-thumb-wrapper {
width: 100%; width: 64px;
height: auto; height: 64px;
aspect-ratio: 1;
} }
.browser-grid-compact .browser-play-overlay svg { .browser-grid-compact .browser-play-overlay svg {

View File

@@ -147,13 +147,6 @@
<div class="browser-container"> <div class="browser-container">
<h2 data-i18n="browser.title">Media Browser</h2> <h2 data-i18n="browser.title">Media Browser</h2>
<!-- Folder Selection -->
<div class="browser-controls">
<select id="folderSelect" onchange="onFolderSelected()" data-i18n-empty="browser.select_folder">
<option value="" data-i18n="browser.select_folder_option">Select a folder...</option>
</select>
</div>
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="breadcrumb" id="breadcrumb"></div> <div class="breadcrumb" id="breadcrumb"></div>
@@ -171,6 +164,10 @@
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button> </button>
</div> </div>
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
<span data-i18n="browser.play_all">Play All</span>
</button>
</div> </div>
<div class="browser-toolbar-right"> <div class="browser-toolbar-right">
<label class="items-per-page-label"> <label class="items-per-page-label">

View File

@@ -1410,6 +1410,7 @@ let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) |
let totalItems = 0; let totalItems = 0;
let mediaFolders = {}; let mediaFolders = {};
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
// Load media folders on page load // Load media folders on page load
async function loadMediaFolders() { async function loadMediaFolders() {
@@ -1427,9 +1428,8 @@ async function loadMediaFolders() {
if (!response.ok) throw new Error('Failed to load folders'); if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json(); mediaFolders = await response.json();
renderFolderSelect();
// Load last browsed path // Load last browsed path or show root folder list
loadLastBrowserPath(); loadLastBrowserPath();
} catch (error) { } catch (error) {
console.error('Error loading media folders:', error); console.error('Error loading media folders:', error);
@@ -1437,33 +1437,69 @@ async function loadMediaFolders() {
} }
} }
function renderFolderSelect() { function showRootFolders() {
const select = document.getElementById('folderSelect'); currentFolderId = '';
select.innerHTML = `<option value="" data-i18n="browser.select_folder_option">${t('browser.select_folder_option')}</option>`; currentPath = '';
currentOffset = 0;
// 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');
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]) => { Object.entries(mediaFolders).forEach(([id, folder]) => {
if (folder.enabled) { if (!folder.enabled) return;
const option = document.createElement('option');
option.value = id; if (viewMode === 'list') {
option.textContent = folder.label; const row = document.createElement('div');
select.appendChild(option); row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.innerHTML = `
<div class="browser-list-icon">📁</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">📁</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
</div>
`;
container.appendChild(card);
} }
}); });
} }
function onFolderSelected() {
const select = document.getElementById('folderSelect');
currentFolderId = select.value;
if (currentFolderId) {
currentPath = '';
currentOffset = 0;
browsePath(currentFolderId, currentPath);
} else {
clearBrowserGrid();
}
}
async function browsePath(folderId, path, offset = 0) { async function browsePath(folderId, path, offset = 0) {
try { try {
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
@@ -1472,6 +1508,11 @@ async function browsePath(folderId, path, offset = 0) {
return; 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); const encodedPath = encodeURIComponent(path);
const response = await fetch( const response = await fetch(
`/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`, `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`,
@@ -1485,10 +1526,15 @@ async function browsePath(folderId, path, offset = 0) {
currentOffset = offset; currentOffset = offset;
totalItems = data.total; totalItems = data.total;
cachedItems = data.items;
renderBreadcrumbs(data.current_path, data.parent_path); renderBreadcrumbs(data.current_path, data.parent_path);
renderBrowserItems(data.items); renderBrowserItems(cachedItems);
renderPagination(); renderPagination();
// 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 // Save last path
saveLastBrowserPath(folderId, currentPath); saveLastBrowserPath(folderId, currentPath);
} catch (error) { } catch (error) {
@@ -1502,17 +1548,29 @@ function renderBreadcrumbs(currentPath, parentPath) {
const breadcrumb = document.getElementById('breadcrumb'); const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = ''; breadcrumb.innerHTML = '';
if (!currentPath || currentPath === '/') return; const parts = (currentPath || '').split('/').filter(p => p);
const parts = currentPath.split('/').filter(p => p);
let path = '/'; let path = '/';
// Root // Home link (back to folder list)
const root = document.createElement('span'); const home = document.createElement('span');
root.className = 'breadcrumb-item'; home.className = 'breadcrumb-item breadcrumb-home';
root.textContent = mediaFolders[currentFolderId]?.label || 'Root'; home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
root.onclick = () => browsePath(currentFolderId, ''); home.onclick = () => showRootFolders();
breadcrumb.appendChild(root); breadcrumb.appendChild(home);
// Separator + Folder name
const sep = document.createElement('span');
sep.className = 'breadcrumb-separator';
sep.textContent = '';
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 // Path parts
parts.forEach((part, index) => { parts.forEach((part, index) => {
@@ -1859,6 +1917,33 @@ async function playMediaFile(fileName) {
} }
} }
async function playAllFolder() {
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');
}
}
function downloadFile(fileName, event) { function downloadFile(fileName, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
@@ -1932,9 +2017,11 @@ function setViewMode(mode) {
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn'; const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active'); document.getElementById(btnId).classList.add('active');
// Re-render current items if we have a folder selected // Re-render current view from cache (no network request)
if (currentFolderId) { if (currentFolderId && cachedItems) {
browsePath(currentFolderId, currentPath, currentOffset); renderBrowserItems(cachedItems);
} else {
showRootFolders();
} }
} }
@@ -1986,6 +2073,7 @@ function clearBrowserGrid() {
grid.innerHTML = `<div class="browser-empty" data-i18n="browser.no_folder_selected">${t('browser.no_folder_selected')}</div>`; grid.innerHTML = `<div class="browser-empty" data-i18n="browser.no_folder_selected">${t('browser.no_folder_selected')}</div>`;
document.getElementById('breadcrumb').innerHTML = ''; document.getElementById('breadcrumb').innerHTML = '';
document.getElementById('browserPagination').style.display = 'none'; document.getElementById('browserPagination').style.display = 'none';
document.getElementById('playAllBtn').style.display = 'none';
} }
// LocalStorage for last path // LocalStorage for last path
@@ -2004,12 +2092,14 @@ function loadLastBrowserPath() {
const lastPath = localStorage.getItem('mediaBrowser.lastPath'); const lastPath = localStorage.getItem('mediaBrowser.lastPath');
if (lastFolderId && mediaFolders[lastFolderId]) { if (lastFolderId && mediaFolders[lastFolderId]) {
document.getElementById('folderSelect').value = lastFolderId;
currentFolderId = lastFolderId; currentFolderId = lastFolderId;
browsePath(lastFolderId, lastPath || ''); browsePath(lastFolderId, lastPath || '');
} else {
showRootFolders();
} }
} catch (e) { } catch (e) {
console.error('Failed to load last browser path:', e); console.error('Failed to load last browser path:', e);
showRootFolders();
} }
} }

View File

@@ -110,6 +110,7 @@
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?", "callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?", "callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"browser.title": "Media Browser", "browser.title": "Media Browser",
"browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",
"browser.select_folder": "Select a folder...", "browser.select_folder": "Select a folder...",
"browser.select_folder_option": "Select a folder...", "browser.select_folder_option": "Select a folder...",
@@ -125,6 +126,9 @@
"browser.download": "Download", "browser.download": "Download",
"browser.play_success": "Playing {filename}", "browser.play_success": "Playing {filename}",
"browser.play_error": "Failed to play file", "browser.play_error": "Failed to play file",
"browser.play_all": "Play All",
"browser.play_all_success": "Playing {count} files",
"browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory", "browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders", "browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.", "browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",

View File

@@ -110,6 +110,7 @@
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?", "callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?", "callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"browser.title": "Медиа Браузер", "browser.title": "Медиа Браузер",
"browser.home": "Главная",
"browser.manage_folders": "Управление папками", "browser.manage_folders": "Управление папками",
"browser.select_folder": "Выберите папку...", "browser.select_folder": "Выберите папку...",
"browser.select_folder_option": "Выберите папку...", "browser.select_folder_option": "Выберите папку...",
@@ -125,6 +126,9 @@
"browser.download": "Скачать", "browser.download": "Скачать",
"browser.play_success": "Воспроизведение {filename}", "browser.play_success": "Воспроизведение {filename}",
"browser.play_error": "Не удалось воспроизвести файл", "browser.play_error": "Не удалось воспроизвести файл",
"browser.play_all": "Воспроизвести все",
"browser.play_all_success": "Воспроизведение {count} файлов",
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога", "browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки", "browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.", "browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",