Skip to content

feat: music player island with MPRIS and PipeWire#87

Open
nongio wants to merge 36 commits into
mainfrom
feat/island-music
Open

feat: music player island with MPRIS and PipeWire#87
nongio wants to merge 36 commits into
mainfrom
feat/island-music

Conversation

@nongio

@nongio nongio commented Apr 4, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a music player island to otto-islands with MPRIS player control, PipeWire audio visualization, and a polished layout/animation system.

Music island

  • MPRIS monitoring via playerctl — track info, album art, playback state
  • PipeWire audio level capture for real-time EQ bar animation
  • Three display modes: Mini (animated EQ bars), Compact (album art + title + controls), Expanded (full player with progress bar and seek)
  • Interactive controls: play/pause, skip forward/back, seek via progress bar
  • Album art loading (including remote fetch for Spotify) with accent color extraction for dynamic theming
  • 3-second grace period when track disappears to avoid flicker on track changes
  • Music island only appears when player app loses focus

Layout and animation

  • Fixed-width island layer (900px) with manual centering — eliminates Wayland layer re-centering jank during animations
  • Two-zone layout: notifications on the left, priority islands (music) on the right
  • Cascade slide animations radiating from the island that changed most (epicenter)
  • Focus bias: content pulls 15% toward the clicked pill for visual feedback
  • New islands appear small and bounce-animate to their target size
  • Smooth animations for all mode transitions (Mini ↔ Compact ↔ Expanded)
  • Click on empty space dismisses expanded islands
  • Centralized focus/mode management with unit tests

Performance

  • EQ bars rendered as child subsurface — only EQ redraws at 24fps, not the full pill
  • Split redraw: fast EQ tick (~24fps) and slow full redraw (1fps for progress bar)
  • PipeWire level updates throttled to ~15fps
  • Partial EQ redraw — only clear and damage the equalizer region

Try it out

# Terminal 1: run Otto
cargo run -- --winit

# Terminal 2: run otto-islands
WAYLAND_DISPLAY=wayland-1 cargo run -p otto-islands

# Terminal 3: play music in any MPRIS-compatible player
spotify &
# or: vlc music.mp3 &

The music island appears automatically when playback starts and the player app loses focus.

Test plan

  • cargo build -p otto-islands passes
  • Play music in Spotify/VLC — music island appears when player loses focus
  • Click play/pause, skip controls work
  • Expanded mode shows progress bar, clicking seeks
  • Stop music — island disappears after 3s grace period
  • Music island coexists with notification islands (two-zone layout)
  • Notifications cascade-animate when islands are added/removed
  • Click empty space to dismiss expanded islands
  • New islands appear small and animate to target size

Copilot AI review requested due to automatic review settings April 4, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “music player island” to otto-islands, integrating MPRIS-based playback metadata/control (via playerctl) and PipeWire-based audio level sampling to drive an animated EQ UI across mini/compact/expanded presentation modes.

Changes:

  • Introduces music.rs implementing the music activity renderer, MPRIS polling, PipeWire capture, and control actions (play/pause/skip/seek).
  • Wires music monitoring and rendering into the main island lifecycle, layout, input handling, and tick scheduling (30fps when playing).
  • Adds the pipewire dependency to the otto-islands crate.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
components/otto-islands/src/music.rs New music island renderer + background monitors for playerctl and PipeWire, plus playback control actions.
components/otto-islands/src/main.rs Integrates the music island into island detection, layout/draw loop, input hit-testing, and tick cadence.
components/otto-islands/Cargo.toml Adds PipeWire crate dependency for audio level capture.

Comment thread components/otto-islands/src/music.rs Outdated
Comment on lines +745 to +753
let progress = if length > 0.0 {
(position / length).clamp(0.0, 1.0) as f32
} else {
0.0
};

// length is in microseconds from MPRIS
let duration_secs = (length / 1_000_000.0) as f32;

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

position and length appear to be in different units: mpris:length is microseconds, while {{position}} from playerctl is typically seconds. Computing progress = position / length will then be near-zero (or otherwise wrong). Consider normalizing to the same unit (e.g., convert length to seconds and compute position_secs / duration_secs, or fetch position in microseconds).

Suggested change
let progress = if length > 0.0 {
(position / length).clamp(0.0, 1.0) as f32
} else {
0.0
};
// length is in microseconds from MPRIS
let duration_secs = (length / 1_000_000.0) as f32;
// length is in microseconds from MPRIS; convert to seconds to match {{position}}
let duration_secs = (length / 1_000_000.0) as f32;
let progress = if duration_secs > 0.0 {
(position / duration_secs as f64).clamp(0.0, 1.0) as f32
} else {
0.0
};

Copilot uses AI. Check for mistakes.
Comment thread components/otto-islands/src/music.rs Outdated
Comment on lines +732 to +753
let length = Command::new("playerctl")
.args(["metadata", "mpris:length"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<f64>()
.ok()
})
.unwrap_or(1.0);

let progress = if length > 0.0 {
(position / length).clamp(0.0, 1.0) as f32
} else {
0.0
};

// length is in microseconds from MPRIS
let duration_secs = (length / 1_000_000.0) as f32;

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When playerctl metadata mpris:length fails, length falls back to 1.0, which can make progress clamp to 1.0 and duration_secs become ~0, producing misleading UI. Prefer treating missing/invalid length as 0.0 (or None) and set progress/duration_secs accordingly so the progress bar/time labels don’t jump to a completed state.

Copilot uses AI. Check for mistakes.
Comment on lines +891 to +898
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.unwrap()
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).unwrap()];

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PodSerializer::serialize(...) is followed by unwrap(). If serialization fails (unexpected PipeWire/SPA state), this will panic and kill the monitoring thread. Prefer returning the error (or logging and disabling levels) instead of panicking here.

Suggested change
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.unwrap()
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).unwrap()];
let values: Vec<u8> = match spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
) {
Ok(serialized) => serialized.0.into_inner(),
Err(err) => {
eprintln!(
"otto-islands: failed to serialize PipeWire audio format pod: {err}"
);
return Ok(());
}
};
let pod = match Pod::from_bytes(&values) {
Some(pod) => pod,
None => {
eprintln!("otto-islands: failed to parse serialized PipeWire audio format pod");
return Ok(());
}
};
let mut params = [pod];

Copilot uses AI. Check for mistakes.
.unwrap()
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).unwrap()];

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pod::from_bytes(&values).unwrap() can panic if the serialized SPA pod isn’t accepted/parseable. This should be handled as a recoverable error (return Err / log and exit the PipeWire loop) rather than crashing the thread.

Suggested change
let mut params = [Pod::from_bytes(&values).unwrap()];
let pod = match Pod::from_bytes(&values) {
Ok(pod) => pod,
Err(err) => {
eprintln!("Failed to parse PipeWire format pod: {err}");
return Ok(());
}
};
let mut params = [pod];

Copilot uses AI. Check for mistakes.
Comment thread components/otto-islands/src/main.rs Outdated
Comment on lines +341 to +389
if island.kind == IslandKind::Music {
// Use MusicActivityRenderer sizes via the ActivityRenderer trait.
return match mode {
IslandMode::Mini => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Minimal)
}
IslandMode::Compact => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Compact)
}
IslandMode::Expanded => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Expanded)
}
};

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

island_size constructs a full MusicActivityRenderer three times just to call size(), and hard-codes levels: [0.0; 8]. This is both repetitive and brittle (if BAR_COUNT changes). Consider exposing size constants or a small helper (e.g., music::music_island_size(mode)) to avoid allocating dummy renderers and duplicating the bar count.

Suggested change
if island.kind == IslandKind::Music {
// Use MusicActivityRenderer sizes via the ActivityRenderer trait.
return match mode {
IslandMode::Mini => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Minimal)
}
IslandMode::Compact => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Compact)
}
IslandMode::Expanded => {
use crate::activity::ActivityRenderer;
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: [0.0; 8],
pressed: None,
};
dummy.size(PresentationMode::Expanded)
}
};
let music_island_size = |mode: IslandMode| -> (f32, f32) {
use crate::activity::ActivityRenderer;
let presentation_mode = match mode {
IslandMode::Mini => PresentationMode::Minimal,
IslandMode::Compact => PresentationMode::Compact,
IslandMode::Expanded => PresentationMode::Expanded,
};
let dummy = music::MusicActivityRenderer {
title: String::new(),
artist: String::new(),
album_art: None,
is_playing: false,
progress: 0.0,
duration_secs: 0.0,
accent: skia_safe::Color::TRANSPARENT,
levels: std::array::from_fn(|_| 0.0),
pressed: None,
};
dummy.size(presentation_mode)
};
if island.kind == IslandKind::Music {
// Use MusicActivityRenderer sizes via the ActivityRenderer trait.
return music_island_size(mode);

Copilot uses AI. Check for mistakes.
Comment thread components/otto-islands/src/music.rs Outdated
Comment on lines +681 to +685
thread::spawn(move || loop {
let is_playing = Command::new("playerctl")
.arg("status")
.output()
.ok()

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The player monitoring loop spawns multiple playerctl subprocesses every 1.5s (status + several metadata calls). This can be relatively expensive and adds latency/jitter. Consider consolidating into a single playerctl metadata --format ... call (including status/position/title/artist/artUrl), or switching to playerctl --follow/D-Bus events to avoid repeated process creation.

Copilot uses AI. Check for mistakes.
}
let rms = (sum_sq / seen as f32).sqrt();
let normalized = (peak * 1.35 + rms * 0.65).clamp(0.0, 1.0);
if let Ok(mut level) = user_data.level.lock() {

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PipeWire .process callback runs in the RT thread (StreamFlags::RT_PROCESS), but it takes a std::sync::Mutex lock to publish normalized. Blocking locks in RT callbacks can cause glitches/xruns or priority inversion. Prefer a lock-free handoff (e.g., AtomicU32 storing f32::to_bits, a ringbuffer/channel, or try_lock with drop-on-contention) and consume/smooth on the non-RT side.

Suggested change
if let Ok(mut level) = user_data.level.lock() {
if let Ok(mut level) = user_data.level.try_lock() {

Copilot uses AI. Check for mistakes.
@nongio nongio force-pushed the feat/island-music branch 9 times, most recently from b467507 to 0c8bfa0 Compare April 4, 2026 23:14
nongio added 18 commits April 8, 2026 21:02
Add music island support to otto-islands:
- MPRIS player monitoring (play/pause, skip, track info, album art)
- PipeWire audio level monitoring for real-time EQ visualization
- Three modes: Mini (EQ bars), Compact (album art + title + controls),
  Expanded (full player with progress bar and seek)
- Interactive controls: play/pause, skip forward/back, seek via
  progress bar click
- Album art with accent color extraction for dynamic theming
- Grace period on track disappearance (3s) to avoid flicker
Add draw_region to otto-kit (SkiaSurface, BaseWaylandSurface,
SubsurfaceSurface) for clipped partial redraws with targeted damage.

Music EQ tick now uses draw_centered_region: clips to the EQ bar area,
clears only that rect, redraws the bars, and damages only the affected
physical pixels. The rest of the surface (album art, title, controls)
is untouched.
- EQ bars: partial redraw every 42ms (24fps), clipped to bar region
- Full surface (progress, controls, art): full redraw every 1s
- Both paths coexist — full redraw resets the EQ timer too
Add a child subsurface for the music EQ bars. The main pill surface
only redraws on mode/track changes. The tiny EQ subsurface redraws
at 24fps with its own buffer — no double-buffer issues since it's
a separate small surface that gets fully cleared each frame.
nongio added 18 commits April 8, 2026 21:02
- Buffer is 220x32 logical to fit expanded EQ (~210x22)
- draw_eq_only centers content in the buffer
- surface style set_size_and_position controls visible bounds per mode
- contents_gravity Center reveals the right portion of the buffer
- Main pill surface: draw_without_eq (no EQ bars, only art/title/controls)
- EQ child subsurface: draw_eq_only (compact uses 4-bar small EQ,
  expanded uses large 8-bar EQ, mini is the full content)
- EQ position snapped instantly via set_size_and_position (no animation)
- Point to local layers (has engine early-exit, change guards, noise cache)
- Remove idle_countdown from event loop dispatch timeout (only animations
  need 1ms polling, surface commits use render_requested wakeup)
- Clean up comments in layer shell update path
Move the foreign toplevel focus watcher from otto-bar into otto-kit as
a shared utility. otto-islands now tracks which app is focused and
compares it to the MPRIS player name: the music island appears when
the player app loses focus and dismisses when it regains focus.
Spotify provides https:// URLs for album art instead of file:// paths.
Add ureq to fetch remote images in load_album_art.
Split islands into right zone (priority/music) and left zone
(notifications), horizontally centered. Notifications sorted by
recency with configurable max visible (default 6). Cascading
bounce animations on reposition with staggered delays. Remove
clip-to-bounds from pill layers to preserve shadows.
Only the music island pill gets set_masks_to_bounds — notification
pills and the parent layer surface remain unclipped to preserve
their shadows.
- Layer shell container size synced via style protocol for pointer containment
- Compositor routes pointer events to parent surface for client-side hit testing
- Epicenter-based cascade delays for layout animations
- Hover grow/shrink via animate_to on subsurfaces
- New islands fade in at target position instead of sliding from origin
- Fix layer surface crash when width reaches 0
- Increase max visible notifications to 10
- Fixed layer width (900px) eliminates re-centering jumps during animations
- Island positions computed with center offset for visual centering
- Focus bias pulls content 15% toward clicked pill
- Expanded→Compact and Compact→Mini transitions animate size smoothly
- Correct border radius for expanded music pill on hover leave
- Music pill starts Compact, click toggles Expanded/Compact
- Expanding music closes other expanded islands
- Focus timeout also closes expanded islands gracefully
- Remove all last_layout resets that caused snapping instead of animating
- Keyboard leave (window focus change) animates Expanded→Compact
- Add set_focus(), clear_focus(), collapse_expanded() for centralized
  mode transitions and focus management
- Add mark_dirty() helper replacing scattered lock/dirty/drop patterns
- Add IslandMode::to_presentation_mode() eliminating repeated matches
- Add MusicActivityRenderer::mode_size() replacing 3 dummy instances
- Split handle_click into handle_card_click + handle_pill_click
- Add 14 unit tests for state CRUD, grouping, expiry, and mode mapping
@nongio nongio force-pushed the feat/island-music branch from cd59e73 to 7eab38d Compare April 8, 2026 19:06
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