Tabbed UI, browse caching, and bottom mini player

- Convert stacked sections to tabbed interface (Player, Browser, Actions, Scripts, Callbacks) with localStorage persistence
- Add in-memory directory listing cache (5-min TTL) with nocache bypass for refresh
- Defer stat()/duration calls to paginated items only for faster browse
- Move mini player from top to bottom with footer padding fix
- Always show scrollbar to prevent layout shift between tabs
- Add tab localization keys (en/ru)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 02:34:29 +03:00
parent 8db40d3ee9
commit 98a33bca54
7 changed files with 237 additions and 55 deletions

View File

@@ -218,6 +218,7 @@ async def browse(
path: str = Query(default="", description="Path relative to folder root"), path: str = Query(default="", description="Path relative to folder root"),
offset: int = Query(default=0, ge=0, description="Pagination offset"), offset: int = Query(default=0, ge=0, description="Pagination offset"),
limit: int = Query(default=100, ge=1, le=1000, description="Pagination limit"), limit: int = Query(default=100, ge=1, le=1000, description="Pagination limit"),
nocache: bool = Query(default=False, description="Bypass directory cache"),
_: str = Depends(verify_token), _: str = Depends(verify_token),
): ):
"""Browse a directory and list files/folders. """Browse a directory and list files/folders.
@@ -244,6 +245,7 @@ async def browse(
path=decoded_path, path=decoded_path,
offset=offset, offset=offset,
limit=limit, limit=limit,
nocache=nocache,
) )
return result return result

View File

@@ -2,12 +2,17 @@
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from ..config import settings from ..config import settings
# Directory listing cache: {resolved_path_str: (timestamp, all_items)}
_dir_cache: dict[str, tuple[float, list[dict]]] = {}
DIR_CACHE_TTL = 300 # 5 minutes
try: try:
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
HAS_MUTAGEN = True HAS_MUTAGEN = True
@@ -141,6 +146,7 @@ class BrowserService:
path: str = "", path: str = "",
offset: int = 0, offset: int = 0,
limit: int = 100, limit: int = 100,
nocache: bool = False,
) -> dict: ) -> dict:
"""Browse a directory and return items with metadata. """Browse a directory and return items with metadata.
@@ -189,49 +195,66 @@ class BrowserService:
parent_relative = full_path.parent.relative_to(base_path) parent_relative = full_path.parent.relative_to(base_path)
parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/" parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/"
# List directory contents # List directory contents (with caching)
try: try:
all_items = [] cache_key = str(full_path)
for item in full_path.iterdir(): now = time.monotonic()
# Skip hidden files (starting with .)
if item.name.startswith("."):
continue
# Get file type # Check cache
file_type = BrowserService.get_file_type(item) if not nocache and cache_key in _dir_cache:
cached_time, cached_items = _dir_cache[cache_key]
if now - cached_time < DIR_CACHE_TTL:
all_items = cached_items
else:
del _dir_cache[cache_key]
all_items = None
else:
all_items = None
# Skip non-media files (but include folders) # Enumerate directory if not cached
if file_type == "other" and not item.is_dir(): if all_items is None:
continue all_items = []
for item in full_path.iterdir():
if item.name.startswith("."):
continue
# Get file info is_dir = item.is_dir()
try: if is_dir:
stat = item.stat() file_type = "folder"
size = stat.st_size if item.is_file() else None else:
modified = datetime.fromtimestamp(stat.st_mtime).isoformat() suffix = item.suffix.lower()
except (OSError, PermissionError): if suffix in AUDIO_EXTENSIONS:
size = None file_type = "audio"
modified = None elif suffix in VIDEO_EXTENSIONS:
file_type = "video"
else:
continue
all_items.append({ all_items.append({
"name": item.name, "name": item.name,
"type": file_type, "type": file_type,
"size": size, "is_media": file_type in ("audio", "video"),
"modified": modified, })
"is_media": file_type in ("audio", "video"),
})
# Sort: folders first, then by name all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower())) _dir_cache[cache_key] = (now, all_items)
# Apply pagination # Apply pagination
total = len(all_items) total = len(all_items)
items = all_items[offset:offset + limit] items = all_items[offset:offset + limit]
# Extract duration for media files in the current page # Fetch stat + duration only for items on the current page
for item in items: for item in items:
item_path = full_path / item["name"]
try:
stat = item_path.stat()
item["size"] = stat.st_size if item["type"] != "folder" else None
item["modified"] = datetime.fromtimestamp(stat.st_mtime).isoformat()
except (OSError, PermissionError):
item["size"] = None
item["modified"] = None
if item["is_media"]: if item["is_media"]:
item_path = full_path / item["name"]
item["duration"] = BrowserService.get_duration(item_path) item["duration"] = BrowserService.get_duration(item_path)
else: else:
item["duration"] = None item["duration"] = None

View File

@@ -30,6 +30,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
overflow-y: scroll;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
@@ -144,6 +148,72 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Tab Bar */
.tab-bar {
display: flex;
gap: 0.25rem;
margin-bottom: 1.5rem;
padding: 0.25rem;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border);
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 0.5rem;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
width: auto;
height: auto;
}
.tab-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary) !important;
transform: none !important;
}
.tab-btn.active {
color: var(--accent);
background: var(--bg-tertiary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.tab-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
[data-tab-content] {
display: none;
}
[data-tab-content].active {
display: block;
}
@media (max-width: 600px) {
.tab-btn span {
display: none;
}
.tab-btn {
padding: 0.6rem;
}
}
.player-container { .player-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
@@ -341,7 +411,6 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
margin-top: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -407,7 +476,6 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
margin-top: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -862,11 +930,11 @@
/* Mini Player (Sticky) */ /* Mini Player (Sticky) */
.mini-player { .mini-player {
position: fixed; position: fixed;
top: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -874,11 +942,11 @@
z-index: 1000; z-index: 1000;
transform: translateY(0); transform: translateY(0);
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
} }
.mini-player.hidden { .mini-player.hidden {
transform: translateY(-100%); transform: translateY(100%);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
@@ -1087,6 +1155,11 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
transition: padding-bottom 0.3s ease-in-out;
}
body.mini-player-visible footer {
padding-bottom: 70px;
} }
footer a { footer a {
@@ -1113,7 +1186,6 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
margin-top: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }

View File

@@ -83,7 +83,31 @@
</div> </div>
</header> </header>
<div class="player-container"> <!-- Tab Bar -->
<div class="tab-bar" id="tabBar">
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')">
<svg viewBox="0 0 24 24" width="16" height="16"><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>
<span data-i18n="tab.player">Player</span>
</button>
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
<span data-i18n="tab.browser">Browser</span>
</button>
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
<span data-i18n="tab.quick_actions">Actions</span>
</button>
<button class="tab-btn" data-tab="scripts" onclick="switchTab('scripts')">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
<span data-i18n="tab.scripts">Scripts</span>
</button>
<button class="tab-btn" data-tab="callbacks" onclick="switchTab('callbacks')">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span data-i18n="tab.callbacks">Callbacks</span>
</button>
</div>
<div class="player-container" data-tab-content="player">
<div class="album-art-container"> <div class="album-art-container">
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art"> <img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
</div> </div>
@@ -144,7 +168,7 @@
</div> </div>
<!-- Media Browser Section --> <!-- Media Browser Section -->
<div class="browser-container"> <div class="browser-container" data-tab-content="browser" >
<h2 data-i18n="browser.title">Media Browser</h2> <h2 data-i18n="browser.title">Media Browser</h2>
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
@@ -203,8 +227,8 @@
</div> </div>
</div> </div>
<!-- Scripts Section --> <!-- Scripts Section (Quick Actions) -->
<div class="scripts-container" id="scripts-container" style="display: none;"> <div class="scripts-container" id="scripts-container" data-tab-content="quick-actions" >
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2> <h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
<div class="scripts-grid" id="scripts-grid"> <div class="scripts-grid" id="scripts-grid">
<div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div> <div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div>
@@ -212,7 +236,7 @@
</div> </div>
<!-- Script Management Section --> <!-- Script Management Section -->
<div class="script-management"> <div class="script-management" data-tab-content="scripts" >
<div class="script-management-header"> <div class="script-management-header">
<h2 data-i18n="scripts.management">Script Management</h2> <h2 data-i18n="scripts.management">Script Management</h2>
<button class="add-script-btn" onclick="showAddScriptDialog()" data-i18n="scripts.add">+ Add</button> <button class="add-script-btn" onclick="showAddScriptDialog()" data-i18n="scripts.add">+ Add</button>
@@ -236,7 +260,7 @@
</div> </div>
<!-- Callback Management Section --> <!-- Callback Management Section -->
<div class="script-management"> <div class="script-management" id="callbacksSection" data-tab-content="callbacks" >
<div class="script-management-header"> <div class="script-management-header">
<h2 data-i18n="callbacks.management">Callback Management</h2> <h2 data-i18n="callbacks.management">Callback Management</h2>
<button class="add-script-btn" onclick="showAddCallbackDialog()" data-i18n="callbacks.add">+ Add</button> <button class="add-script-btn" onclick="showAddCallbackDialog()" data-i18n="callbacks.add">+ Add</button>

View File

@@ -1,3 +1,52 @@
// Tab management
let activeTab = 'player';
function setMiniPlayerVisible(visible) {
const miniPlayer = document.getElementById('mini-player');
if (visible) {
miniPlayer.classList.remove('hidden');
document.body.classList.add('mini-player-visible');
} else {
miniPlayer.classList.add('hidden');
document.body.classList.remove('mini-player-visible');
}
}
function switchTab(tabName) {
activeTab = tabName;
// Hide all tab content
document.querySelectorAll('[data-tab-content]').forEach(el => {
el.classList.remove('active');
el.style.display = '';
});
// Show selected tab content
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
if (target) {
target.classList.add('active');
}
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) activeBtn.classList.add('active');
// Save to localStorage
localStorage.setItem('activeTab', tabName);
// Mini-player: show when not on player tab
if (tabName !== 'player') {
setMiniPlayerVisible(true);
} else {
// Restore scroll-based behavior: check if player is in view
const playerContainer = document.querySelector('.player-container');
const rect = playerContainer.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
setMiniPlayerVisible(!inView);
}
}
// Theme management // Theme management
function initTheme() { function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark'; const savedTheme = localStorage.getItem('theme') || 'dark';
@@ -259,6 +308,10 @@
setTimeout(() => { isUserAdjustingVolume = false; }, 500); setTimeout(() => { isUserAdjustingVolume = false; }, 500);
}); });
// Restore saved tab
const savedTab = localStorage.getItem('activeTab') || 'player';
switchTab(savedTab);
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view // Mini Player: Intersection Observer to show/hide when main player scrolls out of view
const playerContainer = document.querySelector('.player-container'); const playerContainer = document.querySelector('.player-container');
const miniPlayer = document.getElementById('mini-player'); const miniPlayer = document.getElementById('mini-player');
@@ -271,13 +324,10 @@
const observerCallback = (entries) => { const observerCallback = (entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { // Only use scroll-based logic when on the player tab
// Main player is visible - hide mini player if (activeTab !== 'player') return;
miniPlayer.classList.add('hidden');
} else { setMiniPlayerVisible(!entry.isIntersecting);
// Main player is scrolled out of view - show mini player
miniPlayer.classList.remove('hidden');
}
}); });
}; };
@@ -738,11 +788,10 @@
const grid = document.getElementById('scripts-grid'); const grid = document.getElementById('scripts-grid');
if (scripts.length === 0) { if (scripts.length === 0) {
container.style.display = 'none'; grid.innerHTML = `<div class="scripts-empty">${t('scripts.no_scripts')}</div>`;
return; return;
} }
container.style.display = 'block';
grid.innerHTML = ''; grid.innerHTML = '';
scripts.forEach(script => { scripts.forEach(script => {
@@ -1500,7 +1549,7 @@ function showRootFolders() {
}); });
} }
async function browsePath(folderId, path, offset = 0) { async function browsePath(folderId, path, offset = 0, nocache = false) {
try { try {
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (!token) { if (!token) {
@@ -1514,8 +1563,10 @@ async function browsePath(folderId, path, offset = 0) {
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>'; container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
if (nocache) url += '&nocache=true';
const response = await fetch( const response = await fetch(
`/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`, url,
{ headers: { 'Authorization': `Bearer ${token}` } } { headers: { 'Authorization': `Bearer ${token}` } }
); );
@@ -2015,7 +2066,7 @@ function nextPage() {
function refreshBrowser() { function refreshBrowser() {
if (currentFolderId) { if (currentFolderId) {
browsePath(currentFolderId, currentPath, currentOffset); browsePath(currentFolderId, currentPath, currentOffset, true);
} else { } else {
loadMediaFolders(); loadMediaFolders();
} }

View File

@@ -109,6 +109,11 @@
"callbacks.msg.list_failed": "Failed to load callbacks", "callbacks.msg.list_failed": "Failed to load callbacks",
"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?",
"tab.player": "Player",
"tab.browser": "Browser",
"tab.quick_actions": "Actions",
"tab.scripts": "Scripts",
"tab.callbacks": "Callbacks",
"browser.title": "Media Browser", "browser.title": "Media Browser",
"browser.home": "Home", "browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",

View File

@@ -109,6 +109,11 @@
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы", "callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?", "callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?", "callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"tab.player": "Плеер",
"tab.browser": "Браузер",
"tab.quick_actions": "Действия",
"tab.scripts": "Скрипты",
"tab.callbacks": "Колбэки",
"browser.title": "Медиа Браузер", "browser.title": "Медиа Браузер",
"browser.home": "Главная", "browser.home": "Главная",
"browser.manage_folders": "Управление папками", "browser.manage_folders": "Управление папками",