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

@@ -2,12 +2,17 @@
import logging
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
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:
from mutagen import File as MutagenFile
HAS_MUTAGEN = True
@@ -141,6 +146,7 @@ class BrowserService:
path: str = "",
offset: int = 0,
limit: int = 100,
nocache: bool = False,
) -> dict:
"""Browse a directory and return items with metadata.
@@ -189,49 +195,66 @@ class BrowserService:
parent_relative = full_path.parent.relative_to(base_path)
parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/"
# List directory contents
# List directory contents (with caching)
try:
all_items = []
for item in full_path.iterdir():
# Skip hidden files (starting with .)
if item.name.startswith("."):
continue
cache_key = str(full_path)
now = time.monotonic()
# Get file type
file_type = BrowserService.get_file_type(item)
# Check cache
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)
if file_type == "other" and not item.is_dir():
continue
# Enumerate directory if not cached
if all_items is None:
all_items = []
for item in full_path.iterdir():
if item.name.startswith("."):
continue
# Get file info
try:
stat = item.stat()
size = stat.st_size if item.is_file() else None
modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
except (OSError, PermissionError):
size = None
modified = None
is_dir = item.is_dir()
if is_dir:
file_type = "folder"
else:
suffix = item.suffix.lower()
if suffix in AUDIO_EXTENSIONS:
file_type = "audio"
elif suffix in VIDEO_EXTENSIONS:
file_type = "video"
else:
continue
all_items.append({
"name": item.name,
"type": file_type,
"size": size,
"modified": modified,
"is_media": file_type in ("audio", "video"),
})
all_items.append({
"name": item.name,
"type": file_type,
"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
total = len(all_items)
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:
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"]:
item_path = full_path / item["name"]
item["duration"] = BrowserService.get_duration(item_path)
else:
item["duration"] = None