Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3bebfeb8 | |||
| 34eb7c7b19 | |||
| 972ee54b91 | |||
| d09a0b90e4 | |||
| c3cb7a4da9 | |||
| e3889fef29 | |||
| 84500401e7 | |||
| 28293c6340 | |||
| 39b3aed5f3 | |||
| ba90dffa18 | |||
| 69df9b6b95 | |||
| 760c3df90c | |||
| 60f287bb40 | |||
| f52af51a20 | |||
| f2d569a1b0 | |||
| db777fa64b | |||
| 2961f8eaec | |||
| c50a8f472c | |||
| cad6e8a1fe | |||
| c9ee41ad35 |
+5
-16
@@ -1,18 +1,8 @@
|
||||
## v0.1.1 (2026-03-28)
|
||||
## v0.1.5 (2026-04-11)
|
||||
|
||||
### Bug Fixes
|
||||
- Use custom app icon for Windows shortcuts instead of the default Python executable icon ([5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503))
|
||||
- Check if port is already in use before starting the server ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
|
||||
|
||||
### Improvements
|
||||
- Replace `packaging` library with lightweight built-in version comparison — one fewer dependency ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Add manual build workflow for testing artifacts without tagging a release ([4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e))
|
||||
- Make WebSocket token query parameter optional to prevent connection failures for clients that authenticate via other means ([34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7))
|
||||
- Fetch playback status eagerly on new WebSocket connection instead of waiting for the next poll cycle ([d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9))
|
||||
|
||||
---
|
||||
|
||||
@@ -21,8 +11,7 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263) | fix: port-in-use check and remove packaging dependency | alexei.dolgolyov |
|
||||
| [5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503) | fix: use custom icon for Windows shortcuts instead of python.exe | alexei.dolgolyov |
|
||||
| [4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e) | ci: add manual build workflow for testing artifacts | alexei.dolgolyov |
|
||||
| [34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7) | fix(ws): make WebSocket token parameter optional | alexei.dolgolyov |
|
||||
| [d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9) | fix(ws): fetch status eagerly on new WebSocket connection | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
+20
-2
@@ -23,6 +23,17 @@ detect_version() {
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
|
||||
# Normalize non-PEP440 labels (e.g. "dev", "nightly", "snapshot") to a
|
||||
# valid PEP440 dev release. Without this, pip/setuptools rejects
|
||||
# pyproject.toml with: `project.version` must be pep440.
|
||||
#
|
||||
# Valid forms: 1.2.3, 1.2.3a1, 1.2.3rc2, 1.2.3.dev0, 1.2.3.post1, +local
|
||||
# Invalid forms: dev, vdev, nightly, snapshot-2024
|
||||
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
|
||||
VERSION_CLEAN="0.0.0.dev0"
|
||||
fi
|
||||
|
||||
# Stamp version into pyproject.toml (single source of truth)
|
||||
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
|
||||
}
|
||||
@@ -98,6 +109,13 @@ cleanup_site_packages() {
|
||||
# Strip debug symbols from native extensions
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
|
||||
# Remove .py source files (keep .pyc only) — saves ~30-40% on pure-Python packages
|
||||
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
|
||||
# NOTE: do NOT strip .py source files. A previous version of this function
|
||||
# ran `find ... -name "*.py" ! -name "__init__.py" -delete` with a comment
|
||||
# claiming "keep .pyc only" — but no compileall step exists, so the dist
|
||||
# shipped with __init__.py + .pyd only, missing every submodule (Image.py,
|
||||
# ImageDraw.py, _version.py, ...). Fresh installs would fail with
|
||||
# ModuleNotFoundError; in-place upgrades over an older install produced a
|
||||
# half-old/half-new site-packages where PIL/__init__.py was new but
|
||||
# PIL/_version.py was stale, yielding the runtime "_imaging extension was
|
||||
# built for another version of Pillow" import error.
|
||||
}
|
||||
|
||||
+38
-10
@@ -19,10 +19,15 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||
|
||||
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
|
||||
# --- Download embedded Python ---
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
# --- Download embedded Python (cache-friendly) ---
|
||||
mkdir -p build
|
||||
if [ ! -f build/python-embed.zip ]; then
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
else
|
||||
echo "Using cached embedded Python ${PYTHON_VERSION}"
|
||||
fi
|
||||
unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
|
||||
|
||||
# Patch ._pth to enable site-packages and app source
|
||||
@@ -35,9 +40,18 @@ echo '..\app' >> "$PTH_FILE"
|
||||
echo "Downloading Windows wheels..."
|
||||
|
||||
# Core dependencies
|
||||
# NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker.
|
||||
# pip evaluates env markers against the HOST (Linux in CI), so uvloop is
|
||||
# requested, but `--platform win_amd64 --only-binary :all:` cannot find a
|
||||
# Windows wheel for uvloop (none exist). Result: pip backtracks across every
|
||||
# uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn
|
||||
# and list only the Windows-compatible standard extras we actually need.
|
||||
CORE_DEPS=(
|
||||
"fastapi>=0.109.0"
|
||||
"uvicorn[standard]>=0.27.0"
|
||||
"uvicorn>=0.27.0"
|
||||
"httptools>=0.5.0"
|
||||
"websockets>=10.4"
|
||||
"python-dotenv>=0.13"
|
||||
"pydantic>=2.0"
|
||||
"pydantic-settings>=2.0"
|
||||
"pyyaml>=6.0"
|
||||
@@ -53,23 +67,37 @@ WIN_DEPS=(
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"monitorcontrol>=3.0.0"
|
||||
"pystray>=0.19.0"
|
||||
)
|
||||
|
||||
# Visualizer dependencies
|
||||
VIS_DEPS=(
|
||||
"soundcard>=0.4.0"
|
||||
"numpy>=1.24.0,<2.0"
|
||||
# pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the
|
||||
# same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded
|
||||
# a second Pillow version that clobbered the core one on unzip, producing
|
||||
# "_imaging extension was built for another version of Pillow" at runtime.
|
||||
"pystray>=0.19.0"
|
||||
)
|
||||
|
||||
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
# Resolve core + visualizer deps in a SINGLE call so pip picks compatible
|
||||
# transitive versions (notably pydantic/pydantic-core must match).
|
||||
# Per-dep loops resolve each dep independently and can leave mismatched
|
||||
# transitive versions that overwrite each other in the site-packages unzip.
|
||||
CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"${CROSS_DEPS[@]}"
|
||||
|
||||
for dep in "${ALL_DEPS[@]}"; do
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
# Windows-only deps in a loop with --pre: winsdk only ships beta wheels
|
||||
# (1.0.0bNN) and each dep needs its own platform/non-platform fallback.
|
||||
for dep in "${WIN_DEPS[@]}"; do
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--only-binary :all: \
|
||||
"$dep"
|
||||
done
|
||||
|
||||
@@ -56,6 +56,11 @@ scripts:
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
# Media folder management from Web UI (default: true)
|
||||
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
||||
# Set to false to disable folder management from the UI.
|
||||
# media_folders_management: false
|
||||
|
||||
# Callback scripts (executed after media actions)
|
||||
# All callbacks are optional - if not defined, the action runs without callback
|
||||
callbacks:
|
||||
|
||||
@@ -70,6 +70,19 @@ Section "!Core (required)" SecCore
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Wipe previous payload before extracting so stale files from an older
|
||||
; version cannot survive an upgrade. Without this, in-place upgrades
|
||||
; produce a half-old/half-new site-packages — e.g. an old PIL/_version.py
|
||||
; alongside a new PIL/_imaging.pyd, which raises "_imaging extension was
|
||||
; built for another version of Pillow" at runtime. config.yaml lives at
|
||||
; $INSTDIR root and is preserved.
|
||||
RMDir /r "$INSTDIR\python"
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\${EXENAME}"
|
||||
Delete "$INSTDIR\VERSION"
|
||||
Delete "$INSTDIR\config.example.yaml"
|
||||
|
||||
; Copy entire distribution
|
||||
File /r "dist\media-server\*.*"
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ class Settings(BaseSettings):
|
||||
default_factory=dict,
|
||||
description="Media folders available for browsing in the media browser",
|
||||
)
|
||||
media_folders_management: bool = Field(
|
||||
default=True,
|
||||
description="Allow adding, editing, and deleting media folders from the Web UI",
|
||||
)
|
||||
|
||||
# Thumbnail settings
|
||||
thumbnail_size: str = Field(
|
||||
|
||||
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
|
||||
def _require_folder_management() -> None:
|
||||
"""Raise 403 if media folder management is disabled in config."""
|
||||
if not settings.media_folders_management:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
||||
"""Poll until media session registers, then broadcast status update.
|
||||
|
||||
@@ -83,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)):
|
||||
"""List all configured media folders.
|
||||
|
||||
Returns:
|
||||
Dictionary of folder configurations.
|
||||
Dictionary with folder configurations and management flag.
|
||||
"""
|
||||
folders = {}
|
||||
for folder_id, config in settings.media_folders.items():
|
||||
folder_path = Path(config.path)
|
||||
folders[folder_id] = {
|
||||
"id": folder_id,
|
||||
"label": config.label,
|
||||
"path": config.path,
|
||||
"enabled": config.enabled,
|
||||
"available": folder_path.is_dir(),
|
||||
}
|
||||
return folders
|
||||
return {
|
||||
"folders": folders,
|
||||
"management_enabled": settings.media_folders_management,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/folders/create")
|
||||
@@ -112,6 +126,7 @@ async def create_folder(
|
||||
Raises:
|
||||
HTTPException: If folder already exists or validation fails.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
@@ -169,6 +184,7 @@ async def update_folder(
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist or validation fails.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate path exists
|
||||
path = Path(request.path)
|
||||
@@ -217,6 +233,7 @@ async def delete_folder(
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
config_manager.delete_media_folder(folder_id)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Request
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import auth_enabled
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["health"])
|
||||
|
||||
@@ -23,6 +24,7 @@ async def health_check(request: Request) -> dict[str, Any]:
|
||||
"platform": platform.system(),
|
||||
"version": __version__,
|
||||
"auth_required": auth_enabled(),
|
||||
"media_folders_management": settings.media_folders_management,
|
||||
}
|
||||
|
||||
# Include cached update info if available
|
||||
|
||||
@@ -323,7 +323,7 @@ async def set_visualizer_device(
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="API authentication token"),
|
||||
token: str | None = Query(None, description="API authentication token"),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for real-time media status updates.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class ConnectionManager:
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
@@ -39,9 +40,17 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
# Send current status immediately upon connection
|
||||
if self._last_status:
|
||||
status = self._last_status
|
||||
if not status and self._get_status_func:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": self._last_status})
|
||||
result = await self._get_status_func()
|
||||
status = result.model_dump()
|
||||
self._last_status = status
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch initial status: %s", e)
|
||||
if status:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": status})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
@@ -251,6 +260,7 @@ class ConnectionManager:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._get_status_func = get_status_func
|
||||
self._running = True
|
||||
self._broadcast_task = asyncio.create_task(
|
||||
self._status_monitor_loop(get_status_func)
|
||||
|
||||
@@ -192,17 +192,67 @@ h1 {
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.status-dot::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
.status-dot.connected::before,
|
||||
.status-dot.status-online::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.status-dot.status-offline::before {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
/* Folder management */
|
||||
.folder-unavailable-badge,
|
||||
.folder-disabled-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.folder-unavailable-badge {
|
||||
background: color-mix(in srgb, var(--error) 20%, transparent);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.folder-disabled-badge {
|
||||
background: color-mix(in srgb, var(--text-secondary) 20%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.browser-item.unavailable,
|
||||
.browser-list-item.unavailable {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.path-cell {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2681,7 +2731,7 @@ footer .separator {
|
||||
.browser-container {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -2702,14 +2752,20 @@ footer .separator {
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.813rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb:empty {
|
||||
@@ -2717,28 +2773,44 @@ footer .separator {
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
transition: all 0.2s;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
color: var(--accent);
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-item:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breadcrumb-home {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-home:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0.25rem;
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Browser Toolbar */
|
||||
@@ -2909,13 +2981,19 @@ footer .separator {
|
||||
/* Browser Grid */
|
||||
.browser-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 200px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Root folder grid — wider cards */
|
||||
.browser-grid.browser-root-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Compact Grid */
|
||||
.browser-grid.browser-grid-compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 100px));
|
||||
@@ -2952,41 +3030,66 @@ footer .separator {
|
||||
.browser-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* List view column header */
|
||||
.browser-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.688rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.browser-list-header span:nth-child(n+3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.browser-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
animation: itemFadeIn 0.3s ease-out backwards;
|
||||
animation-delay: calc(var(--item-index, 0) * 20ms);
|
||||
animation-delay: calc(var(--item-index, 0) * 15ms);
|
||||
}
|
||||
|
||||
.browser-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.browser-list-item:active {
|
||||
background: var(--border);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.browser-list-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@@ -2998,8 +3101,8 @@ footer .separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
@@ -3016,10 +3119,10 @@ footer .separator {
|
||||
}
|
||||
|
||||
.browser-list-thumbnail {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.browser-list-thumbnail.loading {
|
||||
@@ -3046,6 +3149,7 @@ footer .separator {
|
||||
white-space: nowrap;
|
||||
min-width: 55px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.browser-list-duration {
|
||||
@@ -3063,6 +3167,7 @@ footer .separator {
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.browser-loading {
|
||||
@@ -3087,85 +3192,114 @@ footer .separator {
|
||||
|
||||
.browser-item {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
animation: itemFadeIn 0.3s ease-out backwards;
|
||||
animation-delay: calc(var(--item-index, 0) * 30ms);
|
||||
animation-delay: calc(var(--item-index, 0) * 25ms);
|
||||
}
|
||||
|
||||
@keyframes itemFadeIn {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.browser-item:hover {
|
||||
background: var(--border);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--border);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.browser-item:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Root Folder Cards — distinctive hero style */
|
||||
.browser-item.browser-root-folder {
|
||||
padding: 1.25rem 1rem;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder .browser-thumb-wrapper {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder .browser-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
font-size: 1.75rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
border: 1px solid rgba(29, 185, 84, 0.15);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder:hover .browser-icon {
|
||||
background: rgba(29, 185, 84, 0.18);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder .browser-item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Unavailable root folder overlay */
|
||||
.browser-item.browser-root-folder.unavailable .browser-icon {
|
||||
background: rgba(231, 76, 60, 0.08);
|
||||
border-color: rgba(231, 76, 60, 0.12);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Thumbnail Display */
|
||||
.browser-thumbnail {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.browser-thumbnail.loading {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-primary) 25%,
|
||||
110deg,
|
||||
var(--bg-primary) 30%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-primary) 75%
|
||||
var(--bg-primary) 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.browser-thumbnail.loading::after {
|
||||
content: '⏳';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2rem;
|
||||
opacity: 0.6;
|
||||
animation: pulse 1.5s infinite;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.browser-thumbnail.loaded {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -3175,13 +3309,13 @@ footer .separator {
|
||||
|
||||
/* File/Folder Icons */
|
||||
.browser-icon {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
border-radius: 6px;
|
||||
font-size: 2.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -3189,10 +3323,11 @@ footer .separator {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.browser-item-name {
|
||||
font-size: 0.813rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
@@ -3201,12 +3336,14 @@ footer .separator {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.browser-item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.688rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.2rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.browser-item-type {
|
||||
@@ -3222,6 +3359,11 @@ footer .separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.browser-item:hover .browser-item-type {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -3236,9 +3378,11 @@ footer .separator {
|
||||
/* Thumbnail Wrapper & Play Overlay */
|
||||
.browser-thumb-wrapper {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-thumb-wrapper .browser-thumbnail,
|
||||
@@ -3253,24 +3397,29 @@ footer .separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-play-overlay svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.browser-item:hover .browser-play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.browser-item:hover .browser-play-overlay svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Compact grid overrides */
|
||||
.browser-grid-compact .browser-thumb-wrapper {
|
||||
width: 100%;
|
||||
@@ -3287,7 +3436,7 @@ footer .separator {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
@@ -3297,6 +3446,11 @@ footer .separator {
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.browser-list-item:hover .browser-list-download {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.browser-list-download:hover {
|
||||
@@ -3308,7 +3462,7 @@ footer .separator {
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
@@ -3316,13 +3470,13 @@ footer .separator {
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
padding: 0.4rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
width: auto;
|
||||
@@ -3345,19 +3499,25 @@ footer .separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.813rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination-showing {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 3.5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
width: 3rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
text-align: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.813rem;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@@ -3374,8 +3534,17 @@ footer .separator {
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 600px) {
|
||||
.browser-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.browser-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.browser-grid.browser-root-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -3383,17 +3552,13 @@ footer .separator {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.browser-thumb-wrapper {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.browser-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.browser-item {
|
||||
padding: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder {
|
||||
padding: 1rem 0.75rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.browser-header-section {
|
||||
@@ -3429,12 +3594,27 @@ footer .separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-header {
|
||||
grid-template-columns: 32px 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.browser-list-header span:nth-child(n+3):nth-child(-n+4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-item {
|
||||
grid-template-columns: 32px 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.browser-list-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.browser-list-duration {
|
||||
display: none;
|
||||
}
|
||||
@@ -3447,6 +3627,17 @@ footer .separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-showing {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.album-art-glow {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
@@ -3485,6 +3676,14 @@ footer .separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-header {
|
||||
grid-template-columns: 40px 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.browser-list-header span:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-item {
|
||||
grid-template-columns: 40px 1fr auto auto auto;
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<span id="pageTotal">/ 1</span>
|
||||
</div>
|
||||
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<span class="pagination-showing" id="paginationShowing"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -323,6 +324,39 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
|
||||
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="browser.folders_description">
|
||||
Media folders available for browsing. Folders on network shares show availability status.
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="browser.folders_table.id">ID</th>
|
||||
<th data-i18n="browser.folders_table.label">Label</th>
|
||||
<th data-i18n="browser.folders_table.path">Path</th>
|
||||
<th data-i18n="browser.folders_table.status">Status</th>
|
||||
<th data-i18n="browser.folders_table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="foldersTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddFolderDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.scripts">Scripts</summary>
|
||||
<div class="settings-section-content">
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
|
||||
} from './browser.js';
|
||||
|
||||
import {
|
||||
@@ -117,6 +118,7 @@ Object.assign(window, {
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
|
||||
// Links
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
|
||||
saveLink, deleteLinkConfirm,
|
||||
@@ -131,6 +133,23 @@ Object.assign(window, {
|
||||
// Initialization (DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
// Prevent <dialog>.showModal() from auto-focusing the first input field.
|
||||
// On touch devices this pops up the on-screen keyboard, which is confusing
|
||||
// when the user just opened a dialog. Force focus onto the dialog itself.
|
||||
const _origShowModal = HTMLDialogElement.prototype.showModal;
|
||||
HTMLDialogElement.prototype.showModal = function (...args) {
|
||||
if (!this.hasAttribute('tabindex')) {
|
||||
this.setAttribute('tabindex', '-1');
|
||||
}
|
||||
const result = _origShowModal.apply(this, args);
|
||||
const active = document.activeElement;
|
||||
if (active && active !== this && this.contains(active)) {
|
||||
active.blur();
|
||||
this.focus({ preventScroll: true });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
@@ -323,6 +342,24 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
else if (action === 'delete') deleteCallbackConfirm(name);
|
||||
});
|
||||
|
||||
// Folder dialog backdrop click to close
|
||||
const folderDialog = document.getElementById('folderDialog');
|
||||
folderDialog.addEventListener('click', (e) => {
|
||||
if (e.target === folderDialog) {
|
||||
closeFolderDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated click handlers for folder table actions
|
||||
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const folderId = btn.dataset.folderId;
|
||||
if (action === 'edit') showEditFolderDialog(folderId);
|
||||
else if (action === 'delete') deleteFolderConfirm(folderId);
|
||||
});
|
||||
|
||||
// Link dialog backdrop click to close
|
||||
const linkDialog = document.getElementById('linkDialog');
|
||||
linkDialog.addEventListener('click', (e) => {
|
||||
@@ -352,7 +389,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
initBrowserToolbar();
|
||||
if (token) {
|
||||
if (!authReq || token) {
|
||||
loadMediaFolders();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog,
|
||||
t, showToast, showConfirm, escapeHtml, closeDialog,
|
||||
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
|
||||
getAuthHeaders, hasCredentials,
|
||||
} from './core.js';
|
||||
@@ -15,6 +15,7 @@ let currentOffset = 0;
|
||||
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
|
||||
let totalItems = 0;
|
||||
let mediaFolders = {};
|
||||
let managementEnabled = false;
|
||||
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load folders');
|
||||
|
||||
mediaFolders = await response.json();
|
||||
const data = await response.json();
|
||||
mediaFolders = data.folders || {};
|
||||
managementEnabled = data.management_enabled || false;
|
||||
|
||||
// Show/hide the media folders settings section
|
||||
const section = document.getElementById('mediaFoldersSection');
|
||||
if (section) {
|
||||
section.style.display = managementEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
// Render folders table in settings if management is enabled
|
||||
if (managementEnabled) {
|
||||
loadFoldersTable();
|
||||
}
|
||||
|
||||
// Load last browsed path or show root folder list
|
||||
loadLastBrowserPath();
|
||||
@@ -69,41 +83,48 @@ function showRootFolders() {
|
||||
revokeBlobUrls(container);
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
} else if (viewMode === 'compact') {
|
||||
container.className = 'browser-grid browser-grid-compact';
|
||||
} else {
|
||||
container.className = 'browser-grid';
|
||||
container.className = 'browser-grid browser-root-grid';
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><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>';
|
||||
|
||||
Object.entries(mediaFolders).forEach(([id, folder]) => {
|
||||
if (!folder.enabled) return;
|
||||
const unavailable = folder.available === false;
|
||||
const unavailableClass = unavailable ? ' unavailable' : '';
|
||||
|
||||
if (viewMode === 'list') {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
row.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
row.className = 'browser-list-item' + unavailableClass;
|
||||
if (!unavailable) {
|
||||
row.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
}
|
||||
row.innerHTML = `
|
||||
<div class="browser-list-icon">\u{1F4C1}</div>
|
||||
<div class="browser-list-name">${folder.label}</div>
|
||||
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
|
||||
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
} else {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'browser-item';
|
||||
card.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
card.className = 'browser-item browser-root-folder' + unavailableClass;
|
||||
if (!unavailable) {
|
||||
card.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
}
|
||||
card.innerHTML = `
|
||||
<div class="browser-thumb-wrapper">
|
||||
<div class="browser-icon">\u{1F4C1}</div>
|
||||
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
|
||||
</div>
|
||||
<div class="browser-item-info">
|
||||
<div class="browser-item-name">${folder.label}</div>
|
||||
<div class="browser-item-name">${escapeHtml(folder.label)}</div>
|
||||
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Column header row
|
||||
const header = document.createElement('div');
|
||||
header.className = 'browser-list-header';
|
||||
header.innerHTML = `
|
||||
<span></span>
|
||||
<span>${t('browser.list_header.name')}</span>
|
||||
<span>${t('browser.list_header.bitrate')}</span>
|
||||
<span>${t('browser.list_header.duration')}</span>
|
||||
<span>${t('browser.list_header.size')}</span>
|
||||
<span></span>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
@@ -662,6 +696,7 @@ function renderPagination() {
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const pageTotal = document.getElementById('pageTotal');
|
||||
const showingEl = document.getElementById('paginationShowing');
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
|
||||
@@ -676,6 +711,13 @@ function renderPagination() {
|
||||
pageInput.max = totalPages;
|
||||
pageTotal.textContent = `/ ${totalPages}`;
|
||||
|
||||
// "Showing X-Y of Z"
|
||||
if (showingEl) {
|
||||
const from = currentOffset + 1;
|
||||
const to = Math.min(currentOffset + itemsPerPage, totalItems);
|
||||
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
|
||||
}
|
||||
|
||||
prevBtn.disabled = currentPage === 1;
|
||||
nextBtn.disabled = currentPage === totalPages;
|
||||
}
|
||||
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Management
|
||||
export function showManageFoldersDialog() {
|
||||
// TODO: Implement folder management UI
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
// Folder Management — Settings table
|
||||
|
||||
export function loadFoldersTable() {
|
||||
const tbody = document.getElementById('foldersTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const entries = Object.entries(mediaFolders);
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
|
||||
</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = entries.map(([id, folder]) => {
|
||||
const available = folder.available !== false;
|
||||
const statusIcon = available
|
||||
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
|
||||
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
|
||||
const enabledBadge = folder.enabled
|
||||
? ''
|
||||
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
|
||||
return `<tr>
|
||||
<td>${escapeHtml(id)}${enabledBadge}</td>
|
||||
<td>${escapeHtml(folder.label)}</td>
|
||||
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
|
||||
<td>${statusIcon}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function showAddFolderDialog() {
|
||||
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
|
||||
document.getElementById('folderIsEdit').value = '';
|
||||
document.getElementById('folderOriginalId').value = '';
|
||||
document.getElementById('folderId').value = '';
|
||||
document.getElementById('folderId').disabled = false;
|
||||
document.getElementById('folderLabel').value = '';
|
||||
document.getElementById('folderPath').value = '';
|
||||
document.getElementById('folderEnabled').checked = true;
|
||||
document.getElementById('folderDialog').showModal();
|
||||
}
|
||||
|
||||
export function showEditFolderDialog(folderId) {
|
||||
const folder = mediaFolders[folderId];
|
||||
if (!folder) return;
|
||||
|
||||
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
|
||||
document.getElementById('folderIsEdit').value = '1';
|
||||
document.getElementById('folderOriginalId').value = folderId;
|
||||
document.getElementById('folderId').value = folderId;
|
||||
document.getElementById('folderId').disabled = true;
|
||||
document.getElementById('folderLabel').value = folder.label;
|
||||
document.getElementById('folderPath').value = folder.path;
|
||||
document.getElementById('folderEnabled').checked = folder.enabled;
|
||||
document.getElementById('folderDialog').showModal();
|
||||
}
|
||||
|
||||
export function closeFolderDialog() {
|
||||
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
|
||||
|
||||
export async function saveFolder(event) {
|
||||
event.preventDefault();
|
||||
closeFolderDialog();
|
||||
|
||||
const isEdit = document.getElementById('folderIsEdit').value === '1';
|
||||
const folderId = isEdit
|
||||
? document.getElementById('folderOriginalId').value
|
||||
: document.getElementById('folderId').value.trim();
|
||||
const label = document.getElementById('folderLabel').value.trim();
|
||||
const path = document.getElementById('folderPath').value.trim();
|
||||
const enabled = document.getElementById('folderEnabled').checked;
|
||||
|
||||
if (!folderId || !label || !path) return;
|
||||
|
||||
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (isEdit) {
|
||||
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ label, path, enabled }),
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/browser/folders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeFolderDialog();
|
||||
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
|
||||
await loadMediaFolders();
|
||||
} else {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
showToast(result.detail || t('browser.folder_save_error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving folder:', error);
|
||||
showToast(t('browser.folder_save_error'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFolderConfirm(folderId) {
|
||||
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast(t('browser.folder_deleted'), 'success');
|
||||
await loadMediaFolders();
|
||||
} else {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
showToast(result.detail || t('browser.folder_delete_error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting folder:', error);
|
||||
showToast(t('browser.folder_delete_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy stub — now handled via settings table
|
||||
export function showManageFoldersDialog() {
|
||||
if (managementEnabled) {
|
||||
// Switch to settings tab and scroll to the folders section
|
||||
const switchTabFn = window.switchTab;
|
||||
if (switchTabFn) switchTabFn('settings');
|
||||
setTimeout(() => {
|
||||
const section = document.getElementById('mediaFoldersSection');
|
||||
if (section) {
|
||||
section.setAttribute('open', '');
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,27 @@
|
||||
"browser.play_all_error": "Failed to play folder",
|
||||
"browser.error_loading": "Error loading directory",
|
||||
"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 is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||
"browser.unavailable": "Unavailable",
|
||||
"browser.folder_available": "Available",
|
||||
"browser.folder_unavailable": "Unavailable (path not reachable)",
|
||||
"browser.folder_disabled": "disabled",
|
||||
"browser.folder_edit": "Edit folder",
|
||||
"browser.folder_delete": "Delete folder",
|
||||
"browser.folder_created": "Media folder created successfully",
|
||||
"browser.folder_updated": "Media folder updated successfully",
|
||||
"browser.folder_deleted": "Media folder deleted successfully",
|
||||
"browser.folder_save_error": "Failed to save media folder",
|
||||
"browser.folder_delete_error": "Failed to delete media folder",
|
||||
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
|
||||
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
|
||||
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
|
||||
"browser.folders_table.id": "ID",
|
||||
"browser.folders_table.label": "Label",
|
||||
"browser.folders_table.path": "Path",
|
||||
"browser.folders_table.status": "Status",
|
||||
"browser.folders_table.actions": "Actions",
|
||||
"settings.section.media_folders": "Media Folders",
|
||||
"browser.folder_dialog.title_add": "Add Media Folder",
|
||||
"browser.folder_dialog.title_edit": "Edit Media Folder",
|
||||
"browser.folder_dialog.folder_id": "Folder ID *",
|
||||
@@ -185,6 +205,11 @@
|
||||
"browser.folder_dialog.enabled": "Enabled",
|
||||
"browser.folder_dialog.cancel": "Cancel",
|
||||
"browser.folder_dialog.save": "Save",
|
||||
"browser.list_header.name": "Name",
|
||||
"browser.list_header.bitrate": "Bitrate",
|
||||
"browser.list_header.duration": "Duration",
|
||||
"browser.list_header.size": "Size",
|
||||
"browser.showing_items": "Showing {from}\u2013{to} of {total}",
|
||||
"browser.download_error": "Failed to download file",
|
||||
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
|
||||
"connection.lost": "Connection lost. Server may be unavailable.",
|
||||
|
||||
@@ -173,7 +173,27 @@
|
||||
"browser.play_all_error": "Не удалось воспроизвести папку",
|
||||
"browser.error_loading": "Ошибка загрузки каталога",
|
||||
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
|
||||
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
|
||||
"browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
|
||||
"browser.unavailable": "Недоступна",
|
||||
"browser.folder_available": "Доступна",
|
||||
"browser.folder_unavailable": "Недоступна (путь не найден)",
|
||||
"browser.folder_disabled": "отключена",
|
||||
"browser.folder_edit": "Редактировать папку",
|
||||
"browser.folder_delete": "Удалить папку",
|
||||
"browser.folder_created": "Медиа папка успешно создана",
|
||||
"browser.folder_updated": "Медиа папка успешно обновлена",
|
||||
"browser.folder_deleted": "Медиа папка успешно удалена",
|
||||
"browser.folder_save_error": "Не удалось сохранить медиа папку",
|
||||
"browser.folder_delete_error": "Не удалось удалить медиа папку",
|
||||
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
|
||||
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
|
||||
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
|
||||
"browser.folders_table.id": "ID",
|
||||
"browser.folders_table.label": "Метка",
|
||||
"browser.folders_table.path": "Путь",
|
||||
"browser.folders_table.status": "Статус",
|
||||
"browser.folders_table.actions": "Действия",
|
||||
"settings.section.media_folders": "Медиа папки",
|
||||
"browser.folder_dialog.title_add": "Добавить медиа папку",
|
||||
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
|
||||
"browser.folder_dialog.folder_id": "ID папки *",
|
||||
@@ -185,6 +205,11 @@
|
||||
"browser.folder_dialog.enabled": "Включено",
|
||||
"browser.folder_dialog.cancel": "Отмена",
|
||||
"browser.folder_dialog.save": "Сохранить",
|
||||
"browser.list_header.name": "Название",
|
||||
"browser.list_header.bitrate": "Битрейт",
|
||||
"browser.list_header.duration": "Длительность",
|
||||
"browser.list_header.size": "Размер",
|
||||
"browser.showing_items": "Показано {from}\u2013{to} из {total}",
|
||||
"browser.download_error": "Не удалось скачать файл",
|
||||
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
|
||||
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.5",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "0.1.1"
|
||||
version = "0.1.5"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
Reference in New Issue
Block a user