diff --git a/README.md b/README.md index 1a20d36..9da0552 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,24 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu «Моя волна» продолжается с последних прослушанных треков (берётся история), а не начинается каждый раз с одного и того же места. +## Управление из системной панели (MPRIS) + +При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник +звука (`org.mpris.MediaPlayer2.mtsmusic`). Поэтому плеер виден системе и им можно +управлять из панели GNOME — например, расширением +[media-controls](https://github.com/sakithb/media-controls), — а также через +`playerctl` и другие MPRIS-клиенты. + +Что работает из панели: + +- play/pause, следующий/предыдущий трек; +- перемотка (слайдер и seek); +- отображение названия, исполнителя, альбома, длительности и цветной обложки; +- громкость. + +Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного +D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции. + ## Настройки Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной diff --git a/mpris.py b/mpris.py new file mode 100644 index 0000000..311d99e --- /dev/null +++ b/mpris.py @@ -0,0 +1,371 @@ +"""MPRIS (D-Bus) интеграция: система видит плеер как источник звука. + +Благодаря этому плеером можно управлять из панели GNOME (расширение +media-controls), из `playerctl` и т.п.: play/pause, следующий/предыдущий, +перемотка, метаданные и обложка. + +Модуль необязательный: если нет `dbus-next` или сессионного D-Bus, всё +превращается в no-op, и приложения работают как обычно. +""" + +import asyncio +import logging +import threading +from typing import Any, Callable + +log = logging.getLogger("mpris") + +try: # pragma: no cover - зависит от окружения + from dbus_next import Variant + from dbus_next.aio import MessageBus + from dbus_next.constants import PropertyAccess + from dbus_next.service import ServiceInterface, dbus_property, method, signal + + HAVE_DBUS = True +except Exception: # pragma: no cover + HAVE_DBUS = False + +BUS_NAME = "org.mpris.MediaPlayer2.mtsmusic" +OBJECT_PATH = "/org/mpris/MediaPlayer2" + + +def track_object_path(track_id: Any) -> str: + """D-Bus object path для mpris:trackid (только [A-Za-z0-9_]).""" + safe = "".join(ch if ch.isalnum() else "_" for ch in str(track_id)) or "unknown" + return f"/mtsmusic/track/{safe}" + + +if HAVE_DBUS: + + class _RootInterface(ServiceInterface): + def __init__(self, service: "MprisService"): + super().__init__("org.mpris.MediaPlayer2") + self._svc = service + + @method() + def Raise(self): + self._svc.dispatch("raise") + + @method() + def Quit(self): + self._svc.dispatch("quit") + + @dbus_property(access=PropertyAccess.READ) + def Identity(self) -> "s": + return self._svc.identity + + @dbus_property(access=PropertyAccess.READ) + def DesktopEntry(self) -> "s": + return self._svc.desktop_entry + + @dbus_property(access=PropertyAccess.READ) + def CanQuit(self) -> "b": + return True + + @dbus_property(access=PropertyAccess.READ) + def CanRaise(self) -> "b": + return True + + @dbus_property(access=PropertyAccess.READ) + def HasTrackList(self) -> "b": + return False + + @dbus_property(access=PropertyAccess.READ) + def SupportedUriSchemes(self) -> "as": + return [] + + @dbus_property(access=PropertyAccess.READ) + def SupportedMimeTypes(self) -> "as": + return [] + + class _PlayerInterface(ServiceInterface): + def __init__(self, service: "MprisService"): + super().__init__("org.mpris.MediaPlayer2.Player") + self._svc = service + + # -- методы ------------------------------------------------------ + @method() + def Play(self): + self._svc.dispatch("play") + + @method() + def Pause(self): + self._svc.dispatch("pause") + + @method() + def PlayPause(self): + self._svc.dispatch("play_pause") + + @method() + def Stop(self): + self._svc.dispatch("stop") + + @method() + def Next(self): + self._svc.dispatch("next") + + @method() + def Previous(self): + self._svc.dispatch("previous") + + @method() + def Seek(self, offset: "x"): + self._svc.dispatch("seek", int(offset)) + + @method() + def SetPosition(self, track_id: "o", position: "x"): + self._svc.dispatch("set_position", str(track_id), int(position)) + + @method() + def OpenUri(self, uri: "s"): + self._svc.dispatch("open_uri", str(uri)) + + # -- сигналы ----------------------------------------------------- + @signal() + def Seeked(self, position: "x") -> "x": + return position + + # -- свойства ---------------------------------------------------- + @dbus_property(access=PropertyAccess.READ) + def PlaybackStatus(self) -> "s": + return self._svc.playback_status + + @dbus_property(access=PropertyAccess.READ) + def Metadata(self) -> "a{sv}": + return self._svc.metadata + + @dbus_property(access=PropertyAccess.READ) + def Position(self) -> "x": + return int(self._svc.position) + + @dbus_property(access=PropertyAccess.READ) + def MinimumRate(self) -> "d": + return 1.0 + + @dbus_property(access=PropertyAccess.READ) + def MaximumRate(self) -> "d": + return 1.0 + + @dbus_property(access=PropertyAccess.READ) + def CanGoNext(self) -> "b": + return self._svc.can_next + + @dbus_property(access=PropertyAccess.READ) + def CanGoPrevious(self) -> "b": + return self._svc.can_previous + + @dbus_property(access=PropertyAccess.READ) + def CanPlay(self) -> "b": + return self._svc.can_play + + @dbus_property(access=PropertyAccess.READ) + def CanPause(self) -> "b": + return self._svc.can_pause + + @dbus_property(access=PropertyAccess.READ) + def CanSeek(self) -> "b": + return self._svc.can_seek + + @dbus_property(access=PropertyAccess.READ) + def CanControl(self) -> "b": + return True + + @dbus_property(access=PropertyAccess.READWRITE) + def Volume(self) -> "d": + return float(self._svc.volume) + + @Volume.setter + def Volume(self, value: "d"): + self._svc.dispatch("volume", float(value)) + + @dbus_property(access=PropertyAccess.READWRITE) + def LoopStatus(self) -> "s": + return self._svc.loop_status + + @LoopStatus.setter + def LoopStatus(self, value: "s"): + self._svc.loop_status = str(value) + + @dbus_property(access=PropertyAccess.READWRITE) + def Shuffle(self) -> "b": + return self._svc.shuffle + + @Shuffle.setter + def Shuffle(self, value: "b"): + self._svc.shuffle = bool(value) + + @dbus_property(access=PropertyAccess.READWRITE) + def Rate(self) -> "d": + return float(self._svc.rate) + + @Rate.setter + def Rate(self, value: "d"): + self._svc.rate = float(value) + + +class MprisService: + """Публикует MPRIS на сессионном D-Bus; состояние задаётся снаружи. + + ``handler`` — объект с необязательными методами ``on_play``, + ``on_pause``, ``on_play_pause``, ``on_next``, ``on_previous``, + ``on_stop``, ``on_seek(offset_us)``, ``on_set_position(track_path, us)``, + ``on_volume(value)``, ``on_raise()``, ``on_quit()``. + """ + + def __init__( + self, + handler: Any, + identity: str = "MTS Music", + desktop_entry: str = "mts-music", + ): + self.handler = handler + self.identity = identity + self.desktop_entry = desktop_entry + self.enabled = False + + self.playback_status = "Stopped" + self.metadata: dict[str, Any] = {} + self.position = 0 + self.volume = 1.0 + self.can_next = False + self.can_previous = False + self.can_play = True + self.can_pause = True + self.can_seek = False + self.loop_status = "None" + self.shuffle = False + self.rate = 1.0 + + self._loop: asyncio.AbstractEventLoop | None = None + self._bus: Any = None + self._player: Any = None + self._root: Any = None + self._thread: threading.Thread | None = None + + if HAVE_DBUS: + self._player = _PlayerInterface(self) + self._root = _RootInterface(self) + self._thread = threading.Thread( + target=self._run, name="mpris-dbus", daemon=True + ) + self._thread.start() + + # -- жизненный цикл --------------------------------------------------- + + def _run(self): + loop = asyncio.new_event_loop() + self._loop = loop + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._connect()) + loop.run_forever() + except Exception: # pragma: no cover - окружение без D-Bus + log.debug("MPRIS недоступен", exc_info=True) + + async def _connect(self): + self._bus = await MessageBus().connect() + self._bus.export(OBJECT_PATH, self._player) + self._bus.export(OBJECT_PATH, self._root) + await self._bus.request_name(BUS_NAME) + self.enabled = True + log.debug("MPRIS зарегистрирован: %s", BUS_NAME) + + def close(self): + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + + # -- диспетчеризация команд из D-Bus --------------------------------- + + def dispatch(self, action: str, *args: Any): + callback: Callable[..., Any] | None = getattr( + self.handler, "on_" + action, None + ) + if callback is None: + return + try: + callback(*args) + except Exception: # pragma: no cover + log.debug("ошибка обработчика MPRIS: %s", action, exc_info=True) + + def _emit(self, changed: dict[str, Any]): + if not (self.enabled and self._loop): + return + self._loop.call_soon_threadsafe(self._player.emit_properties_changed, changed) + + # -- обновление состояния (из основного потока) ---------------------- + + def update_playback_status(self, status: str): + if status == self.playback_status: + return + self.playback_status = status + self.can_seek = status != "Stopped" or self.can_seek + self._emit({"PlaybackStatus": status}) + + def update_metadata( + self, + track_id: Any, + title: str = "", + artist: str = "", + album: str = "", + duration_ms: int = 0, + art_url: str = "", + ): + metadata: dict[str, Any] = { + "mpris:trackid": Variant("o", track_object_path(track_id)), + "mpris:length": Variant("x", int(duration_ms) * 1000), + "xesam:title": Variant("s", title or ""), + "xesam:artist": Variant("as", [artist] if artist else []), + "xesam:album": Variant("s", album or ""), + } + if art_url: + metadata["mpris:artUrl"] = Variant("s", art_url) + self.metadata = metadata + self._emit({"Metadata": metadata}) + + def clear_metadata(self): + self.metadata = {} + self._emit({"Metadata": self.metadata}) + + def update_position(self, position_us: int): + # Position в MPRIS не рассылается через PropertiesChanged — клиенты + # читают его сами (или ориентируются на сигнал Seeked). + self.position = max(0, int(position_us)) + + def notify_seeked(self, position_us: int): + self.update_position(position_us) + if self.enabled and self._loop: + self._loop.call_soon_threadsafe(self._player.Seeked, int(position_us)) + + def update_volume(self, volume: float): + volume = max(0.0, min(1.0, float(volume))) + if abs(volume - self.volume) < 1e-4: + return + self.volume = volume + self._emit({"Volume": volume}) + + def update_capabilities( + self, + can_next: bool | None = None, + can_previous: bool | None = None, + can_play: bool | None = None, + can_pause: bool | None = None, + can_seek: bool | None = None, + ): + changed: dict[str, Any] = {} + if can_next is not None and can_next != self.can_next: + self.can_next = can_next + changed["CanGoNext"] = can_next + if can_previous is not None and can_previous != self.can_previous: + self.can_previous = can_previous + changed["CanGoPrevious"] = can_previous + if can_play is not None and can_play != self.can_play: + self.can_play = can_play + changed["CanPlay"] = can_play + if can_pause is not None and can_pause != self.can_pause: + self.can_pause = can_pause + changed["CanPause"] = can_pause + if can_seek is not None and can_seek != self.can_seek: + self.can_seek = can_seek + changed["CanSeek"] = can_seek + if changed: + self._emit(changed) diff --git a/qt_app.py b/qt_app.py index ebd35a4..ccf7d7c 100644 --- a/qt_app.py +++ b/qt_app.py @@ -90,6 +90,7 @@ from settings import ( load_settings, update_settings, ) +from mpris import MprisService, track_object_path APP_QSS = """ QWidget { @@ -439,6 +440,55 @@ class Scrim(QFrame): super().mousePressEvent(event) +class MprisBridge(QObject): + """Принимает команды MPRIS из D-Bus и перекладывает их в поток Qt.""" + + play = Signal() + pause = Signal() + playPause = Signal() + stop = Signal() + next = Signal() + previous = Signal() + seek = Signal(int) + setPosition = Signal(str, int) + volume = Signal(float) + raiseWindow = Signal() + quitApp = Signal() + + def on_play(self) -> None: + self.play.emit() + + def on_pause(self) -> None: + self.pause.emit() + + def on_play_pause(self) -> None: + self.playPause.emit() + + def on_stop(self) -> None: + self.stop.emit() + + def on_next(self) -> None: + self.next.emit() + + def on_previous(self) -> None: + self.previous.emit() + + def on_seek(self, offset: int) -> None: + self.seek.emit(int(offset)) + + def on_set_position(self, track_id: str, position: int) -> None: + self.setPosition.emit(str(track_id), int(position)) + + def on_volume(self, value: float) -> None: + self.volume.emit(float(value)) + + def on_raise(self) -> None: + self.raiseWindow.emit() + + def on_quit(self) -> None: + self.quitApp.emit() + + class QtMusicApp(QMainWindow): def __init__(self, api: Api) -> None: super().__init__() @@ -489,6 +539,22 @@ class QtMusicApp(QMainWindow): self._update_cover_size() self.play_btn.setFocus() + # MPRIS: система видит плеер как источник звука (панель GNOME, playerctl). + self.mpris_bridge = MprisBridge(self) + self.mpris_bridge.play.connect(self.mpris_play) + self.mpris_bridge.pause.connect(self.mpris_pause) + self.mpris_bridge.playPause.connect(self.toggle_play) + self.mpris_bridge.stop.connect(self.mpris_stop) + self.mpris_bridge.next.connect(lambda: self.advance(1)) + self.mpris_bridge.previous.connect(lambda: self.advance(-1)) + self.mpris_bridge.seek.connect(self.mpris_seek) + self.mpris_bridge.setPosition.connect(self.mpris_set_position) + self.mpris_bridge.volume.connect(self.mpris_set_volume) + self.mpris_bridge.raiseWindow.connect(self.mpris_raise) + self.mpris_bridge.quitApp.connect(self.close) + self.mpris = MprisService(self.mpris_bridge, identity="MTS Music") + self.mpris.update_volume(self.audio.volume()) + QTimer.singleShot(0, self.load_initial) # -- построение интерфейса ------------------------------------------- @@ -892,6 +958,8 @@ class QtMusicApp(QMainWindow): self.volume_value.setText(f"{value} %") self.audio.setVolume(value / 100) self.update_setting("volume", value) + if getattr(self, "mpris", None): + self.mpris.update_volume(value / 100) def on_device_changed(self, index: int) -> None: device = self.device_combo.itemData(index) @@ -1142,6 +1210,7 @@ class QtMusicApp(QMainWindow): self.duration_label.setText(fmt_millis(duration)) self.update_like_button() self.update_play_button() + self._update_mpris_metadata() def update_like_button(self, liked: bool | None = None) -> None: if liked is None: @@ -1154,6 +1223,7 @@ class QtMusicApp(QMainWindow): def update_play_button(self, *args) -> None: playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) + self._update_mpris_status() # -- обложка ---------------------------------------------------------- @@ -1266,6 +1336,8 @@ class QtMusicApp(QMainWindow): if duration > 1: self.slider.setValue(min(position, duration)) self.elapsed_label.setText(fmt_millis(position)) + if getattr(self, "mpris", None): + self.mpris.update_position(int(position) * 1000) def on_player_duration(self, duration: int) -> None: track_duration = int((self.current or {}).get("durationMs") or 0) @@ -1286,6 +1358,8 @@ class QtMusicApp(QMainWindow): self._seeking = False self.player.setPosition(value) self.elapsed_label.setText(fmt_millis(value)) + if getattr(self, "mpris", None): + self.mpris.notify_seeked(value * 1000) def on_slider_action(self, action: int) -> None: # Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider). @@ -1293,6 +1367,8 @@ class QtMusicApp(QMainWindow): value = self.slider.value() self.player.setPosition(value) self.elapsed_label.setText(fmt_millis(value)) + if getattr(self, "mpris", None): + self.mpris.notify_seeked(value * 1000) def seek(self, delta_ms: int) -> None: if self.player.source().isEmpty(): @@ -1302,6 +1378,92 @@ class QtMusicApp(QMainWindow): self.player.setPosition(position) self.slider.setValue(position) self.elapsed_label.setText(fmt_millis(position)) + if getattr(self, "mpris", None): + self.mpris.notify_seeked(position * 1000) + + # -- MPRIS ------------------------------------------------------------ + + def mpris_play(self) -> None: + if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: + self.toggle_play() + + def mpris_pause(self) -> None: + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.toggle_play() + + def mpris_stop(self) -> None: + self.player.stop() + self.slider.setValue(0) + self.elapsed_label.setText("0:00") + self.update_play_button() + + def mpris_seek(self, offset_us: int) -> None: + if self.player.source().isEmpty(): + return + duration = self.slider.maximum() + position = max( + 0, min(self.player.position() + offset_us // 1000, max(0, duration - 500)) + ) + self.player.setPosition(position) + self.slider.setValue(position) + self.elapsed_label.setText(fmt_millis(position)) + self.mpris.notify_seeked(position * 1000) + + def mpris_set_position(self, track_path: str, position_us: int) -> None: + if not self.current: + return + if track_object_path(self.current.get("id")) != track_path: + return + position = max(0, position_us // 1000) + self.player.setPosition(position) + self.slider.setValue(position) + self.elapsed_label.setText(fmt_millis(position)) + self.mpris.update_position(position_us) + + def mpris_set_volume(self, value: float) -> None: + value = max(0.0, min(1.0, value)) + self.audio.setVolume(value) + self.volume_slider.setValue(int(round(value * 100))) + + def mpris_raise(self) -> None: + self.showNormal() + self.raise_() + self.activateWindow() + + def _update_mpris_status(self) -> None: + if not getattr(self, "mpris", None): + return + state = self.player.playbackState() + if state == QMediaPlayer.PlaybackState.PlayingState: + status = "Playing" + elif state == QMediaPlayer.PlaybackState.PausedState: + status = "Paused" + else: + status = "Stopped" + self.mpris.update_playback_status(status) + + def _update_mpris_metadata(self) -> None: + if not getattr(self, "mpris", None): + return + track = self.current + if not track: + self.mpris.clear_metadata() + self.mpris.update_capabilities( + can_next=False, can_previous=False, can_seek=False + ) + return + self.mpris.update_metadata( + track.get("id"), + title=str(track.get("title") or ""), + artist=track_artist(track), + album=track_album(track), + duration_ms=int(track.get("durationMs") or 0), + art_url=track_cover_url(track) or "", + ) + has_queue = bool(self.queue or self.wave) + self.mpris.update_capabilities( + can_next=has_queue, can_previous=has_queue, can_seek=True + ) def advance(self, direction: int) -> None: queue = self.queue or self.wave @@ -1629,6 +1791,8 @@ class QtMusicApp(QMainWindow): def closeEvent(self, event) -> None: # noqa: N802 self.player.stop() + if getattr(self, "mpris", None): + self.mpris.close() self.pool.clear() super().closeEvent(event) diff --git a/requirements.txt b/requirements.txt index 3de73a8..41dd7d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ playwright>=1.40 requests>=2.31 textual>=0.80 pillow>=10 +dbus-next>=0.2.3 diff --git a/tui.py b/tui.py index 0b1b799..59090f1 100644 --- a/tui.py +++ b/tui.py @@ -43,6 +43,7 @@ from mtsmusic import ( resolve_stream, ) from player import MusicPlayer +from mpris import MprisService, track_object_path from settings import ( START_QUEUE_LABELS, START_QUEUES, @@ -459,6 +460,52 @@ class MenuScreen(ModalScreen[str | None]): # --------------------------------------------------------------------------- +class TuiMprisHandler: + """Прокидывает команды MPRIS из D-Bus в поток приложения Textual.""" + + def __init__(self, app: "MusicTui") -> None: + self.app = app + + def _call(self, method: str, *args) -> None: + callback = getattr(self.app, method, None) + if callback is None: + return + try: + self.app.call_from_thread(callback, *args) + except Exception: + return + + def on_play(self) -> None: + self._call("action_play_current") + + def on_pause(self) -> None: + self._call("mpris_pause") + + def on_play_pause(self) -> None: + self._call("action_toggle_pause") + + def on_stop(self) -> None: + self._call("mpris_stop") + + def on_next(self) -> None: + self._call("action_next_track") + + def on_previous(self) -> None: + self._call("action_prev_track") + + def on_seek(self, offset: int) -> None: + self._call("mpris_seek", offset) + + def on_set_position(self, track_id: str, position: int) -> None: + self._call("mpris_set_position", track_id, position) + + def on_volume(self, value: float) -> None: + self._call("mpris_set_volume", value) + + def on_quit(self) -> None: + self._call("exit") + + class MusicTui(App): TITLE = "MTS Music" @@ -585,6 +632,7 @@ class MusicTui(App): self.cover_bytes: dict[str, bytes] = {} self.cover_rendered: dict[tuple[str, int], Text] = {} self.cover_width = 40 + self.mpris: MprisService | None = None # -- разметка --------------------------------------------------------- @@ -615,6 +663,8 @@ class MusicTui(App): self.theme = "mono" self.set_interval(0.4, self.tick) self.apply_cover_width() + self.mpris = MprisService(TuiMprisHandler(self), identity="MTS Music") + self.mpris.update_volume(self.player.volume / 100) try: self.api = Api() except SystemExit: @@ -627,6 +677,8 @@ class MusicTui(App): self.load_initial() async def on_unmount(self) -> None: + if self.mpris: + self.mpris.close() await self.player.stop() # -- геометрия -------------------------------------------------------- @@ -749,6 +801,7 @@ class MusicTui(App): self.update_like_button(liked) self.update_play_button() self.update_time() + self._update_mpris_metadata() self.sub_title = track.get("title") or "" def update_like_button(self, liked: bool | None = None) -> None: @@ -761,6 +814,7 @@ class MusicTui(App): def update_play_button(self) -> None: self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶" + self._update_mpris_status() def update_time(self) -> None: duration = (self.current or {}).get("durationMs") or 0 @@ -770,6 +824,8 @@ class MusicTui(App): self.query_one("#time", Static).update( f"{fmt_duration(int(elapsed * 1000))} / {fmt_duration(duration)}" ) + if self.mpris: + self.mpris.update_position(int(elapsed * 1000) * 1000) # -- обложка ---------------------------------------------------------- @@ -1069,6 +1125,76 @@ class MusicTui(App): volume = int(self.settings.get("volume", 80)) if self.player.volume != volume: self.run_worker(self.player.set_volume(volume), group="volume", exclusive=True) + if self.mpris: + self.mpris.update_volume(volume / 100) + + # -- MPRIS ------------------------------------------------------------ + + def _update_mpris_status(self) -> None: + if not self.mpris: + return + if self.player.is_playing() and not self.player.paused: + status = "Playing" + elif self.player.is_playing(): + status = "Paused" + else: + status = "Stopped" + self.mpris.update_playback_status(status) + + def _update_mpris_metadata(self) -> None: + if not self.mpris: + return + track = self.current + if not track: + self.mpris.clear_metadata() + self.mpris.update_capabilities( + can_next=False, can_previous=False, can_seek=False + ) + return + self.mpris.update_metadata( + track.get("id"), + title=str(track.get("title") or ""), + artist=track_artist(track), + album=track_album(track), + duration_ms=int(track.get("durationMs") or 0), + art_url=track_cover_url(track) or "", + ) + has_queue = bool(self.queue or self.wave) + self.mpris.update_capabilities( + can_next=has_queue, can_previous=has_queue, can_seek=True + ) + + def mpris_pause(self) -> None: + if self.player.is_playing() and not self.player.paused: + self.player.toggle_pause() + self.update_play_button() + + def mpris_stop(self) -> None: + self.run_worker(self._mpris_stop(), group="playback", exclusive=True) + + async def _mpris_stop(self) -> None: + await self.player.stop() + self.update_play_button() + self.update_time() + + def mpris_seek(self, offset_us: int) -> None: + if not self.current or not self.player.is_playing(): + return + self.seek(offset_us / 1_000_000) + + def mpris_set_position(self, track_path: str, position_us: int) -> None: + if not self.current or not self.player.is_playing(): + return + if track_object_path(self.current.get("id")) != track_path: + return + duration = (self.current.get("durationMs") or 0) / 1000 + target = max(0.0, position_us / 1_000_000) + if duration > 0: + target = min(target, duration - 1) + self.seek(target - self.player.elapsed()) + + def mpris_set_volume(self, value: float) -> None: + self.update_setting("volume", int(round(max(0.0, min(1.0, value)) * 100))) def action_show_favorites(self) -> None: self.push_screen(FavoritesScreen())