island: transport buttons answer at once

- play/pause flips its icon (card and pill) the moment it's clicked; the
  clicked state is shown until the player has answered
- after play/pause, next or previous, Media is read again at 150 and
  600 ms instead of waiting for its once-a-second poll, so title, time
  and play state follow within a fraction of a second
- the cover loader checks 4 times a second instead of once
- ipc: island play-pause, the same path as a click (keybinds, tests)
This commit is contained in:
server2
2026-10-08 01:37:10 +02:00
parent e847cbde73
commit 9d6a13f876
3 changed files with 77 additions and 10 deletions
+1
View File
@@ -50,6 +50,7 @@ The installed CLI on the current desktop is older than the Amane docs: it doesn'
```sh
amane ipc call island toggle # also: open, close
amane ipc call island next # show the next player
amane ipc call island play-pause # play/pause the shown player; the icon flips at once
amane ipc call island menu output # open a dropdown: output | player | close
amane ipc call island preset Rock # apply an equalizer preset to the shown player
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
+2 -1
View File
@@ -41,7 +41,8 @@ impl Service for Art {
*Self::write() = Self { url, path };
}
thread::sleep(Duration::from_secs(1));
// 4 checks a second: after next/previous the new cover follows the title quickly
thread::sleep(Duration::from_millis(250));
}
}
}
+74 -9
View File
@@ -152,6 +152,9 @@ pub struct Island {
*/
opened_on: Option<String>,
pointer_on: Option<String>,
// a clicked play/pause, (player, playing), shown until the player has answered
playing_now: Option<(String, bool)>,
}
impl Service for Island {
@@ -166,6 +169,7 @@ impl Service for Island {
hover: HashMap::new(),
opened_on: None,
pointer_on: None,
playing_now: None,
}
}
@@ -301,7 +305,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
veiled(card(&scene), width, height, radius, reveal, &colors)
} else if pill > 0.0 {
veiled(compact(player, &art.path, levels), width, height, radius, pill, &colors)
veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors)
} else {
// mid-grow: the empty shape alone, the cheapest frames of the animation
Rectangle::new().width(0.0).height(0.0)
@@ -424,7 +428,7 @@ fn toggle_menu(menu: Menu) {
/*
* `amane ipc call island ...` for keybinds (and tests):
* open | close | toggle | next | menu output|player|close | preset <name> | output <sink name> | status
* open | close | toggle | next | play-pause | menu output|player|close | preset <name> | output <sink name> | status
*/
pub fn command(arguments: &[String]) -> String {
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
@@ -442,6 +446,18 @@ pub fn command(arguments: &[String]) -> String {
if expanded { collapse() } else { expand() }
}
["next"] => next_player(),
["play-pause"] => {
// copied out, so both locks are let go before play_pause writes the island
let player = shown(&Media::read(), selected.as_deref()).cloned();
let Some(player) = player else {
return String::from("no player");
};
let before = playing(&player, &Island::read());
play_pause(&player, before);
}
["menu", which] => {
let menu = match *which {
"output" => Some(Menu::Output),
@@ -474,7 +490,7 @@ pub fn command(arguments: &[String]) -> String {
return format!("player={identity} expanded={} menu={}", island.expanded, island.menu.is_some());
}
_ => return String::from("usage: island open|close|toggle|next|menu <output|player|close>|preset <name>|output <sink>|status"),
_ => return String::from("usage: island open|close|toggle|next|play-pause|menu <output|player|close>|preset <name>|output <sink>|status"),
}
String::from("ok")
@@ -567,7 +583,7 @@ fn faded(color: Color, alpha: u8) -> Color {
}
// the pill: cover, title, the player's live levels
fn compact(player: &MediaPlayer, art: &str, levels: &[f32]) -> Rectangle {
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
Rectangle::new()
.width(COMPACT_W)
.height(COMPACT_H)
@@ -580,7 +596,7 @@ fn compact(player: &MediaPlayer, art: &str, levels: &[f32]) -> Rectangle {
.height(COMPACT_H)
.align_child(Start, Center)
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
pill_levels(player.playing(), levels),
pill_levels(playing, levels),
])
.gap(10.0)
.align(Center),
@@ -906,12 +922,14 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
let toggle = player.clone();
let next = player.clone();
let shown = playing(player, island);
// hovered, the transport icons go bold: thicker strokes, nothing else
let lit = island.lit(Spot::PlayPause);
let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center);
let middle = if player.playing() {
let middle = if shown {
let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + BOLD * lit).radius(3.0).fill(PINK);
middle.child(Row::new(children![bar(), bar()]).gap(7.0 - BOLD * lit))
@@ -920,9 +938,15 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
};
Row::new(children![
button(Glyph::Previous, island.lit(Spot::Previous)).on_hover(hovering(Spot::Previous)).on_click(move |_| previous.previous()),
middle.cursor(Pointer).on_hover(hovering(Spot::PlayPause)).on_click(move |_| toggle.play_pause()),
button(Glyph::Next, island.lit(Spot::Next)).on_hover(hovering(Spot::Next)).on_click(move |_| next.next()),
button(Glyph::Previous, island.lit(Spot::Previous)).on_hover(hovering(Spot::Previous)).on_click(move |_| {
previous.previous();
refresh_soon(None);
}),
middle.cursor(Pointer).on_hover(hovering(Spot::PlayPause)).on_click(move |_| play_pause(&toggle, shown)),
button(Glyph::Next, island.lit(Spot::Next)).on_hover(hovering(Spot::Next)).on_click(move |_| {
next.next();
refresh_soon(None);
}),
])
.gap(26.0)
.align(Center)
@@ -931,6 +955,47 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
.height(CONTROLS_H)
}
// whether to show `player` as playing: a just-clicked play/pause wins until the player has answered
fn playing(player: &MediaPlayer, island: &Island) -> bool {
match &island.playing_now {
Some((name, playing)) if name == player.name() => *playing,
_ => player.playing(),
}
}
// the icon flips now, the player confirms a moment later; `shown` is what the island shows before
fn play_pause(player: &MediaPlayer, shown: bool) {
let flipped = (player.name().to_string(), !shown);
Island::write().playing_now = Some(flipped.clone());
player.play_pause();
refresh_soon(Some(flipped));
}
/*
* Media polls the players once a second, so a click would show up to a second late:
* read them again right after the player has had time to answer, then let the real
* state replace the clicked one (unless another click has replaced it since)
*/
fn refresh_soon(clicked: Option<(String, bool)>) {
thread::spawn(move || {
for wait in [150, 450] {
thread::sleep(Duration::from_millis(wait));
let _ = Media::write().update();
}
if clicked.is_some() {
let mut island = Island::write();
if island.playing_now == clicked {
island.playing_now = None;
}
}
});
}
// a transport icon button, going bold as `lit` rises
fn button(glyph: Glyph, lit: f32) -> Rectangle {
Rectangle::new()