Skip to content
📖 12 min🎯Difficulty:
Prerequisites:PopupWindowIPC
You'll build:A media player popup that controls playback and shows now-playing info

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:

qml
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.

💡Mental Model

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 ​

qml
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() } }
      }
    }
  }
}
🛠️Let's Build It

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:

qml
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
      }
    }
  }
}
⚠️Common Mistake

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 ​

⭐Exercise
Add a volume slider (0-100) that controls the media player's volume via `player.volume`. Display volume percentage next to the slider.
⭐⭐Exercise
Build a "Now Playing" panel widget that shows the current track title scrolling (marquee) when it's too long to fit. Use `Text` with `elide` and a `Timer` to animate text position.
⭐⭐⭐Exercise
Implement a full queue view. Subscribe to the player's `TrackList` property via D-Bus (requires extra D-Bus calls beyond what MprisService exposes). Display a `ListView` of upcoming tracks and allow reordering.
✅What You've Learned
  • 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

Built with VitePress. Licensed under MIT.