Codebase audit fixes: stability, performance, accessibility

- Fix CORS: set allow_credentials=False (token auth, not cookies)
- Add threading.Lock for position cache thread safety
- Add shutdown_executor() for clean ThreadPoolExecutor cleanup
- Dedicated ThreadPoolExecutors for script/callback execution
- Fix Mutagen file handle leaks with try/finally close
- Reduce idle WebSocket polling (0.5s → 2.0s when no clients)
- Add :focus-visible styles for playback control buttons
- Add aria-label to icon-only header buttons
- Dynamic album art alt text for screen readers
- Persist MDI icon cache to localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:10:24 +03:00
parent 73a6f387e1
commit 9404b37f05
9 changed files with 260 additions and 195 deletions

View File

@@ -87,6 +87,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor # Stop WebSocket status monitor
await ws_manager.stop_status_monitor() await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
from .services.windows_media import shutdown_executor
shutdown_executor()
logger.info("Media Server shutting down") logger.info("Media Server shutting down")
@@ -103,10 +110,11 @@ def create_app() -> FastAPI:
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # Add CORS middleware for cross-origin requests
# Token auth is via Authorization header, not cookies, so credentials are not needed
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

@@ -5,6 +5,7 @@ import logging
import re import re
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -17,6 +18,9 @@ from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
class CallbackInfo(BaseModel): class CallbackInfo(BaseModel):
"""Information about a configured callback.""" """Information about a configured callback."""
@@ -127,10 +131,10 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}") logger.info(f"Executing callback for debugging: {callback_name}")
try: try:
# Execute in thread pool to not block # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _callback_executor,
lambda: _run_callback( lambda: _run_callback(
command=callback_config.command, command=callback_config.command,
timeout=callback_config.timeout, timeout=callback_config.timeout,

View File

@@ -5,6 +5,7 @@ import logging
import re import re
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -16,6 +17,9 @@ from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,10 +105,10 @@ async def execute_script(
# Append arguments to command # Append arguments to command
command = f"{command} {' '.join(args)}" command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _script_executor,
lambda: _run_script( lambda: _run_script(
command=command, command=command,
timeout=script_config.timeout, timeout=script_config.timeout,

View File

@@ -28,61 +28,65 @@ class MetadataService:
if audio is None: if audio is None:
return {"error": "Unable to read audio file"} return {"error": "Unable to read audio file"}
metadata = { try:
"type": "audio", metadata = {
"filename": file_path.name, "type": "audio",
"path": str(file_path), "filename": file_path.name,
} "path": str(file_path),
}
# Extract duration # Extract duration
if hasattr(audio.info, "length"): if hasattr(audio.info, "length"):
metadata["duration"] = round(audio.info.length, 2) metadata["duration"] = round(audio.info.length, 2)
# Extract bitrate # Extract bitrate
if hasattr(audio.info, "bitrate"): if hasattr(audio.info, "bitrate"):
metadata["bitrate"] = audio.info.bitrate metadata["bitrate"] = audio.info.bitrate
# Extract sample rate # Extract sample rate
if hasattr(audio.info, "sample_rate"): if hasattr(audio.info, "sample_rate"):
metadata["sample_rate"] = audio.info.sample_rate metadata["sample_rate"] = audio.info.sample_rate
elif hasattr(audio.info, "samplerate"): elif hasattr(audio.info, "samplerate"):
metadata["sample_rate"] = audio.info.samplerate metadata["sample_rate"] = audio.info.samplerate
# Extract channels # Extract channels
if hasattr(audio.info, "channels"): if hasattr(audio.info, "channels"):
metadata["channels"] = audio.info.channels metadata["channels"] = audio.info.channels
# Extract tags (use easy=True for consistent tag names) # Extract tags (use easy=True for consistent tag names)
if audio is not None and hasattr(audio, "tags") and audio.tags: if audio is not None and hasattr(audio, "tags") and audio.tags:
# Easy tags provide lists, so we take the first item # Easy tags provide lists, so we take the first item
tags = audio.tags tags = audio.tags
if "title" in tags: if "title" in tags:
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"] metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "artist" in tags: if "artist" in tags:
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"] metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if "album" in tags: if "album" in tags:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"] metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "albumartist" in tags: if "albumartist" in tags:
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"] metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
if "date" in tags: if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"] metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
if "genre" in tags: if "genre" in tags:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"] metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "tracknumber" in tags: if "tracknumber" in tags:
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"] metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
# If no title tag, use filename # If no title tag, use filename
if "title" not in metadata: if "title" not in metadata:
metadata["title"] = file_path.stem metadata["title"] = file_path.stem
return metadata return metadata
finally:
if hasattr(audio, 'close'):
audio.close()
except ImportError: except ImportError:
logger.error("mutagen library not installed, cannot extract metadata") logger.error("mutagen library not installed, cannot extract metadata")
@@ -117,40 +121,44 @@ class MetadataService:
"title": file_path.stem, "title": file_path.stem,
} }
metadata = { try:
"type": "video", metadata = {
"filename": file_path.name, "type": "video",
"path": str(file_path), "filename": file_path.name,
} "path": str(file_path),
}
# Extract duration # Extract duration
if hasattr(video.info, "length"): if hasattr(video.info, "length"):
metadata["duration"] = round(video.info.length, 2) metadata["duration"] = round(video.info.length, 2)
# Extract bitrate # Extract bitrate
if hasattr(video.info, "bitrate"): if hasattr(video.info, "bitrate"):
metadata["bitrate"] = video.info.bitrate metadata["bitrate"] = video.info.bitrate
# Extract video-specific properties if available # Extract video-specific properties if available
if hasattr(video.info, "width"): if hasattr(video.info, "width"):
metadata["width"] = video.info.width metadata["width"] = video.info.width
if hasattr(video.info, "height"): if hasattr(video.info, "height"):
metadata["height"] = video.info.height metadata["height"] = video.info.height
# Try to extract title from tags # Try to extract title from tags
if hasattr(video, "tags") and video.tags: if hasattr(video, "tags") and video.tags:
tags = video.tags tags = video.tags
if hasattr(tags, "get"): if hasattr(tags, "get"):
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam") title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
if title: if title:
metadata["title"] = title[0] if isinstance(title, list) else str(title) metadata["title"] = title[0] if isinstance(title, list) else str(title)
# If no title tag, use filename # If no title tag, use filename
if "title" not in metadata: if "title" not in metadata:
metadata["title"] = file_path.stem metadata["title"] = file_path.stem
return metadata return metadata
finally:
if hasattr(video, 'close'):
video.close()
except ImportError: except ImportError:
logger.error("mutagen library not installed, cannot extract metadata") logger.error("mutagen library not installed, cannot extract metadata")

View File

@@ -251,7 +251,7 @@ class ConnectionManager:
has_clients = len(self._active_connections) > 0 has_clients = len(self._active_connections) > 0
if not has_clients: if not has_clients:
await asyncio.sleep(self._poll_interval) await asyncio.sleep(2.0) # Sleep longer when no clients connected
continue continue
status = await get_status_func() status = await get_status_func()

View File

@@ -2,6 +2,8 @@
import asyncio import asyncio
import logging import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any from typing import Optional, Any
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes) # Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking # Global storage for position tracking
import time as _time
_position_cache = { _position_cache = {
"track_id": "", "track_id": "",
"base_position": 0.0, "base_position": 0.0,
@@ -224,124 +228,125 @@ def _sync_get_media_status() -> dict[str, Any]:
is_playing = result["state"] == "playing" is_playing = result["state"] == "playing"
current_title = result.get('title', '') current_title = result.get('title', '')
# Check if track skip is pending and title changed with _position_lock:
skip_just_completed = False # Check if track skip is pending and title changed
if _track_skip_pending["active"]: skip_just_completed = False
if current_title and current_title != _track_skip_pending["old_title"]: if _track_skip_pending["active"]:
# Title changed - clear the skip flag and start grace period if current_title and current_title != _track_skip_pending["old_title"]:
_track_skip_pending["active"] = False # Title changed - clear the skip flag and start grace period
_track_skip_pending["old_title"] = "" _track_skip_pending["active"] = False
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period _track_skip_pending["old_title"] = ""
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking _track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
skip_just_completed = True _track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
# Reset position cache for new track skip_just_completed = True
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}" # Reset position cache for new track
_position_cache["track_id"] = new_track_id new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0 _position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time _position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing _position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}") elif skip_just_completed:
elif current_time - _track_skip_pending["skip_time"] > 5.0: # Just completed skip - interpolate from 0
# Timeout after 5 seconds if is_playing:
_track_skip_pending["active"] = False elapsed = current_time - _position_cache["base_time"]
logger.debug("Track skip timeout") pos = elapsed
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Check if we're in grace period (after skip, ignore high SMTC positions) # Calculate interpolated position from start of new track
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0) if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# If track skip is pending or just completed, use cached/reset position # Get the stale position we've been tracking
if _track_skip_pending["active"]: stale_pos = _track_skip_pending.get("stale_pos", -999)
pos = 0.0
_position_cache["base_position"] = 0.0 # Detect if SMTC position changed significantly from the stale value (user seeked)
_position_cache["base_time"] = current_time smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
_position_cache["is_playing"] = is_playing
elif skip_just_completed: # Trust SMTC if:
# Just completed skip - interpolate from 0 # 1. It reports a low position (indicating new track started)
if is_playing: # 2. It changed from the stale value (user seeked)
elapsed = current_time - _position_cache["base_time"] if smtc_pos < 10.0 or smtc_changed:
pos = elapsed # SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else: else:
pos = 0.0 # Normal position tracking
elif in_grace_period: # Create track ID from title + artist + duration
# Grace period after track skip track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Calculate interpolated position from start of new track # Detect if SMTC position changed (new track, seek, or state change)
if is_playing: smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
elapsed = current_time - _position_cache.get("base_time", current_time) track_changed = track_id != _position_cache.get("track_id", "")
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# Get the stale position we've been tracking if smtc_pos_changed or track_changed:
stale_pos = _track_skip_pending.get("stale_pos", -999) # SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Detect if SMTC position changed significantly from the stale value (user seeked) # Update playing state
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0 if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Trust SMTC if: # Sanity check: position should be non-negative and <= duration
# 1. It reports a low position (indicating new track started) if pos >= 0:
# 2. It changed from the stale value (user seeked) if result["duration"] and pos <= result["duration"]:
if smtc_pos < 10.0 or smtc_changed: result["position"] = pos
# SMTC is now trustworthy elif result["duration"] and pos > result["duration"]:
_position_cache["base_position"] = smtc_pos result["position"] = result["duration"]
_position_cache["base_time"] = current_time elif not result["duration"]:
_position_cache["last_smtc_pos"] = smtc_pos result["position"] = pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else:
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}") logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
except Exception as e: except Exception as e:
@@ -483,6 +488,11 @@ def _sync_seek(position: float) -> bool:
return False return False
def shutdown_executor() -> None:
"""Shut down the WinRT thread pool executor."""
_executor.shutdown(wait=False)
class WindowsMediaController(MediaController): class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw.""" """Media controller for Windows using WinRT and pycaw."""
@@ -602,10 +612,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("next") result = await self._run_command("next")
if result: if result:
# Set flag to force position to 0 until title changes with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title _track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time() _track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}") logger.debug(f"Track skip initiated, old title: {old_title}")
return result return result
@@ -620,10 +630,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("previous") result = await self._run_command("previous")
if result: if result:
# Set flag to force position to 0 until title changes with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title _track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time() _track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}") logger.debug(f"Track skip initiated, old title: {old_title}")
return result return result

View File

@@ -824,6 +824,20 @@ button:disabled {
transform: scale(1.05); transform: scale(1.05);
} }
.controls button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.mute-btn:focus-visible,
.mini-control-btn:focus-visible,
.vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.controls button.primary { .controls button.primary {
width: 56px; width: 56px;
height: 56px; height: 56px;

View File

@@ -74,12 +74,12 @@
<div class="header-toolbar"> <div class="header-toolbar">
<div id="headerLinks" class="header-links"></div> <div id="headerLinks" class="header-links"></div>
<div class="accent-picker"> <div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color"> <button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span> <span class="accent-dot" id="accentDot"></span>
</button> </button>
<div class="accent-picker-dropdown" id="accentDropdown"></div> <div class="accent-picker-dropdown" id="accentDropdown"></div>
</div> </div>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle"> <button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;"> <svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg> </svg>
@@ -92,7 +92,7 @@
<option value="ru">RU</option> <option value="ru">RU</option>
</select> </select>
<span class="header-toolbar-sep"></span> <span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token"> <button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg> <svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button> </button>
</div> </div>

View File

@@ -1368,6 +1368,13 @@
currentState = status.state; currentState = status.state;
updatePlaybackState(status.state); updatePlaybackState(status.state);
// Update album art alt text for accessibility
const altText = status.title && status.artist
? `${status.artist} ${status.title}`
: status.title || t('player.no_media');
dom.albumArt.alt = altText;
dom.miniAlbumArt.alt = altText;
// Update album art (skip if same track to avoid redundant network requests) // Update album art (skip if same track to avoid redundant network requests)
const artworkSource = status.album_art_url || null; const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`; const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
@@ -3468,7 +3475,16 @@ async function toggleDisplayPower(monitorId, monitorName) {
// Header Quick Links // Header Quick Links
// ============================================================ // ============================================================
const mdiIconCache = {}; // In-memory + localStorage cache for MDI icons (persists across reloads)
const mdiIconCache = (() => {
try {
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
} catch { return {}; }
})();
function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
async function fetchMdiIcon(iconName) { async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name" // Parse "mdi:icon-name" → "icon-name"
@@ -3480,6 +3496,7 @@ async function fetchMdiIcon(iconName) {
if (response.ok) { if (response.ok) {
const svg = await response.text(); const svg = await response.text();
mdiIconCache[name] = svg; mdiIconCache[name] = svg;
_persistMdiCache();
return svg; return svg;
} }
} catch (e) { } catch (e) {