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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Управление папками",
|
||||||
|
|||||||
Reference in New Issue
Block a user