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:
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user