feat: expose player over MPRIS for system media controls

Register a session D-Bus MPRIS service (org.mpris.MediaPlayer2.mtsmusic)
in both the TUI and Qt apps, so the player shows up as a media source:
play/pause, next/previous, seek, metadata (title/artist/album/length/art)
and volume can be controlled from the GNOME media-controls extension,
playerctl and other MPRIS clients. Integration is optional and degrades
to a no-op without dbus-next or a session bus.
This commit is contained in:
jads
2026-10-23 23:32:59 +03:00
parent 5b202984fa
commit 4460791a95
5 changed files with 680 additions and 0 deletions

126
tui.py
View File

@@ -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())