Fix mpv pause state when loading new media

Fixes #107

This fixes an issue where loading a new media URL while the player
was paused would keep playback paused, causing Music Assistant to
show the player as playing while no audio was audible.

The mpv pause state is now always set explicitly when starting playback,
ensuring deterministic behavior
This commit is contained in:
Philipp Grafe committed 2026-02-03 21:34:57 +01:00
1 parent 7a92d69864
commit d42abaa2c9
6 files changed
+205 -68

No files matched your search

+24 -68
View File
@@ -1,32 +1,18 @@
"""Media player using mpv in a subprocess."""
import logging
from collections.abc import Callable
from threading import Lock
from typing import List, Optional, Union
from typing import Union, List, Callable, Optional
from mpv import MPV
_LOGGER = logging.getLogger(__name__)
from .player.libmpv import LibMpvPlayer
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None) -> None:
self.player = MPV()
"""
LVA MediaPlayer implementation using libmpv.
"""
if device:
self.player["audio-device"] = device
self.is_playing = False
self._playlist: List[str] = []
def __init__(self, device: str | None = None):
self._log = logging.getLogger(self.__class__.__name__)
self._player = LibMpvPlayer(device=device)
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._duck_volume: int = 50
self._unduck_volume: int = 100
self.player.event_callback("end-file")(self._on_end_file)
def play(
self,
@@ -34,61 +20,31 @@ class MpvMediaPlayer:
done_callback: Optional[Callable[[], None]] = None,
stop_first: bool = True,
) -> None:
self.stop()
self._log.info("Playing %s", url)
if isinstance(url, str):
self._playlist = [url]
else:
self._playlist = url
next_url = self._playlist.pop(0)
_LOGGER.debug("Playing %s", next_url)
if isinstance(url, list):
url = url[0]
self._done_callback = done_callback
self.is_playing = True
self.player.play(next_url)
self._player.play(url, paused=False)
def pause(self) -> None:
self.player.pause = True
self.is_playing = False
self._player.pause()
def resume(self) -> None:
self.player.pause = False
if self._playlist:
self.is_playing = True
self._player.resume()
def stop(self) -> None:
self.player.stop()
self._playlist.clear()
def duck(self) -> None:
self.player.volume = self._duck_volume
def unduck(self) -> None:
self.player.volume = self._unduck_volume
def set_volume(self, volume: int) -> None:
volume = max(0, min(100, volume))
self.player.volume = volume
self._unduck_volume = volume
self._duck_volume = volume // 2
def _on_end_file(self, event) -> None:
if self._playlist:
self.player.play(self._playlist.pop(0))
return
self.is_playing = False
todo_callback: Optional[Callable[[], None]] = None
with self._done_callback_lock:
self._player.stop()
if self._done_callback:
todo_callback = self._done_callback
self._done_callback()
self._done_callback = None
if todo_callback:
try:
todo_callback()
except Exception:
_LOGGER.exception("Unexpected error running done callback")
def set_volume(self, volume: float) -> None:
self._player.set_volume(volume)
def duck(self, factor: float = 0.5) -> None:
self._player.duck(factor)
def unduck(self) -> None:
self._player.unduck()
+25
View File
@@ -0,0 +1,25 @@
from abc import ABC, abstractmethod
from .state import PlayerState
class AudioPlayer(ABC):
@abstractmethod
def play(self, url: str) -> None:
pass
@abstractmethod
def pause(self) -> None:
pass
@abstractmethod
def resume(self) -> None:
pass
@abstractmethod
def stop(self) -> None:
pass
@abstractmethod
def state(self) -> PlayerState:
pass
+86
View File
@@ -0,0 +1,86 @@
import threading
import logging
from .base import AudioPlayer
from .state import PlayerState
import mpv
class LibMpvPlayer(AudioPlayer):
def __init__(self, device: str | None = None):
self._log = logging.getLogger(self.__class__.__name__)
self._state = PlayerState.IDLE
self._state_lock = threading.Lock()
self._last_volume: float = 100.0 # mpv Volume 0-100
self._mpv = mpv.MPV(
audio_display=False,
log_handler=self._on_mpv_log,
loglevel="error",
)
if device:
self._log.info("Using audio device: %s", device)
self._mpv["audio-device"] = device
# -------- Core Methods --------
def play(self, url: str, paused: bool = False) -> None:
with self._state_lock:
self._set_state(PlayerState.LOADING)
self._mpv.pause = paused
self._log.info("Loading media: %s (paused=%s)", url, paused)
self._mpv.play(url)
def pause(self):
with self._state_lock:
self._mpv.pause = True
self._set_state(PlayerState.PAUSED)
def resume(self):
with self._state_lock:
self._mpv.pause = False
self._set_state(PlayerState.PLAYING)
def stop(self):
with self._state_lock:
self._mpv.stop()
self._set_state(PlayerState.IDLE)
def state(self):
with self._state_lock:
return self._state
# -------- Volume / Ducking --------
def set_volume(self, volume: float):
"""
volume: float 0.0-1.0
"""
with self._state_lock:
self._last_volume = volume * 100
self._mpv.volume = self._last_volume
def duck(self, factor: float = 0.5):
with self._state_lock:
self._last_volume = getattr(self, "_last_volume", self._mpv.volume)
self._mpv.volume = self._last_volume * factor
def unduck(self):
with self._state_lock:
self._mpv.volume = getattr(self, "_last_volume", self._mpv.volume)
# -------- Internal Helpers --------
def _on_mpv_log(self, level, prefix, text):
if level in ("error", "fatal"):
self._log.error("[mpv] %s", text.strip())
with self._state_lock:
self._set_state(PlayerState.ERROR)
def _set_state(self, new_state: PlayerState):
if self._state != new_state:
self._log.debug("State %s → %s", self._state.name, new_state.name)
self._state = new_state
+10
View File
@@ -0,0 +1,10 @@
from enum import Enum, auto
class PlayerState(Enum):
IDLE = auto()
LOADING = auto()
PLAYING = auto()
PAUSED = auto()
STOPPING = auto()
ERROR = auto()
+37
View File
@@ -0,0 +1,37 @@
import time
import mpv
"""
mpv_smoke_test.py - test playback of media
"""
def handle_event(event):
print(f"EVENT: {event['event_id']}")
player = mpv.MPV(
audio_display=False,
log_handler=print,
loglevel="info",
)
player.event_callback = handle_event
print("Loading media...")
player.play("https://icecast.radiofrance.fr/fip-midfi.mp3")
time.sleep(5)
print("Pausing...")
player.pause = True
time.sleep(2)
print("Resuming...")
player.pause = False
time.sleep(5)
print("Stopping...")
player.stop()
time.sleep(2)
print("Done.")
+23
View File
@@ -0,0 +1,23 @@
import time
import logging
from linux_voice_assistant.player.libmpv import LibMpvPlayer
logging.basicConfig(level=logging.DEBUG)
player = LibMpvPlayer()
player.play("https://icecast.radiofrance.fr/fip-midfi.mp3")
time.sleep(5)
player.pause()
time.sleep(2)
player.resume()
time.sleep(5)
player.stop()
time.sleep(2)
print("Final state:", player.state())