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

164
qt_app.py
View File

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