The Problem
Users play music and video throughout their workday. Without a media widget, they must alt-tab to their media app to pause, skip, or see what's playing. A shell-integrated media player solves this.
The Naive Approach
Spawn playerctl commands for every media action:
function playPause() {
Qt.process(["playerctl", "play-pause"])
}
function nextTrack() {
Qt.process(["playerctl", "next"])
}This works but each command spawns a subprocess (50-100ms latency). There's no reactive way to know when the track changes or playback state toggles.
Think of MPRIS as a universal remote protocol. Any media player that speaks MPRIS (Spotify, VLC, Firefox, mpv) exposes play/pause/next/prev controls and track metadata. Your shell is a universal remote that can control any of them.
The Idea
MPRIS (Media Player Remote Interfacing Specification) is a D-Bus interface that media players implement. Quickshell provides MprisService from Quickshell.Services that connects to all running MPRIS players and exposes their state.
Let's Build It
import Quickshell
import Quickshell.Services
import QtQml.Models
PopupWindow {
id: root
width: 350
height: 160
visible: false
property var player: MprisService.players.length > 0
? MprisService.players[0] : null
// Reactive bindings to current player
property string trackTitle: player ? player.title : "No media playing"
property string trackArtist: player ? player.artist : ""
property string artUrl: player ? player.artUrl : ""
property bool isPlaying: player ? player.isPlaying : false
property double position: player ? player.position : 0
property double length: player ? player.length : 0
Rectangle {
anchors.fill: parent
color: "#1e1e2e"
radius: 8
Column {
anchors.fill: parent; anchors.margins: 16; spacing: 8
// Album art + track info
Row {
spacing: 12
Rectangle {
width: 64; height: 64; radius: 6; color: "#313244"
Image { source: artUrl; anchors.fill: parent; radius: 6; fillMode: Image.PreserveAspectCrop }
Text { text: "♪"; anchors.centerIn: parent; color: "#6c7086"; font.pixelSize: 24; visible: artUrl === "" }
}
Column {
anchors.verticalCenter: parent.verticalCenter; spacing: 4
Text { text: trackTitle; font.pixelSize: 15; font.bold: true; color: "#cdd6f4"; elide: Text.ElideRight; width: 220 }
Text { text: trackArtist; font.pixelSize: 12; color: "#a6adc8"; elide: Text.ElideRight; width: 220 }
}
}
// Progress bar
Rectangle {
width: parent.width; height: 4; radius: 2; color: "#313244"
Rectangle {
width: parent.width * (length > 0 ? position / length : 0); height: 4; radius: 2; color: "#89b4fa"
}
}
// Controls
Row {
spacing: 16; anchors.horizontalCenter: parent.horizontalCenter
Text { text: "⏮"; color: "#cdd6f4"; font.pixelSize: 20; MouseArea { anchors.fill: parent; onClicked: player?.previous() } }
Text { text: isPlaying ? "⏸" : "▶"; color: "#89b4fa"; font.pixelSize: 24; MouseArea { anchors.fill: parent; onClicked: player?.playPause() } }
Text { text: "⏭"; color: "#cdd6f4"; font.pixelSize: 20; MouseArea { anchors.fill: parent; onClicked: player?.next() } }
Item { width: 20; height: 1 }
Text { text: "⏹"; color: "#f38ba8"; font.pixelSize: 18; MouseArea { anchors.fill: parent; onClicked: player?.stop() } }
}
}
}
}MprisService.players returns a list of all active MPRIS players. Each player has title, artist, album, artUrl, isPlaying, position, length, and methods playPause(), next(), previous(), stop(). Bind to these properties reactively — when the track changes, your UI updates automatically.
Let's Improve It
Add track list and player selection. Some users have multiple media apps open:
Row {
spacing: 6
Repeater {
model: MprisService.players
delegate: Rectangle {
required property var modelData
width: 80; height: 24; radius: 4
color: modelData === player ? "#89b4fa" : "#45475a"
Text {
text: modelData.identity || "Player"
anchors.centerIn: parent
color: "white"; font.pixelSize: 11
}
MouseArea {
anchors.fill: parent
onClicked: root.player = modelData
}
}
}
}Assuming a player is always available. MPRIS players come and go. When no player is active, show a "No media playing" state. When the current player exits, fall back to another player or show the idle state.
Not handling position updates. position updates every ~200ms during playback. If you bind a progress bar to position, it animates smoothly. But if you're polling position with a Timer, you're wasting cycles on paint events.
Forgetting that artUrl might be a file:// path or data URL. Some players send local file paths (file:///tmp/album-art.jpg), others send HTTP URLs. Handle both, and provide a fallback icon when the image fails to load.
Under the Hood
MPRIS is specified at specifications.freedesktop.org/mpris-spec/latest/. The D-Bus interface org.mpris.MediaPlayer2 provides root properties, and org.mpris.MediaPlayer2.Player provides playback controls. Quickshell's MprisService connects to all players on the session bus, listens to PropertiesChanged signals, and exposes a unified model to QML.
The position property is mapped from the D-Bus Position property (microseconds via org.freedesktop.DBus.Properties.Get). Quickshell converts it to seconds and updates it at ~5Hz during active playback.
Exercises
- MprisService exposes all MPRIS-compatible media players as a reactive model
- Bind to player.title, artist, isPlaying, position for live updates
- Provide a fallback UI when no player is active
- Position updates at ~5Hz for smooth progress bar animation