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) или из командной
|
Настройки открываются из меню (пункт «Настройки» в 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,
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
126
tui.py
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user