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:
@@ -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=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user