diff --git a/README.md b/README.md index ad6f387..ed12872 100644 --- a/README.md +++ b/README.md @@ -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 # move the shown player to a sink (pactl sink name) diff --git a/src/art.rs b/src/art.rs index d4218ae..9aea641 100644 --- a/src/art.rs +++ b/src/art.rs @@ -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)); } } } diff --git a/src/island.rs b/src/island.rs index b917faf..9be1656 100644 --- a/src/island.rs +++ b/src/island.rs @@ -152,6 +152,9 @@ pub struct Island { */ opened_on: Option, pointer_on: Option, + + // 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 | output | status + * open | close | toggle | next | play-pause | menu output|player|close | preset | output | 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 |preset |output |status"), + _ => return String::from("usage: island open|close|toggle|next|play-pause|menu |preset |output |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()