Files
imonlinux 4da78c7435 Update mpv_player.py
What this fixes:

On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0).

When you move the HA media_player volume slider, LVA now updates:

wpctl (PipeWire) if available

pactl (PulseAudio / PipeWire-Pulse) if available

amixer (ALSA) as a fallback

No reboot / alsactl store required — LVA simply re-applies the persisted volume at start.
2026-01-05 21:52:41 -08:00

278 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Media player using mpv in a subprocess.
This wrapper focuses on:
- Simple playback control (play / pause / resume / stop)
- Volume control and ducking
- Notifying a done_callback when playback finishes
- Letting mpv choose the best audio backend by default
If a specific audio device is provided, it is passed directly to mpv as
`audio-device`. Otherwise, mpv's own automatic backend/device selection is used.
Note about volume:
- mpv has its own per-player volume (0..100).
- PipeWire/PulseAudio/ALSA also has a system output volume.
LVA persists a single user volume (0.0..1.0) in preferences.json. To avoid
"mystery caps" where the OS sink is stuck at e.g. 40% while LVA shows 100%,
LVA treats the OS output volume as the "master" and keeps mpv at 100% for normal
playback. Ducking still uses mpv's per-player volume.
"""
from __future__ import annotations
import asyncio
import logging
import os
from threading import Lock
from typing import Callable, List, Optional, Sequence, Union
# Note: python-mpv must be installed; imported at runtime.
from mpv import MPV
from .audio_volume import set_output_volume
_LOGGER = logging.getLogger(__name__)
class MpvMediaPlayer:
"""A media player class that wraps the python-mpv library."""
def __init__(
self,
loop: Optional[asyncio.AbstractEventLoop],
device: Optional[str] = None,
initial_volume: float = 1.0,
) -> None:
"""Initialize the mpv player.
:param loop: The asyncio loop used to schedule done_callback.
May be None in one-off utility contexts (e.g. listing devices).
:param device: Optional mpv audio device name (e.g.
"pipewire/alsa_output.pci-0000_00_1f.3.analog-stereo").
:param initial_volume: Initial volume as a float 0.0–1.0. This is now
treated as the *master* OS output volume. mpv's
internal volume is set to 100% for normal playback.
"""
self.loop = loop
self.device = device
self.initial_volume = max(0.0, min(1.0, float(initial_volume)))
self.player = MPV(
video=False,
terminal=False,
log_handler=self._mpv_log,
audio_samplerate=44100,
audio_channels="stereo",
keep_open="no",
network_timeout=7,
msg_level=os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn"),
)
# Optional: allow forcing ao via environment for power users/debugging.
ao_env = os.environ.get("LVA_AO")
if ao_env:
try:
self.player["ao"] = ao_env
_LOGGER.info("Forcing mpv ao=%r from LVA_AO", ao_env)
except Exception:
_LOGGER.exception("Failed to set mpv ao=%r", ao_env)
# If the caller provided a specific device, honor it directly.
if device:
try:
self.player["audio-device"] = device
_LOGGER.info("Using mpv audio-device=%r", device)
except Exception:
_LOGGER.exception("Failed to set mpv audio-device %r", device)
# Log final backend/device selection and available devices (best-effort)
try:
ao_effective = self.player["ao"]
# python-mpv may expose an unset option as [] – normal for "auto"
if isinstance(ao_effective, list) and not ao_effective:
ao_display = "<unset/auto>"
else:
ao_display = ao_effective
audio_device_effective = self.player["audio-device"]
try:
dev_list = self.player.audio_device_list or []
dev_summary = [
f"{dev.get('name')} ({dev.get('description')})" for dev in dev_list
]
except Exception:
dev_summary = ["<unavailable>"]
_LOGGER.debug(
"mpv audio config: ao=%r, audio-device=%r, devices=%s",
ao_display,
audio_device_effective,
dev_summary,
)
except Exception:
_LOGGER.exception("Failed to query mpv audio properties")
# Keep mpv at 100% for normal playback. Master volume is handled at the OS level.
self.set_volume(100)
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None
# When mpv becomes idle, we treat it as end-of-playback.
self.player.observe_property("idle-active", self._on_idle_active)
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
def play(
self,
url: Union[str, Sequence[str], bytes],
done_callback: Optional[Callable[[], None]] = None,
) -> None:
"""Plays a URL or sequence of URLs.
:param url: A single URL (str/bytes) or a sequence of URLs.
:param done_callback: Called once when playback finishes or is stopped.
"""
# Ensure player is in a clean state
self.stop()
with self._done_callback_lock:
self._done_callback = done_callback
playlist: List[str] = []
if isinstance(url, (list, tuple)):
playlist = list(url)
elif isinstance(url, bytes):
playlist = [url.decode(errors="ignore")]
elif isinstance(url, str):
playlist = [url]
else:
_LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url))
self._run_done_callback()
return
if not playlist:
self._run_done_callback()
return
# Load the full playlist into mpv
self.player.playlist_clear()
for item in playlist:
self.player.playlist_append(item)
self.is_playing = True
self.player.playlist_pos = 0 # Start playing from the first item
def pause(self) -> None:
"""Pauses playback."""
try:
self.player.pause = True
except Exception:
_LOGGER.exception("pause() failed")
def resume(self) -> None:
"""Resumes playback."""
try:
self.player.pause = False
except Exception:
_LOGGER.exception("resume() failed")
def stop(self) -> None:
"""Stops playback and clears the playlist."""
if self.is_playing:
self.is_playing = False
try:
self.player.playlist_clear()
self.player.command("stop")
except Exception:
_LOGGER.exception("stop() failed")
finally:
self._run_done_callback()
def set_volume(self, volume: int) -> None:
"""Sets the player (mpv) volume from 0 to 100."""
try:
self.player.volume = max(0, min(100, volume))
except Exception:
_LOGGER.exception("set_volume() failed")
def set_master_volume(self, volume_level: float) -> bool:
"""Sets the *OS output* volume (PipeWire/PulseAudio/ALSA).
:param volume_level: 0.0–1.0
:returns: True if any backend succeeded.
"""
return set_output_volume(volume_level=volume_level, output_device=self.device)
def duck(self, target_percent: int = 20) -> None:
"""Lowers the mpv volume for an announcement."""
if self._pre_duck_volume is not None:
return
try:
self._pre_duck_volume = int(self.player.volume)
self.set_volume(target_percent)
except Exception:
_LOGGER.exception("duck() failed")
def unduck(self) -> None:
"""Restores the mpv volume after an announcement."""
if self._pre_duck_volume is None:
return
try:
self.set_volume(self._pre_duck_volume)
except Exception:
_LOGGER.exception("unduck() failed")
finally:
self._pre_duck_volume = None
# -------------------------------------------------------------------------
# Internal callbacks
# -------------------------------------------------------------------------
def _on_idle_active(self, _name: str, active: bool) -> None:
"""Callback triggered when mpv enters or leaves the idle state."""
if active and self.is_playing:
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
"""Safely runs the done_callback on the main asyncio loop (if any)."""
with self._done_callback_lock:
cb = self._done_callback
self._done_callback = None
if not cb:
return
# If we have an asyncio loop, schedule callback there.
if self.loop is not None:
try:
self.loop.call_soon_threadsafe(cb)
except Exception:
_LOGGER.exception("Error scheduling done_callback on loop")
else:
# Fallback: call directly (used in contexts where no loop is passed).
try:
cb()
except Exception:
_LOGGER.exception("Error running done_callback directly")
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
"""Routes mpv's internal logs to our logger."""
msg = f"mpv[{prefix}]: {text}".rstrip()
if level == "error":
_LOGGER.error(msg)
elif level == "warn":
_LOGGER.warning(msg)
elif level == "info":
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)