From 98a33bca540a29f6b07a2a03a5b5900cfbc21dda Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Feb 2026 02:34:29 +0300 Subject: [PATCH] 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 --- media_server/routes/browser.py | 2 + media_server/services/browser_service.py | 83 ++++++++++++++--------- media_server/static/css/styles.css | 86 ++++++++++++++++++++++-- media_server/static/index.html | 36 ++++++++-- media_server/static/js/app.js | 75 +++++++++++++++++---- media_server/static/locales/en.json | 5 ++ media_server/static/locales/ru.json | 5 ++ 7 files changed, 237 insertions(+), 55 deletions(-) diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index 54c040a..8760471 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -218,6 +218,7 @@ async def browse( path: str = Query(default="", description="Path relative to folder root"), offset: int = Query(default=0, ge=0, description="Pagination offset"), 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), ): """Browse a directory and list files/folders. @@ -244,6 +245,7 @@ async def browse( path=decoded_path, offset=offset, limit=limit, + nocache=nocache, ) return result diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py index a7b7211..2569194 100644 --- a/media_server/services/browser_service.py +++ b/media_server/services/browser_service.py @@ -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 diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 2423522..a2603b2 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -30,6 +30,10 @@ box-sizing: border-box; } + html { + overflow-y: scroll; + } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; background: var(--bg-primary); @@ -144,6 +148,72 @@ 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 { background: var(--bg-secondary); border-radius: 12px; @@ -341,7 +411,6 @@ background: var(--bg-secondary); border-radius: 12px; padding: 2rem; - margin-top: 2rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); } @@ -407,7 +476,6 @@ background: var(--bg-secondary); border-radius: 12px; padding: 2rem; - margin-top: 2rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); } @@ -862,11 +930,11 @@ /* Mini Player (Sticky) */ .mini-player { position: fixed; - top: 0; + bottom: 0; left: 0; right: 0; background: var(--bg-secondary); - border-bottom: 1px solid var(--border); + border-top: 1px solid var(--border); padding: 0.75rem 1rem; display: flex; align-items: center; @@ -874,11 +942,11 @@ z-index: 1000; transform: translateY(0); 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 { - transform: translateY(-100%); + transform: translateY(100%); opacity: 0; pointer-events: none; } @@ -1087,6 +1155,11 @@ border-top: 1px solid var(--border); color: var(--text-secondary); font-size: 0.875rem; + transition: padding-bottom 0.3s ease-in-out; + } + + body.mini-player-visible footer { + padding-bottom: 70px; } footer a { @@ -1113,7 +1186,6 @@ background: var(--bg-secondary); border-radius: 12px; padding: 2rem; - margin-top: 2rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); } diff --git a/media_server/static/index.html b/media_server/static/index.html index 7d36213..2e71a3d 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -83,7 +83,31 @@ -
+ +
+ + + + + +
+ +
Album Art
@@ -144,7 +168,7 @@
-
+

Media Browser

@@ -203,8 +227,8 @@
- -