Skip to content

Linux: the Spotify pill over MPRIS, and Mochi dances to it - #372

Merged
Louis-CFM merged 5 commits into
mainfrom
linux/spotify-dance
Oct 8, 2026
Merged

Louis-CFM merged 5 commits into
mainfrom
linux/spotify-dance

Conversation

@Louis-CFM

Copy link
Copy Markdown
Owner

The Mac's Spotify pill (#246) comes to Linux, through MPRIS, and Mochi now dances to the music on Windows/Linux, by the Mac's rules. Windows has no music source yet, so nothing changes there (same code path, nothing ever reports a track).

Rust: src-tauri/src/spotify.rs (Linux only)

  • A small MPRIS client for org.mpris.MediaPlayer2.spotify on the session bus, using dbus 0.9.12. It is already in Cargo.lock through keyring, so nothing new is downloaded. The only lock change is the new dependency line. Linux: global shortcuts on Wayland through the XDG GlobalShortcuts portal #367 adds dbus the same way.
  • No polling. While the pill is declared, one thread blocks on the bus. It wakes only for PropertiesChanged, Seeked and NameOwnerChanged (Spotify starting or quitting). Undeclaring the pill sends that thread a ping so it leaves the bus at once. The blocking_pop_message timeout is one hour; it only bounds a lost wake-up.
  • What it reads: track (id, title, artist(s), album, mpris:length, mpris:artUrl), PlaybackStatus, Position, Shuffle, LoopStatus and Volume. Titles and artists are cut like the Mac's shortTitle / shortArtist. Track ids become spotify:track:…, so ads are recognised.
  • Position: it isn't signalled. Rust reads it when the status or the track changes and when the card shows (spotify_refresh). The page runs it on from positionAt, as position(at:) does on the Mac.
  • Commands:
    • spotify_control: playPause / next / previous / seek / shuffle / repeat / volume. Seek uses SetPosition, or Seek on builds whose trackid isn't an object path. Shuffle, repeat and volume show at once, since Spotify doesn't always signal them.
    • spotify_open: Raise if it can. Otherwise it starts spotify from PATH, else the Flatpak com.spotify.Client, else the Snap. With none of them it opens https://www.spotify.com/download/linux/.
    • spotify_installed: for Settings.
  • Covers: the CSP blocks remote images (img-src 'self' data: blob:), so Rust fetches the cover with reqwest and sends a data URL in a spotify-artwork event. Rules:
    • Only i.scdn.co, *.scdn.co, *.spotifycdn.com or a local file:// path are read. open.spotify.com/image/… is rewritten to the CDN.
    • No redirects, 3 MB cap, and the type is checked from the bytes.
    • The last 12 covers are cached.
    • Nothing is fetched while Coucou is paused.

Page

  • Catalog: integration_spotify ("Spotify", #1DB954, service, "Integration"), connect none. A new support: "linux" sits next to "windows". It is declared in Settings → Integrations, which now lists only the pills this OS offers. When there is no Spotify to launch, the row says "Not installed", as on the Mac.
  • Views (views/spotify.ts) are ported from SpotifyPill, MusicControlButton, SpotifyCardView and NowPlaying*, with the Mac's sizes and colours:
    • Pill: the existing pill look, with a mini Mochi that dances while Spotify plays. On hover, play/pause and next appear (20 px round buttons) and the title makes room for them.
    • Card, playing: 36 px cover, title with the green dot, artist · album, and the progress bar with elapsed and remaining time. Drag or click the bar to seek. Below are shuffle, previous, play/pause, next and repeat, then the volume slider (sends are coalesced to one every 120 ms).
    • Card, idle: "Not playing" or "Spotify not installed", with Open Spotify or Get Spotify, in the existing idle-card layout.
    • Card, timers: while the card is on screen and playing, the progress repaints twice a second. Otherwise nothing runs.
    • New SVG icons: play, pause, forward, backward, shuffle, repeat, a music note and the volume levels. Nothing that already ships was restyled.
  • Strings: all of them are the Mac's and were already in strings.json ("Not playing", "Open Spotify", "Get Spotify", "{0} — open Spotify", "Volume {0}%", "Shuffle on"…). extra.json needed nothing new.
  • The pill's name follows the track ("Advertisement" for ads), as syncTaskName does. When music starts, the hidden island opens to compact without the peek sound (musicReveal).

Dance

  • BotEngine.setDancing / applyDance are ported exactly into mochi/engine.ts: 112 BPM sway, hop, tilt and squash around the bottom of the body, a 0.3 s fade in and 0.5 s fade out, and happy eyes in idle or finished.
  • Where he dances (Mac rules):
    • The island's Mochi: in compact mode whenever music plays, in the idle, working, thinking, searching and finished states.
    • When expanded: only on the overview with the Spotify pill in front.
    • The Spotify pill's mini Mochi.
    • Mochi on the desktop: the island adds dancing to the snapshot it sends him.
  • One deliberate difference: the desktop Mochi stays awake while dancing. Asleep he would bounce at 4 fps; the Mac lets him doze off mid-dance.

README

The Linux section now covers the Spotify pill. The "doesn't dance" line now says he dances to Spotify on Linux.

Tests

  • Rust: cargo test -p coucou --lib gives 213 passed and 1 ignored (11 new). They cover:
    • MPRIS parsing from real dbus variants and from plain values, and the metadata order.
    • Position extrapolation, pause freezing and the cap at the track's end.
    • Track ids, title and artist cleaning, cover sources and image sniffing.
    • The JSON shape the page receives.
  • End to end: dbus-run-session -- cargo test -p coucou --lib spotify -- --ignored passes. It runs the real listener against a fake Spotify on a private session bus:
    • the initial read;
    • PlayPause, then the PropertiesChanged that comes back and the position read again;
    • SetPosition, Shuffle and Volume;
    • Spotify quitting (NameOwnerChanged);
    • the thread really ending when the pill is undeclared.
  • Node: npm test gives 345 passed (21 new). They cover:
    • the dance math and the engine's transform;
    • the island and desktop dance rules;
    • position, time format and volume thresholds;
    • the catalog's Linux-only filtering in both directions;
    • report handling: pill name, silent reveal, refresh on show;
    • the card and pill views, including their controls.
  • Builds: npx tsc --noEmit is clean, and cargo build -p coucou has no warnings.

Not covered

  • Not run against a real Spotify: no Spotify or display in the build environment. The MPRIS calls follow the spec and the fake service.
  • Older Spotify builds: some report Position as 0, so the bar starts from 0 after a pause or seek made in Spotify itself.
  • Windows: the Windows build wasn't compiled here. The new Rust code there is the four no-op commands.
  • Credit: the Mac's Spotify pill is by @JhoanG956 (Mac: Spotify pill and a richer Apple Music card #246).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LJm3SRL9k3gBPaVtfPhwcT


Generated by Claude Code

claude added 5 commits October 9, 2026 00:32
On Linux "Open terminal" always opened the session's folder in VS Code. The
Windows build already finds the window a Claude Code session runs in by
walking up the process tree from the relay; this does the same on Linux and
raises the window with whatever the desktop allows.

Finding it:
- pipe.rs: the Unix socket's SO_PEERCRED gives the relay's PID (tokio's
  peer_cred), as GetNamedPipeClientProcessId does on Windows.
- session_window.rs: parse_stat reads /proc/<pid>/stat (the name ends at the
  last ')', so names with spaces and parentheses are read right), a Linux
  TREE_TOPS list (systemd, init, logins, sshd, display and session managers,
  compositors and panels that own windows) ends the walk, and PID 1 and
  processes of another user are left out of the table. A session now keeps
  a list of candidate PIDs: the window owner on Windows, every ancestor on
  Linux, where which one owns a window is asked only on click.
- choose_window: the windows of the nearest ancestor that has any, the one
  titled after the session folder first.

Raising it (platform/linux_focus.rs), off the UI thread, best way first:
- KDE Plasma on Wayland: a temporary KWin script loaded over D-Bus from the
  private runtime dir activates the window by PID (Plasma 6 windowList /
  activeWindow, Plasma 5 clientList / activeClient), calls us back with
  Activated or NotFound, and is then unloaded and its file removed.
- X11 (and XWayland windows): EWMH through x11rb, _NET_CLIENT_LIST +
  _NET_WM_PID, then _NET_CURRENT_DESKTOP and _NET_ACTIVE_WINDOW (source 2).
- kitty: kitten @ focus-window --match pid:<shell> selects the tab when its
  remote control listens on a socket.
- Nothing worked (GNOME Wayland native windows, tmux, ssh): VS Code, as before.

Before use the remembered chain is re-read from /proc and cut where it no
longer links, so a closed terminal's reused PID is never raised. Only numbers
from /proc reach the script and the commands (the folder name travels as
character codes); commands get separate arguments, never a shell.

dbus and x11rb were already in Cargo.lock (keyring, global-hotkey) with the
same features: nothing new to download or build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJm3SRL9k3gBPaVtfPhwcT
The Mac has a Spotify pill (#246) and Mochi dances while music plays; the
Windows and Linux app had neither. Linux has what the Mac gets from
Spotify's notification and AppleScript in MPRIS, Spotify's D-Bus interface,
so the pill comes to Linux first.

Rust (src-tauri/src/spotify.rs, Linux only):
- A small MPRIS client on libdbus (`dbus` 0.9.12, already in Cargo.lock
  through keyring: nothing new is downloaded). While the pill is declared,
  one thread blocks on the session bus and wakes only for Spotify's
  PropertiesChanged and Seeked signals and NameOwnerChanged (Spotify
  starting or quitting): no polling, 0 % CPU when nothing happens.
  Undeclaring the pill pings that thread off the bus.
- Track (id, title, artist, album, length, cover URL), status, position,
  shuffle, loop status and volume, cleaned like the Mac (shortTitle,
  shortArtist). Position isn't signalled: it is read when the status or the
  track changes and when the card shows, and the page runs it on from a
  timestamp, as SpotifyController.position(at:).
- Controls: PlayPause, Next, Previous, SetPosition (Seek on older builds),
  Shuffle, LoopStatus, Volume. Open raises Spotify, else starts `spotify`
  from PATH, the Flatpak or the Snap, else opens the download page.
- Covers: the CSP keeps remote images out of the page, so Rust fetches them
  (Spotify's CDN only, or a local track's file), checks the bytes are an
  image and hands a data URL over; nothing is fetched while paused.

Page:
- `integration_spotify` in the catalog (Mac values), with a new `linux`
  support next to `windows`: offered on Linux only, nothing to set up;
  declared from Settings → Integrations.
- SpotifyPill and SpotifyCardView ported: dancing mini Mochi with
  play/pause and next on hover; cover, title, artist · album, progress with
  seek, shuffle / previous / play-pause / next / repeat, volume; the idle
  card says Not playing or Spotify not installed with Open / Get Spotify.
  Mac strings, in every language already.
- The Mac's BotEngine dance (setDancing, applyDance, happy eyes) in
  mochi/engine.ts, with the Mac's rules: the compact island whenever music
  plays, expanded on the Spotify card, the pill's mini Mochi, and Mochi on
  the desktop (the island sends him `dancing`). Music starting shows the
  hidden island without the peek sound. On Windows nothing reports music,
  so nothing changes there.

Tests: MPRIS parsing from dbus variants and plain values, position
extrapolation, track ids, covers' sources and types (Rust, plus an
end-to-end test against a fake Spotify on a private bus, run with
`dbus-run-session -- cargo test -p coucou --lib spotify -- --ignored`);
the dance math, the dance rules, the catalog's Linux-only filtering, report
handling and the views (Node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJm3SRL9k3gBPaVtfPhwcT
Cargo.toml: one dbus line for both portal.rs (#367) and linux_focus.rs, x11rb
kept. README: the Linux remark on Open terminal and #367's shortcuts line.
The portal test from #367 still expected "Send Mochi to the desktop" to be
unported; #370 ported it, so it is handed over like the others now, and
"Attach the front window" is the one left unported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJm3SRL9k3gBPaVtfPhwcT
# Conflicts:
#	windows/README.md
#	windows/src-tauri/Cargo.toml
#	windows/src-tauri/src/lib.rs
# Conflicts:
#	windows/src-tauri/Cargo.toml
@Louis-CFM
Louis-CFM merged commit 873f39d into main Oct 8, 2026
4 checks passed
shawn-t516 pushed a commit to shawn-t516/coucou that referenced this pull request Oct 9, 2026
The macOS runner failed this test on main and on Louis-CFM#372 while nothing it
covers had changed. The two cases that raced a short timer against a short
sleep now check long after the old timer and long before the new one, and
every wait gets the 5 s default instead of 1 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJm3SRL9k3gBPaVtfPhwcT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants