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:
126
tui.py
126
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())
|
||||
|
||||
Reference in New Issue
Block a user