StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems - android/: Plex music player that plays per-stem mixes, with Android Auto support, a graphic + parametric (Equalizer APO-style) EQ, and offline downloads - server/: stemserve (serves stems to the app) and stemd (separates the library on the server's GPU, with "Stem Now" priority requests) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""MPRIS on the session bus: media keys, the desktop's media controls, and KDE Connect on the phone."""
|
||||
|
||||
import hashlib
|
||||
|
||||
from PySide6.QtCore import ClassInfo, Property, QObject, Slot
|
||||
from PySide6.QtDBus import QDBusAbstractAdaptor, QDBusConnection, QDBusMessage, QDBusObjectPath
|
||||
|
||||
PATH = "/org/mpris/MediaPlayer2"
|
||||
ROOT_IFACE = "org.mpris.MediaPlayer2"
|
||||
PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
|
||||
|
||||
|
||||
@ClassInfo({"D-Bus Interface": ROOT_IFACE})
|
||||
class _Root(QDBusAbstractAdaptor):
|
||||
def __init__(self, parent, window_raise, quit_):
|
||||
super().__init__(parent)
|
||||
self._raise, self._quit = window_raise, quit_
|
||||
|
||||
@Slot()
|
||||
def Raise(self):
|
||||
self._raise()
|
||||
|
||||
@Slot()
|
||||
def Quit(self):
|
||||
self._quit()
|
||||
|
||||
CanQuit = Property(bool, lambda self: True)
|
||||
CanRaise = Property(bool, lambda self: True)
|
||||
HasTrackList = Property(bool, lambda self: False)
|
||||
Identity = Property(str, lambda self: "StemRoller Player")
|
||||
DesktopEntry = Property(str, lambda self: "stemroller-player")
|
||||
SupportedUriSchemes = Property("QStringList", lambda self: [])
|
||||
SupportedMimeTypes = Property("QStringList", lambda self: [])
|
||||
|
||||
|
||||
@ClassInfo({"D-Bus Interface": PLAYER_IFACE})
|
||||
class _Player(QDBusAbstractAdaptor):
|
||||
def __init__(self, parent, mpris):
|
||||
super().__init__(parent)
|
||||
self.m = mpris
|
||||
|
||||
@Slot()
|
||||
def Next(self):
|
||||
self.m.player.next()
|
||||
|
||||
@Slot()
|
||||
def Previous(self):
|
||||
self.m.player.previous()
|
||||
|
||||
@Slot()
|
||||
def Pause(self):
|
||||
self.m.engine.pause()
|
||||
|
||||
@Slot()
|
||||
def PlayPause(self):
|
||||
self.m.player.toggle()
|
||||
|
||||
@Slot()
|
||||
def Stop(self):
|
||||
self.m.engine.pause()
|
||||
|
||||
@Slot()
|
||||
def Play(self):
|
||||
if not self.m.engine.playing:
|
||||
self.m.player.toggle()
|
||||
|
||||
@Slot("qlonglong")
|
||||
def Seek(self, offset_us):
|
||||
self.m.player.seek(self.m.engine.position() + offset_us / 1e6)
|
||||
|
||||
@Slot(QDBusObjectPath, "qlonglong")
|
||||
def SetPosition(self, track_id, position_us):
|
||||
self.m.player.seek(position_us / 1e6)
|
||||
|
||||
@Slot(str)
|
||||
def OpenUri(self, uri):
|
||||
pass
|
||||
|
||||
def _status(self):
|
||||
s = self.m.player._get_status()
|
||||
return "Playing" if s in ("playing", "buffering") or (s == "loading" and self.m.engine.playing) else \
|
||||
"Paused" if self.m.player.current() else "Stopped"
|
||||
|
||||
def _volume(self):
|
||||
return float(self.m.engine.volume)
|
||||
|
||||
def _set_volume(self, v):
|
||||
self.m.engine.setVolume(max(0.0, min(1.0, v)))
|
||||
|
||||
PlaybackStatus = Property(str, _status)
|
||||
LoopStatus = Property(str, lambda self: {"off": "None", "all": "Playlist", "one": "Track"}[self.m.player.repeat])
|
||||
Rate = Property(float, lambda self: 1.0)
|
||||
Shuffle = Property(bool, lambda self: self.m.player.shuffle)
|
||||
Metadata = Property("QVariantMap", lambda self: self.m.metadata())
|
||||
Volume = Property(float, _volume, _set_volume)
|
||||
Position = Property("qlonglong", lambda self: int(self.m.engine.position() * 1e6))
|
||||
MinimumRate = Property(float, lambda self: 1.0)
|
||||
MaximumRate = Property(float, lambda self: 1.0)
|
||||
CanGoNext = Property(bool, lambda self: bool(self.m.player.queue))
|
||||
CanGoPrevious = Property(bool, lambda self: bool(self.m.player.queue))
|
||||
CanPlay = Property(bool, lambda self: bool(self.m.player.queue))
|
||||
CanPause = Property(bool, lambda self: True)
|
||||
CanSeek = Property(bool, lambda self: True)
|
||||
CanControl = Property(bool, lambda self: True)
|
||||
|
||||
|
||||
class Mpris(QObject):
|
||||
def __init__(self, player, engine, window_raise, quit_, parent=None):
|
||||
super().__init__(parent)
|
||||
self.player, self.engine = player, engine
|
||||
self._root = _Root(self, window_raise, quit_)
|
||||
self._adaptor = _Player(self, self)
|
||||
self.bus = QDBusConnection.sessionBus()
|
||||
self.ok = self.bus.isConnected() and self.bus.registerObject(PATH, self) and \
|
||||
self.bus.registerService("org.mpris.MediaPlayer2.stemroller_player")
|
||||
self._last = {}
|
||||
player.changed.connect(self._changed)
|
||||
player.mixChanged.connect(self._changed)
|
||||
engine.volumeChanged.connect(self._changed)
|
||||
engine.playingChanged.connect(self._changed)
|
||||
|
||||
def metadata(self):
|
||||
t = self.player.current()
|
||||
if not t:
|
||||
return {"mpris:trackid": QDBusObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")}
|
||||
artist = t["artist"]
|
||||
label = self.player._get_preset_label()
|
||||
if label and label != "Full Mix":
|
||||
artist = f"{artist} · {label}" # the mix rides on the artist line, as on the phone
|
||||
md = {
|
||||
"mpris:trackid": QDBusObjectPath("/stemroller/track/t" + hashlib.sha1(t["key"].encode()).hexdigest()[:16]),
|
||||
"mpris:length": int((self.engine.duration or t.get("durationMs", 0) / 1000) * 1e6),
|
||||
"xesam:title": t["title"],
|
||||
"xesam:artist": [artist],
|
||||
"xesam:album": t["album"],
|
||||
"xesam:albumArtist": [t.get("albumArtist") or t["artist"]],
|
||||
}
|
||||
art = self.player.plex.art_url(t.get("thumb"), 600) or t.get("_cover", "")
|
||||
if art:
|
||||
md["mpris:artUrl"] = art
|
||||
if t.get("index"):
|
||||
md["xesam:trackNumber"] = t["index"]
|
||||
return md
|
||||
|
||||
def _changed(self):
|
||||
if not self.ok:
|
||||
return
|
||||
now = {
|
||||
"PlaybackStatus": self._adaptor._status(),
|
||||
"LoopStatus": self._adaptor.LoopStatus,
|
||||
"Shuffle": self.player.shuffle,
|
||||
"Volume": float(self.engine.volume),
|
||||
"Metadata": self.metadata(),
|
||||
"CanGoNext": bool(self.player.queue),
|
||||
"CanGoPrevious": bool(self.player.queue),
|
||||
"CanPlay": bool(self.player.queue),
|
||||
}
|
||||
diff = {k: v for k, v in now.items() if k == "Metadata" and _md_key(v) != _md_key(self._last.get(k)) or
|
||||
k != "Metadata" and self._last.get(k) != v}
|
||||
self._last = now
|
||||
if not diff:
|
||||
return
|
||||
msg = QDBusMessage.createSignal(PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
|
||||
msg.setArguments([PLAYER_IFACE, diff, []])
|
||||
self.bus.send(msg)
|
||||
|
||||
|
||||
def _md_key(md):
|
||||
if not md:
|
||||
return None
|
||||
return tuple((k, str(v.path()) if isinstance(v, QDBusObjectPath) else repr(v)) for k, v in sorted(md.items()))
|
||||
Reference in new issue
Block a user