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

View File

@@ -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) или из командной Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной

371
mpris.py Normal file
View File

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

164
qt_app.py
View File

@@ -90,6 +90,7 @@ from settings import (
load_settings, load_settings,
update_settings, update_settings,
) )
from mpris import MprisService, track_object_path
APP_QSS = """ APP_QSS = """
QWidget { QWidget {
@@ -439,6 +440,55 @@ class Scrim(QFrame):
super().mousePressEvent(event) 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): class QtMusicApp(QMainWindow):
def __init__(self, api: Api) -> None: def __init__(self, api: Api) -> None:
super().__init__() super().__init__()
@@ -489,6 +539,22 @@ class QtMusicApp(QMainWindow):
self._update_cover_size() self._update_cover_size()
self.play_btn.setFocus() 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) QTimer.singleShot(0, self.load_initial)
# -- построение интерфейса ------------------------------------------- # -- построение интерфейса -------------------------------------------
@@ -892,6 +958,8 @@ class QtMusicApp(QMainWindow):
self.volume_value.setText(f"{value} %") self.volume_value.setText(f"{value} %")
self.audio.setVolume(value / 100) self.audio.setVolume(value / 100)
self.update_setting("volume", value) self.update_setting("volume", value)
if getattr(self, "mpris", None):
self.mpris.update_volume(value / 100)
def on_device_changed(self, index: int) -> None: def on_device_changed(self, index: int) -> None:
device = self.device_combo.itemData(index) device = self.device_combo.itemData(index)
@@ -1142,6 +1210,7 @@ class QtMusicApp(QMainWindow):
self.duration_label.setText(fmt_millis(duration)) self.duration_label.setText(fmt_millis(duration))
self.update_like_button() self.update_like_button()
self.update_play_button() self.update_play_button()
self._update_mpris_metadata()
def update_like_button(self, liked: bool | None = None) -> None: def update_like_button(self, liked: bool | None = None) -> None:
if liked is None: if liked is None:
@@ -1154,6 +1223,7 @@ class QtMusicApp(QMainWindow):
def update_play_button(self, *args) -> None: def update_play_button(self, *args) -> None:
playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) 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: if duration > 1:
self.slider.setValue(min(position, duration)) self.slider.setValue(min(position, duration))
self.elapsed_label.setText(fmt_millis(position)) 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: def on_player_duration(self, duration: int) -> None:
track_duration = int((self.current or {}).get("durationMs") or 0) track_duration = int((self.current or {}).get("durationMs") or 0)
@@ -1286,6 +1358,8 @@ class QtMusicApp(QMainWindow):
self._seeking = False self._seeking = False
self.player.setPosition(value) self.player.setPosition(value)
self.elapsed_label.setText(fmt_millis(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: def on_slider_action(self, action: int) -> None:
# Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider). # Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider).
@@ -1293,6 +1367,8 @@ class QtMusicApp(QMainWindow):
value = self.slider.value() value = self.slider.value()
self.player.setPosition(value) self.player.setPosition(value)
self.elapsed_label.setText(fmt_millis(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: def seek(self, delta_ms: int) -> None:
if self.player.source().isEmpty(): if self.player.source().isEmpty():
@@ -1302,6 +1378,92 @@ class QtMusicApp(QMainWindow):
self.player.setPosition(position) self.player.setPosition(position)
self.slider.setValue(position) self.slider.setValue(position)
self.elapsed_label.setText(fmt_millis(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: def advance(self, direction: int) -> None:
queue = self.queue or self.wave queue = self.queue or self.wave
@@ -1629,6 +1791,8 @@ class QtMusicApp(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802 def closeEvent(self, event) -> None: # noqa: N802
self.player.stop() self.player.stop()
if getattr(self, "mpris", None):
self.mpris.close()
self.pool.clear() self.pool.clear()
super().closeEvent(event) super().closeEvent(event)

View File

@@ -2,3 +2,4 @@ playwright>=1.40
requests>=2.31 requests>=2.31
textual>=0.80 textual>=0.80
pillow>=10 pillow>=10
dbus-next>=0.2.3

126
tui.py
View File

@@ -43,6 +43,7 @@ from mtsmusic import (
resolve_stream, resolve_stream,
) )
from player import MusicPlayer from player import MusicPlayer
from mpris import MprisService, track_object_path
from settings import ( from settings import (
START_QUEUE_LABELS, START_QUEUE_LABELS,
START_QUEUES, 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): class MusicTui(App):
TITLE = "MTS Music" TITLE = "MTS Music"
@@ -585,6 +632,7 @@ class MusicTui(App):
self.cover_bytes: dict[str, bytes] = {} self.cover_bytes: dict[str, bytes] = {}
self.cover_rendered: dict[tuple[str, int], Text] = {} self.cover_rendered: dict[tuple[str, int], Text] = {}
self.cover_width = 40 self.cover_width = 40
self.mpris: MprisService | None = None
# -- разметка --------------------------------------------------------- # -- разметка ---------------------------------------------------------
@@ -615,6 +663,8 @@ class MusicTui(App):
self.theme = "mono" self.theme = "mono"
self.set_interval(0.4, self.tick) self.set_interval(0.4, self.tick)
self.apply_cover_width() self.apply_cover_width()
self.mpris = MprisService(TuiMprisHandler(self), identity="MTS Music")
self.mpris.update_volume(self.player.volume / 100)
try: try:
self.api = Api() self.api = Api()
except SystemExit: except SystemExit:
@@ -627,6 +677,8 @@ class MusicTui(App):
self.load_initial() self.load_initial()
async def on_unmount(self) -> None: async def on_unmount(self) -> None:
if self.mpris:
self.mpris.close()
await self.player.stop() await self.player.stop()
# -- геометрия -------------------------------------------------------- # -- геометрия --------------------------------------------------------
@@ -749,6 +801,7 @@ class MusicTui(App):
self.update_like_button(liked) self.update_like_button(liked)
self.update_play_button() self.update_play_button()
self.update_time() self.update_time()
self._update_mpris_metadata()
self.sub_title = track.get("title") or "" self.sub_title = track.get("title") or ""
def update_like_button(self, liked: bool | None = None) -> None: def update_like_button(self, liked: bool | None = None) -> None:
@@ -761,6 +814,7 @@ class MusicTui(App):
def update_play_button(self) -> None: def update_play_button(self) -> None:
self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶" self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶"
self._update_mpris_status()
def update_time(self) -> None: def update_time(self) -> None:
duration = (self.current or {}).get("durationMs") or 0 duration = (self.current or {}).get("durationMs") or 0
@@ -770,6 +824,8 @@ class MusicTui(App):
self.query_one("#time", Static).update( self.query_one("#time", Static).update(
f"{fmt_duration(int(elapsed * 1000))} / {fmt_duration(duration)}" 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)) volume = int(self.settings.get("volume", 80))
if self.player.volume != volume: if self.player.volume != volume:
self.run_worker(self.player.set_volume(volume), group="volume", exclusive=True) 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: def action_show_favorites(self) -> None:
self.push_screen(FavoritesScreen()) self.push_screen(FavoritesScreen())