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:
18
README.md
18
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) или из командной
|
||||
|
||||
371
mpris.py
Normal file
371
mpris.py
Normal 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
164
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)
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ playwright>=1.40
|
||||
requests>=2.31
|
||||
textual>=0.80
|
||||
pillow>=10
|
||||
dbus-next>=0.2.3
|
||||
|
||||
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