feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions
@@ -0,0 +1,126 @@
"""Cross-platform asynchronous sound playback for notification alerts.
Windows: uses winsound.PlaySound (stdlib, no dependencies).
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
All playback is fire-and-forget on a background thread. A new notification
sound cancels any currently playing sound to prevent overlap.
"""
import subprocess
import sys
import threading
from pathlib import Path
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Lock + handle for cancelling previous sound
_play_lock = threading.Lock()
_current_process: subprocess.Popen | None = None
def _play_windows(file_path: Path, volume: float) -> None:
"""Play a WAV file on Windows using winsound."""
import winsound
# winsound doesn't support volume control natively,
# but SND_ASYNC plays non-blocking within this thread
try:
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
except Exception as e:
logger.error(f"winsound playback failed: {e}")
def _play_linux(file_path: Path, volume: float) -> None:
"""Play a sound file on Linux using paplay or aplay."""
global _current_process
# Cancel previous sound
with _play_lock:
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate previous sound process: %s", e)
pass
_current_process = None
try:
# Try paplay first (PulseAudio/PipeWire) — supports volume
pa_volume = max(0, min(65536, int(volume * 65536)))
proc = subprocess.Popen(
["paplay", f"--volume={pa_volume}", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
try:
# Fallback to aplay (ALSA) — no volume control
proc = subprocess.Popen(
["aplay", "-q", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
return
with _play_lock:
_current_process = proc
# Wait for completion
proc.wait()
with _play_lock:
if _current_process is proc:
_current_process = None
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
"""Play a sound file asynchronously (fire-and-forget).
Args:
file_path: Path to the sound file (.wav).
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
"""
if not file_path.exists():
logger.warning(f"Sound file not found: {file_path}")
return
volume = max(0.0, min(1.0, volume))
if sys.platform == "win32":
player = _play_windows
else:
player = _play_linux
thread = threading.Thread(
target=player,
args=(file_path, volume),
name="sound-player",
daemon=True,
)
thread.start()
def stop_current_sound() -> None:
"""Stop any currently playing notification sound."""
if sys.platform == "win32":
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
logger.debug("Failed to stop winsound playback: %s", e)
pass
else:
with _play_lock:
global _current_process
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate sound process: %s", e)
pass
_current_process = None