From 93c57b93c412765c1b1c3d8925c9bf8879f7beb8 Mon Sep 17 00:00:00 2001 From: server2 Date: Tue, 6 Oct 2026 03:28:55 +0200 Subject: [PATCH] Initial --- island-eq | 1 + src.before-test/main.rs | 23 + src/art.rs | 107 ++++ src/island.rs | 1129 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 + src/parse.rs | 433 +++++++++++++++ src/sound.rs | 466 ++++++++++++++++ src/spectrum.rs | 150 ++++++ 8 files changed, 2323 insertions(+) create mode 100644 island-eq create mode 100644 src.before-test/main.rs create mode 100644 src/art.rs create mode 100644 src/island.rs create mode 100644 src/main.rs create mode 100644 src/parse.rs create mode 100644 src/sound.rs create mode 100644 src/spectrum.rs diff --git a/island-eq b/island-eq new file mode 100644 index 0000000..0b30322 --- /dev/null +++ b/island-eq @@ -0,0 +1 @@ +Cider Jazz 3.0 2.0 1.0 2.0 -1.0 -1.0 0.0 1.5 2.5 3.0 diff --git a/src.before-test/main.rs b/src.before-test/main.rs new file mode 100644 index 0000000..b0628ee --- /dev/null +++ b/src.before-test/main.rs @@ -0,0 +1,23 @@ +use amane::*; + +fn main() { + App::new().window(view).run(); +} + +fn view() -> LayerWindow { + LayerWindow::new() + .width(Full) + .height(20.0) + .anchor_vertical(Vertical::Top) + .layer(Layer::Top) + .child( + Rectangle::new() + .width(Parent) + .height(Parent) + .fill(Color::BLUE) + .align_child(End, Center) + .child( + Text::new("hello world").size(20.0).color(Color::WHITE) + ), + ) +} diff --git a/src/art.rs b/src/art.rs new file mode 100644 index 0000000..d4218ae --- /dev/null +++ b/src/art.rs @@ -0,0 +1,107 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; +use std::{env, fs, thread}; + +use amane::{Media, Service}; + +use crate::island::{self, Island}; +use crate::parse; + +/* + * the shown player's cover as a local file Image can draw: file:// links are + * decoded, https:// ones (spotify) downloaded once. a copy also goes to one + * fixed path the Palette watches, so the background glow follows the cover + */ +#[derive(Default)] +pub struct Art { + url: String, + + // empty when there is no cover + pub path: String, +} + +impl Service for Art { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + let url = shown_art_url(); + + if url != Self::read().url { + let path = local_copy(&url); + + if !path.is_empty() { + let _ = fs::copy(&path, palette_file()); + } + + *Self::write() = Self { url, path }; + } + + thread::sleep(Duration::from_secs(1)); + } + } +} + +fn shown_art_url() -> String { + let selected = Island::read().selected.clone(); + + let media = Media::read(); + + island::shown(&media, selected.as_deref()).map_or(String::new(), |player| player.art_url().to_string()) +} + +fn local_copy(url: &str) -> String { + if let Some(path) = parse::file_path(url) { + return path; + } + + if !url.starts_with("https://") && !url.starts_with("http://") { + return String::new(); + } + + let mut hasher = DefaultHasher::new(); + + url.hash(&mut hasher); + + let path = folder().join(format!("{:016x}", hasher.finish())); + + if !path.exists() { + // downloaded beside, then moved, so Image never caches a half written file + let partial = path.with_extension("part"); + + // no shell: the url comes from another program and must never run as a command + let fetched = Command::new("curl") + .args(["-sfL", "--max-time", "10", "-o"]) + .arg(&partial) + .arg(url) + .status() + .is_ok_and(|status| status.success()); + + if !fetched || fs::rename(&partial, &path).is_err() { + let _ = fs::remove_file(&partial); + + return String::new(); + } + } + + path.display().to_string() +} + +// tmpfs under XDG_RUNTIME_DIR, so downloaded covers and equalizer configs go away at logout +pub fn folder() -> PathBuf { + let base = env::var_os("XDG_RUNTIME_DIR").map_or_else(env::temp_dir, PathBuf::from); + + let folder = base.join("magic-island"); + + let _ = fs::create_dir_all(&folder); + + folder +} + +pub fn palette_file() -> String { + folder().join("palette-cover").display().to_string() +} diff --git a/src/island.rs b/src/island.rs new file mode 100644 index 0000000..4860d95 --- /dev/null +++ b/src/island.rs @@ -0,0 +1,1129 @@ +use std::f32::consts::{PI, TAU}; +use std::thread; +use std::time::{Duration, Instant}; + +use amane::{ + Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, End, Full, + Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path, + Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, + Start, Text, Vertical, Weight, Widget, Zone, children, shapes, +}; + +use crate::art::Art; +use crate::parse::{self, BANDS, Sink}; +use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; +use crate::spectrum::Spectrum; + +// sizes, in pixels +const COMPACT_W: f32 = 200.0; +const COMPACT_H: f32 = 38.0; +const WIDE_W: f32 = 560.0; +const WIDE_RADIUS: f32 = 28.0; +const PAD: f32 = 22.0; +const INNER_W: f32 = WIDE_W - 2.0 * PAD; +const GAP: f32 = 16.0; +const TOP_GAP: f32 = 6.0; +const SHADOW: f32 = 34.0; + +// the cd: its box, the cover, the hole, and how far the candles reach past it +const DISC_BOX: f32 = 204.0; +const DISC: f32 = 136.0; +const HOLE: f32 = 26.0; +const CANDLES: usize = 40; +const CANDLE_REACH: f32 = 20.0; +const FLAME: Color = Color::rgb(255, 228, 240); + +const SIDE_GAP: f32 = 20.0; +const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP; +const HEADER_H: f32 = DISC_BOX; +const CHIP_H: f32 = 24.0; +const OUTPUT_CHIP_MAX: f32 = 150.0; +const VOLUME_CHIP_W: f32 = 60.0; +const BAR_H: f32 = 8.0; +const KNOB: f32 = 16.0; +const CONTROLS_H: f32 = 44.0; + +const EQ_HEAD_H: f32 = 26.0; +const SLIDER_H: f32 = 140.0; +const TRACK_W: f32 = 10.0; +const LABEL_H: f32 = 14.0; +const EQ_H: f32 = SLIDER_H + 8.0 + LABEL_H; +const CURVE_STEPS: usize = 72; +const PRESET_H: f32 = 30.0; +const PRESET_GAP: f32 = 8.0; +const PRESET_W: f32 = (INNER_W - 3.0 * PRESET_GAP) / 4.0; +const PRESETS_H: f32 = 2.0 * PRESET_H + PRESET_GAP; + +// every section has a fixed height, so the window is sized up front +const WIDE_H: f32 = 2.0 * PAD + HEADER_H + 1.0 + EQ_HEAD_H + EQ_H + PRESETS_H + 4.0 * GAP; + +// the dropdowns open under the chips +const MENU_X: f32 = PAD + DISC_BOX + SIDE_GAP; +const MENU_Y: f32 = PAD + 92.0; +const MENU_W: f32 = SIDE_W; +const MENU_ROW_H: f32 = 30.0; +const MENU_ROWS: usize = 10; + +// letters that fit a dropdown row and the output chip, for cutting long device names in the middle +const MENU_LETTERS: usize = 30; +const CHIP_LETTERS: usize = 13; + +// covers are decoded once at this size, for both the pill and the cd +const THUMB: u32 = 256; + +// Fira Code renders about 0.64 em per letter here, so a chip can be sized to its label (plus slack) +const LETTER: f32 = 0.66; + +const PINK: Color = Color::rgb(247, 168, 200); +const PINK_DEEP: Color = Color::rgb(150, 82, 122); +const INK: Color = Color::rgb(40, 18, 32); +const TEXT: Color = Color::rgb(241, 236, 246); +const DIM: Color = Color::rgba(205, 192, 218, 160); +const CHIP: Color = Color::rgba(255, 255, 255, 18); +const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); +const TRACK: Color = Color::rgba(255, 255, 255, 24); +const EDGE: Color = Color::rgba(255, 255, 255, 26); +const SURFACE_TOP: Color = Color::rgb(30, 21, 38); +const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18); +const MENU_BG: Color = Color::rgb(35, 26, 44); + +#[derive(Clone, Copy, PartialEq)] +pub enum Menu { + Output, + Player, +} + +pub struct Island { + // 0 is the compact pill, 1 the open card; everything in between is drawn from it + open: Animation, + expanded: bool, + + // the player picked in the dropdown; none follows whichever one plays + pub selected: Option, + + menu: Option, + + // where the time bar is pressed, 0 to 1, and how long a released seek stays on screen + scrub: Option, + seek_shown_until: Option, +} + +impl Service for Island { + fn new() -> Self { + Self { + open: Animation::new(0.0).duration(Duration::from_millis(420)), + expanded: false, + selected: None, + menu: None, + scrub: None, + seek_shown_until: None, + } + } + + // changes only through input + fn listen() {} +} + +impl Island { + pub fn expanded(&self) -> bool { + self.expanded + } +} + +// the picked player while it's still open, else the one Media calls active +pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> { + let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name)); + + picked.or_else(|| media.active()) +} + +// everything one frame of the card reads +struct Scene<'a> { + player: &'a MediaPlayer, + players: &'a [MediaPlayer], + island: &'a Island, + sound: &'a Sound, + art: &'a str, + levels: &'a [f32], +} + +pub fn view() -> LayerWindow { + let media = Media::read(); + let island = Island::read(); + let sound = Sound::read(); + let art = Art::read(); + let palette = Palette::read(); + let spectrum = Spectrum::read(); + + let window_w = WIDE_W + 2.0 * SHADOW; + + let window = LayerWindow::new() + .width(window_w) + .height(TOP_GAP + WIDE_H + SHADOW) + .anchor_vertical(Vertical::Top) + // Overlay: on a desktop of fullscreen windows, Top is hidden behind every one of them + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-island"); + + let Some(player) = shown(&media, island.selected.as_deref()) else { + return window.visible(false); + }; + + let open = island.open.value(); + + /* + * a live ring keeps frames coming the way an animation does: a service change + * alone asks with an empty commit, which hyprland only answers on its next repaint + */ + if island.expanded && !spectrum.levels.is_empty() { + amane::request_frame(); + } + + let width = mix(COMPACT_W, WIDE_W, open); + let height = mix(COMPACT_H, WIDE_H, open); + + // the two faces cross-fade at the halfway point, while the shape keeps growing + let face = if open < 0.5 { + compact(player, &art.path).opacity(1.0 - 2.0 * open) + } else { + let scene = Scene { + player, + players: media.players(), + island: &island, + sound: &sound, + art: &art.path, + levels: &spectrum.levels, + }; + + // an opacity of 1 still costs a gpu layer, so the settled card has none + if open >= 1.0 { card(&scene) } else { card(&scene).opacity(2.0 * open - 1.0) } + }; + + let shape = Rectangle::new() + .width(width) + .height(height) + .radius(mix(COMPACT_H / 2.0, WIDE_RADIUS, open)) + /* + * solid, tinted toward the cover: gradients turned out to be this iGPU's most + * expensive fill (60 fps without them, 30 with), and the card is redrawn every frame + */ + .fill(tint(palette.dominant())) + .border(1.0, EDGE); + + /* + * amane redraws the whole window every frame, so big static effects are paid + * 60 times a second: the settled card goes without a drop shadow (its rim and + * tinted gradient set it apart), the pill and the morph keep theirs + */ + let shape = if open < 1.0 { shape.shadow(Shadow::drop("#000000").opacity(0.55).blur(24.0).offset(0.0, 10.0)) } else { shape }; + + /* + * the clip is only needed while the shape morphs and the face overflows it; a clip + * over the whole card took this iGPU from 47 to 9 frames a second, so the settled + * card goes without (its glows stay inside on their own) + */ + let shape = if open < 1.0 { shape.clip() } else { shape }; + + let shape = shape + .align_child(Center, Start) + .on_click(|_| tap()) + .on_hover(|inside| { + if !inside { + collapse(); + } + }) + .child(face); + + let shape = if island.expanded { shape } else { shape.cursor(Pointer) }; + + // clicks outside the island reach the windows underneath + let region = InputArea { + x: ((window_w - width) / 2.0).floor() as i32, + y: TOP_GAP as i32, + width: width.ceil() as i32, + height: height.ceil() as i32, + }; + + window.input_region(vec![region]).child( + Rectangle::new() + .width(Parent) + .height(Parent) + .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) + .align_child(Center, Start) + .child(shape), + ) +} + +// a click on the island itself: closes an open dropdown, else opens the card +fn tap() { + let mut island = Island::write(); + + if island.menu.take().is_none() { + island.expanded = true; + island.open.to(1.0); + } +} + +fn expand() { + let mut island = Island::write(); + + island.expanded = true; + island.open.to(1.0); +} + +fn collapse() { + let mut island = Island::write(); + + island.expanded = false; + island.menu = None; + island.open.to(0.0); +} + +fn toggle_menu(menu: Menu) { + let mut island = Island::write(); + + island.menu = if island.menu == Some(menu) { None } else { Some(menu) }; +} + +/* + * `amane ipc call island ...` for keybinds (and tests): + * open | close | toggle | next | menu output|player|close | preset | output | status + */ +pub fn command(arguments: &[String]) -> String { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + + let selected = Island::read().selected.clone(); + + let shown_player = shown(&Media::read(), selected.as_deref()).map(|player| (player.name().to_string(), player.identity().to_string())); + + match words.as_slice() { + ["open"] => expand(), + ["close"] => collapse(), + ["toggle"] => { + let expanded = Island::read().expanded; + + if expanded { collapse() } else { expand() } + } + ["next"] => next_player(), + ["menu", which] => { + let menu = match *which { + "output" => Some(Menu::Output), + "player" => Some(Menu::Player), + _ => None, + }; + + Island::write().menu = menu; + } + ["preset", name] => { + let Some((player, identity)) = shown_player else { + return String::from("no player"); + }; + + if !Sound::preset(&player, &identity, name) { + return format!("no preset named {name}"); + } + } + ["output", device] => { + let Some((player, identity)) = shown_player else { + return String::from("no player"); + }; + + Sound::set_output(&player, &identity, device); + } + ["status"] => { + let island = Island::read(); + + let identity = shown_player.map_or(String::from("none"), |(_, identity)| identity); + + 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"), + } + + String::from("ok") +} + +// the next player, wrapping around +fn next_player() { + let selected = Island::read().selected.clone(); + + let next = { + let media = Media::read(); + let players = media.players(); + + let current = shown(&media, selected.as_deref()) + .and_then(|player| players.iter().position(|other| other.name() == player.name())); + + current.and_then(|index| players.get((index + 1) % players.len())).map(|player| player.name().to_string()) + }; + + Island::write().selected = next; +} + +fn mix(from: f32, to: f32, amount: f32) -> f32 { + from + (to - from) * amount +} + +fn faded(color: Color, alpha: u8) -> Color { + Color::rgba(color.red(), color.green(), color.blue(), alpha) +} + +// the pill: cover, title, level bars +fn compact(player: &MediaPlayer, art: &str) -> Rectangle { + Rectangle::new() + .width(COMPACT_W) + .height(COMPACT_H) + .padding(Padding { top: 0.0, right: 14.0, bottom: 0.0, left: 6.0 }) + .child( + Row::new(children![ + cover(art, 26.0, 1.5, false), + Rectangle::new() + .width(Parent) + .height(COMPACT_H) + .align_child(Start, Center) + .child(Text::new(title(player)).size(12.0).color(TEXT).elide()), + levels(player.playing()), + ]) + .gap(10.0) + .align(Center), + ) +} + +fn card(scene: &Scene) -> Rectangle { + let mut layers: Vec> = vec![Box::new(content(scene))]; + + if let Some(menu) = scene.island.menu { + layers.push(Box::new(menu_panel(scene, menu))); + } + + Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H)) +} + +// the surface colour leaning a little toward the cover's +fn tint(color: Color) -> Color { + let lean = |surface: u8, cover: u8| (f32::from(surface) * 0.7 + f32::from(cover) * 0.3) as u8; + + Color::rgb(lean(SURFACE_TOP.red(), color.red()), lean(SURFACE_TOP.green(), color.green()), lean(SURFACE_TOP.blue(), color.blue())) +} + +fn content(scene: &Scene) -> Rectangle { + Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child( + Column::new(children![ + Row::new(children![disc(scene), side(scene)]).gap(SIDE_GAP).align(Center).width(INNER_W).height(HEADER_H), + Rectangle::new().width(INNER_W).height(1.0).fill(EDGE), + eq_header(scene), + equalizer(scene), + presets(scene), + ]) + .gap(GAP), + ) +} + +// the cd: a still cover with a hole, inside a ring of candles that move with this player's sound +fn disc(scene: &Scene) -> Stack { + let offset = (DISC_BOX - DISC) / 2.0; + + let player = scene.player.name().to_string(); + let volume = scene.sound.channel(scene.player.name()).map_or(100, |channel| channel.volume); + + let cd = Rectangle::new() + .width(DISC) + .height(DISC) + .radius(Full) + .fill(PINK) + .shadow(Shadow::drop(PINK).opacity(0.35).blur(18.0)) + .align_child(Center, Center) + .translate(offset, offset) + // scroll over the cd for this player's volume + .on_scroll(move |scroll| Sound::set_volume(&player, stepped(volume, scroll))) + .child(cover(scene.art, DISC - 6.0, 0.0, true)); + + Stack::new(children![ring(scene.levels), cd]).width(DISC_BOX).height(DISC_BOX) +} + +fn stepped(volume: u8, scroll: Scroll) -> u8 { + let step = if scroll.y < 0.0 { 5 } else { -5 }; + + (i32::from(volume) + step).clamp(0, 100) as u8 +} + +/* + * every candle goes into one path per look (glow, body, flame) instead of a + * shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing + */ +fn ring(levels: &[f32]) -> Canvas { + let center = DISC_BOX / 2.0; + let inner = DISC / 2.0 + 7.0; + + let mut glow = Path::new(); + let mut body = Path::new(); + let mut flames = Path::new(); + + for index in 0..CANDLES { + // a gentle curve lifts the quiet bands, so the ring isn't all bass + let level = mirrored(levels, index).powf(0.75); + + let (sin, cos) = (index as f32 / CANDLES as f32 * TAU).sin_cos(); + + let reach = inner + 3.0 + level * CANDLE_REACH; + + let (x0, y0) = (center + sin * inner, center - cos * inner); + let (x1, y1) = (center + sin * reach, center - cos * reach); + + body = body.move_to(x0, y0).line_to(x1, y1); + + // the loud ones glow, and burn at the tip + if level > 0.25 { + glow = glow.move_to(x0, y0).line_to(x1, y1); + } + + if level > 0.45 { + flames = flames.move_to(x1 - sin * 0.5, y1 + cos * 0.5).line_to(x1, y1); + } + } + + Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![ + glow.stroke(9.0, faded(PINK, 50)).cap(Cap::Round), + body.stroke(4.5, faded(PINK, 215)).cap(Cap::Round), + flames.stroke(4.0, FLAME).cap(Cap::Round), + ]) +} + +// low frequencies at the top, rising down both sides, so the ring is symmetric +fn mirrored(levels: &[f32], index: usize) -> f32 { + if levels.is_empty() { + return 0.0; + } + + let half = CANDLES / 2; + let step = if index < half { index } else { CANDLES - 1 - index }; + + levels[(step * levels.len() / half).min(levels.len() - 1)] +} + +fn side(scene: &Scene) -> Column { + let player = scene.player; + + let artist = if player.artist().is_empty() { player.identity() } else { player.artist() }; + + Column::new(children![ + Column::new(children![ + Text::new(title(player)).size(19.0).weight(Weight::Bold).color(TEXT).elide(), + Text::new(format!("BY {artist}")).size(12.0).color(DIM).elide(), + chips(scene), + ]) + .gap(8.0) + .width(SIDE_W), + timeline(scene), + controls(player), + ]) + .justify(SpaceBetween) + .width(SIDE_W) + .height(HEADER_H) +} + +// output device and player, each opening its dropdown +fn chips(scene: &Scene) -> Row { + let channel = scene.sound.channel(scene.player.name()); + let device = channel.and_then(|channel| channel.output.as_ref()); + + let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), CHIP_LETTERS)); + let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, OUTPUT_CHIP_MAX); + + let player_label = format!("VIA {}", scene.player.identity()); + let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, SIDE_W - output_w - 8.0); + + let menu = scene.island.menu; + + let output = Rectangle::new() + .width(output_w) + .height(CHIP_H) + .radius(6.0) + .fill(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }) + .padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 }) + .align_child(Start, Center) + .cursor(Pointer) + .on_click(|_| toggle_menu(Menu::Output)) + .child( + Row::new(children![ + icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK), + Text::new(output_label).size(11.0).color(TEXT).elide(), + icon(Glyph::Chevron, 8.0, DIM), + ]) + .gap(6.0) + .align(Center), + ); + + let player = Rectangle::new() + .width(player_w) + .height(CHIP_H) + .radius(6.0) + .fill(if menu == Some(Menu::Player) { CHIP_OPEN } else { Color::TRANSPARENT }) + .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) + .align_child(Start, Center) + .cursor(Pointer) + .on_click(|_| toggle_menu(Menu::Player)) + .child( + Row::new(children![Text::new(player_label).size(11.0).color(DIM).elide(), icon(Glyph::Chevron, 8.0, DIM)]) + .gap(6.0) + .align(Center), + ); + + Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H) +} + +// a monospace label's width plus whatever surrounds it +fn label_width(label: &str, size: f32, extra: f32) -> f32 { + label.chars().count() as f32 * size * LETTER + extra +} + +fn timeline(scene: &Scene) -> Column { + let player = scene.player; + let island = scene.island; + + let length = player.length().as_secs_f32(); + let position = player.position().as_secs_f32(); + + // a pressed or just-released bar shows where it was pressed, not the last poll + let scrubbing = island.seek_shown_until.is_none_or(|until| Instant::now() < until); + let polled = if length > 0.0 { position / length } else { 0.0 }; + + let fraction = island.scrub.filter(|_| scrubbing).unwrap_or(polled).clamp(0.0, 1.0); + + // a live stream has no length to scale by, so it just counts up + let shown = if length > 0.0 { fraction * length } else { position }; + + let bar = Rectangle::new().width(SIDE_W).height(KNOB).align_child(Start, Center).child( + Stack::new(children![ + Rectangle::new().width(SIDE_W).height(BAR_H).radius(Full).fill(TRACK).child( + Rectangle::new() + .width((SIDE_W * fraction).max(BAR_H)) + .height(BAR_H) + .radius(Full) + .fill(PINK), + ), + Rectangle::new() + .width(KNOB) + .height(KNOB) + .radius(Full) + .fill(Color::WHITE) + .shadow(Shadow::drop("#000000").opacity(0.4).blur(6.0)) + .translate(SIDE_W * fraction - KNOB / 2.0, (BAR_H - KNOB) / 2.0), + ]) + .width(SIDE_W) + .height(BAR_H), + ); + + // live streams have no length, so there is nowhere to seek to + let bar = if length > 0.0 { + let name = player.name().to_string(); + + bar.cursor(Pointer) + .on_hover(leave_bar) + .on_drag(|point| { + let mut island = Island::write(); + + island.scrub = Some((point.x / SIDE_W).clamp(0.0, 1.0)); + island.seek_shown_until = None; + }) + .on_click(move |_| seek(&name, length)) + } else { + bar + }; + + let total = if length > 0.0 { clock(length) } else { String::from("LIVE") }; + + Column::new(children![ + bar, + Row::new(children![Text::new(clock(shown)).size(11.0).color(DIM), Text::new(total).size(11.0).color(DIM)]) + .justify(SpaceBetween) + .width(SIDE_W), + ]) + .gap(4.0) + .width(SIDE_W) +} + +fn leave_bar(inside: bool) { + let mut island = Island::write(); + + // dragged off the bar and let go elsewhere: no seek, back to the real position + if !inside && island.seek_shown_until.is_none() { + island.scrub = None; + } +} + +// runs on its own thread: d-bus waits for the player, and handlers run on the drawing thread +fn seek(player: &str, length: f32) { + let fraction = { + let mut island = Island::write(); + + let Some(fraction) = island.scrub else { + return; + }; + + island.seek_shown_until = Some(Instant::now() + Duration::from_millis(1500)); + + fraction + }; + + let player = player.to_string(); + + thread::spawn(move || { + let bus = Bus::session(); + + let path = "/org/mpris/MediaPlayer2"; + let interface = "org.mpris.MediaPlayer2.Player"; + + // mpris counts microseconds; Seek moves relative to where the player is right now + let now = bus.property(&player, path, interface, "Position").number(); + let target = f64::from(fraction) * f64::from(length) * 1_000_000.0; + + bus.call(&player, path, interface, "Seek", &[Argument::Long((target - now) as i64)]); + }); +} + +fn clock(seconds: f32) -> String { + let seconds = seconds.max(0.0) as u64; + + if seconds >= 3600 { + return format!("{}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60); + } + + format!("{:02}:{:02}", seconds / 60, seconds % 60) +} + +fn controls(player: &MediaPlayer) -> Row { + let previous = player.clone(); + let toggle = player.clone(); + let next = player.clone(); + + // pause is two tall pink bars, play a pink triangle + let middle = if player.playing() { + let bar = || Rectangle::new().width(10.0).height(30.0).radius(3.0).fill(PINK); + + Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(Row::new(children![bar(), bar()]).gap(7.0)) + } else { + Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(icon(Glyph::Play, 30.0, PINK)) + }; + + Row::new(children![ + button(icon(Glyph::Previous, 18.0, TEXT)).on_click(move |_| previous.previous()), + middle.cursor(Pointer).on_click(move |_| toggle.play_pause()), + button(icon(Glyph::Next, 18.0, TEXT)).on_click(move |_| next.next()), + ]) + .gap(26.0) + .align(Center) + .justify(Center) + .width(SIDE_W) + .height(CONTROLS_H) +} + +fn button(glyph: Canvas) -> Rectangle { + Rectangle::new().width(40.0).height(40.0).radius(Full).align_child(Center, Center).cursor(Pointer).child(glyph) +} + +fn eq_header(scene: &Scene) -> Row { + let eq = scene.sound.eq(scene.player.identity()); + + let state = if scene.sound.unsaved() { "Saving" } else { "Saved" }; + + let mut right: Vec> = Vec::new(); + + // the player's own volume sits with its mix: scroll to change, click to mute + if let Some(channel) = scene.sound.channel(scene.player.name()).filter(|channel| !channel.streams.is_empty()) { + let scrolling = scene.player.name().to_string(); + let muting = scene.player.name().to_string(); + let volume = channel.volume; + + let label = if channel.muted { String::from("mute") } else { format!("{}%", volume.min(100)) }; + + right.push(Box::new( + Rectangle::new() + .width(VOLUME_CHIP_W) + .height(22.0) + .radius(6.0) + .fill(CHIP) + .align_child(Center, Center) + .cursor(Pointer) + .on_scroll(move |scroll| Sound::set_volume(&scrolling, stepped(volume, scroll))) + .on_click(move |_| Sound::toggle_mute(&muting)) + .child( + Row::new(children![ + icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, DIM), + Text::new(label).size(11.0).color(TEXT), + ]) + .gap(5.0) + .align(Center), + ), + )); + } + + right.push(Box::new( + Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(CHIP).align_child(Center, Center).child(Text::new(state).size(11.0).color(DIM)), + )); + + right.push(Box::new(Text::new(eq.preset).size(12.0).color(TEXT))); + + Row::new(children![Text::new("Equalizer").size(15.0).color(PINK), Row::new(right).gap(12.0).align(Center)]) + .justify(SpaceBetween) + .align(Center) + .width(INNER_W) + .height(EQ_HEAD_H) +} + +// the knob's centre for a gain, from the top of the slider +fn knob_y(gain: f32) -> f32 { + KNOB / 2.0 + (1.0 - (gain + MAX_GAIN) / (2.0 * MAX_GAIN)) * (SLIDER_H - KNOB) +} + +fn gain_at(y: f32) -> f32 { + (1.0 - (y - KNOB / 2.0) / (SLIDER_H - KNOB)) * 2.0 * MAX_GAIN - MAX_GAIN +} + +// ten vertical sliders, a glowing curve through their knobs, and the bands' names +fn equalizer(scene: &Scene) -> Column { + let gains = scene.sound.eq(scene.player.identity()).gains; + + let column = INNER_W / BANDS.len() as f32; + + let mut tracks: Vec> = Vec::new(); + let mut knobs: Vec> = Vec::new(); + let mut labels: Vec> = Vec::new(); + + for (band, (frequency, gain)) in BANDS.iter().zip(gains).enumerate() { + let y = knob_y(gain); + + tracks.push(Box::new( + Rectangle::new().width(column).height(SLIDER_H).align_child(Center, Start).child( + Rectangle::new() + .width(TRACK_W) + .height(SLIDER_H) + .radius(TRACK_W / 2.0) + .fill(TRACK) + .align_child(Center, End) + .child( + // rounded itself rather than clipped by the track: clips are gpu layers + Rectangle::new() + .width(TRACK_W) + .height(SLIDER_H - y) + .radius(TRACK_W / 2.0) + .fill(PINK), + ), + ), + )); + + let player = scene.player.name().to_string(); + let identity = scene.player.identity().to_string(); + + knobs.push(Box::new( + Rectangle::new() + .width(column) + .height(SLIDER_H) + .align_child(Center, Start) + .cursor(Pointer) + .on_drag(move |point| Sound::set_band(&player, &identity, band, gain_at(point.y))) + .child( + // a pink ring instead of a blurred shadow: ten blurs per frame cost the gpu more than the rest of the card + Rectangle::new() + .width(KNOB) + .height(KNOB) + .radius(Full) + .fill(Color::WHITE) + .border(2.0, faded(PINK, 150)) + .translate(0.0, y - KNOB / 2.0), + ), + )); + + let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() }; + + labels.push(Box::new( + Rectangle::new().width(column).height(LABEL_H).align_child(Center, Center).child(Text::new(label).size(10.0).color(DIM)), + )); + } + + Column::new(children![ + Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(INNER_W).height(SLIDER_H), + Row::new(labels), + ]) + .gap(8.0) +} + +// the curve through the knobs, shivering with the player's sound +fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { + let column = INNER_W / BANDS.len() as f32; + + let points: Vec<(f32, f32)> = (0..=CURVE_STEPS) + .map(|step| { + let along = step as f32 / CURVE_STEPS as f32; + + // cosine blend between neighbouring knobs keeps the curve round + let place = along * (BANDS.len() - 1) as f32; + let band = (place.floor() as usize).min(BANDS.len() - 2); + let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0; + + let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased); + + let level = if levels.is_empty() { 0.0 } else { levels[((along * (levels.len() - 1) as f32).round() as usize).min(levels.len() - 1)] }; + + let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 }; + + (column / 2.0 + along * (INNER_W - column), y + shiver) + }) + .collect(); + + let stroke = |width: f32, alpha: u8| { + let mut path = Path::new().move_to(points[0].0, points[0].1); + + for (x, y) in &points[1..] { + path = path.line_to(*x, *y); + } + + path.stroke(width, faded(PINK, alpha)).cap(Cap::Round) + }; + + // a wide faint stroke under a thin bright one reads as a glow + Canvas::new().width(INNER_W).height(SLIDER_H).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)]) +} + +fn presets(scene: &Scene) -> Column { + let current = scene.sound.eq(scene.player.identity()).preset; + + let mut rows: Vec> = Vec::new(); + + for row in EQ_PRESETS.chunks(4) { + let mut buttons: Vec> = Vec::new(); + + for (name, _) in row { + let chosen = current == *name; + + let player = scene.player.name().to_string(); + let identity = scene.player.identity().to_string(); + + buttons.push(Box::new( + Rectangle::new() + .width(PRESET_W) + .height(PRESET_H) + .radius(8.0) + .fill(if chosen { PINK } else { CHIP }) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| { + Sound::preset(&player, &identity, name); + }) + .child( + Text::new(*name) + .size(12.0) + .weight(if chosen { Weight::Bold } else { Weight::Regular }) + .color(if chosen { INK } else { TEXT }), + ), + )); + } + + rows.push(Box::new(Row::new(buttons).gap(PRESET_GAP))); + } + + Column::new(rows).gap(PRESET_GAP) +} + +// the dropdown of output devices or of players +fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { + let mut rows: Vec> = Vec::new(); + + match menu { + Menu::Output => { + let current = scene.sound.channel(scene.player.name()).and_then(|channel| channel.output.as_ref()).map(|sink| sink.name.clone()); + + for sink in scene.sound.outputs() { + let player = scene.player.name().to_string(); + let identity = scene.player.identity().to_string(); + let device = sink.name.clone(); + + let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; + + rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current.as_deref() == Some(sink.name.as_str()), glyph, move || { + Sound::set_output(&player, &identity, &device); + }))); + } + } + Menu::Player => { + for other in scene.players { + let name = other.name().to_string(); + + let glyph = if other.playing() { Glyph::Play } else { Glyph::Pause }; + + rows.push(Box::new(menu_row(other.identity(), other.name() == scene.player.name(), glyph, move || { + Island::write().selected = Some(name.clone()); + }))); + } + } + } + + let height = rows.len().clamp(1, MENU_ROWS) as f32 * MENU_ROW_H + 12.0; + + Rectangle::new() + .width(MENU_W) + .height(height) + .radius(10.0) + .fill(MENU_BG) + .border(1.0, EDGE) + .shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0)) + .padding(6.0) + .clip() + .translate(MENU_X, MENU_Y) + .child(ScrollArea::new("island-menu", Column::new(rows))) +} + +fn sink_label(sink: &Sink) -> &str { + if sink.description.is_empty() { &sink.name } else { &sink.description } +} + +fn menu_row(label: &str, current: bool, glyph: Glyph, choose: impl Fn() + 'static) -> Rectangle { + Rectangle::new() + .width(Parent) + .height(MENU_ROW_H) + .radius(6.0) + .fill(if current { CHIP } else { Color::TRANSPARENT }) + .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) + .align_child(Start, Center) + .cursor(Pointer) + .on_click(move |_| { + choose(); + + Island::write().menu = None; + }) + .child( + Row::new(children![ + icon(glyph, 11.0, if current { PINK } else { DIM }), + Text::new(label).size(12.0).color(if current { PINK } else { TEXT }).elide(), + ]) + .gap(9.0) + .align(Center), + ) +} + +// some players send no title, a bare stream for example +fn title(player: &MediaPlayer) -> &str { + if player.title().is_empty() { player.identity() } else { player.title() } +} + +// a round cover with a pink rim, optionally with a cd hole; a soft gradient while there is none or it decodes +fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle { + let frame = Rectangle::new() + .width(size) + .height(size) + .radius(Full) + .fill(PINK_DEEP) + .align_child(Center, Center); + + let frame = if rim > 0.0 { frame.border(rim, PINK) } else { frame }; + + let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center); + + let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(THUMB, THUMB)) }; + + // a rectangle holds one child, so the hole sits on the picture, not beside it + let picture = if hole { + picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(SURFACE_BOTTOM).border(2.0, EDGE)) + } else { + picture + }; + + frame.child(picture) +} + +// four bars while playing, a small pause sign otherwise (flat bars read as a "⋯" menu) +fn levels(playing: bool) -> Canvas { + if !playing { + return icon(Glyph::Pause, 13.0, DIM); + } + + let mut bars: Vec> = Vec::new(); + + for (index, height) in [6.0, 13.0, 9.0, 11.0].into_iter().enumerate() { + let x = 2.5 + index as f32 * 5.0; + + let bar = Line::new().from(x, 8.0 - height / 2.0).to(x, 8.0 + height / 2.0); + + bars.push(Box::new(bar.stroke(3.0, PINK).cap(Cap::Round))); + } + + Canvas::new().width(20.0).height(16.0).shapes(bars) +} + +#[derive(Clone, Copy)] +enum Glyph { + Play, + Pause, + Previous, + Next, + Speaker, + Muted, + Bluetooth, + Chevron, +} + +// drawn, not typed, so the island looks the same whatever fonts are installed +fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { + let s = size; + + let triangle = |x0: f32, x1: f32| { + Path::new().move_to(x0, 0.14 * s).line_to(x1, 0.5 * s).line_to(x0, 0.86 * s).close().fill(color) + }; + + let bar = |x0: f32, x1: f32| { + Path::new() + .move_to(x0, 0.14 * s) + .line_to(x1, 0.14 * s) + .line_to(x1, 0.86 * s) + .line_to(x0, 0.86 * s) + .close() + .fill(color) + }; + + let speaker = || { + Path::new() + .move_to(0.08 * s, 0.36 * s) + .line_to(0.28 * s, 0.36 * s) + .line_to(0.54 * s, 0.14 * s) + .line_to(0.54 * s, 0.86 * s) + .line_to(0.28 * s, 0.64 * s) + .line_to(0.08 * s, 0.64 * s) + .close() + .fill(color) + }; + + let thin = (0.12 * s).max(1.2); + + let shapes = match glyph { + Glyph::Play => shapes![triangle(0.2 * s, 0.9 * s)], + Glyph::Pause => shapes![bar(0.18 * s, 0.42 * s), bar(0.58 * s, 0.82 * s)], + Glyph::Next => shapes![triangle(0.08 * s, 0.7 * s), bar(0.74 * s, 0.9 * s)], + Glyph::Previous => shapes![triangle(0.92 * s, 0.3 * s), bar(0.1 * s, 0.26 * s)], + Glyph::Speaker => shapes![ + speaker(), + Arc::new().center(0.56 * s, 0.5 * s).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round), + ], + Glyph::Muted => shapes![ + speaker(), + Line::new().from(0.68 * s, 0.36 * s).to(0.94 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round), + Line::new().from(0.94 * s, 0.36 * s).to(0.68 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round), + ], + Glyph::Bluetooth => shapes![ + Path::new() + .move_to(0.28 * s, 0.3 * s) + .line_to(0.72 * s, 0.68 * s) + .line_to(0.5 * s, 0.9 * s) + .line_to(0.5 * s, 0.1 * s) + .line_to(0.72 * s, 0.32 * s) + .line_to(0.28 * s, 0.7 * s) + .stroke(thin, color) + .cap(Cap::Round) + ], + Glyph::Chevron => shapes![ + Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round) + ], + }; + + Canvas::new().width(size).height(size).shapes(shapes) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..101273a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,14 @@ +mod art; +mod island; +mod parse; +mod sound; +mod spectrum; + +use amane::{App, Palette, Service}; + +fn main() { + // the card's glow follows the cover art, which art::Art copies to this one path + Palette::write().open(&art::palette_file(), 8); + + App::new().font("Fira Code").ipc("island", island::command).window(island::view).run(); +} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..a576a89 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,433 @@ +// plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own + +// the equalizer's bands, in Hz +pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; + +// node names of the island's own equalizer sinks start with this +pub const EQ_PREFIX: &str = "magic_island_eq."; + +// one sound stream from `LC_ALL=C pactl list sink-inputs` +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Stream { + pub id: u32, + + // the sink it plays into + pub sink: u32, + + // first channel, in percent; pactl allows more than 100 + pub volume: u8, + + pub muted: bool, + + // application.process.id, 0 when the stream doesn't say + pub pid: u32, + + // application.process.binary and application.name, lowercased for matching + pub binary: String, + pub name: String, + + // node.name, which tells the island's own equalizer streams apart + pub node_name: String, +} + +// one output device from `LC_ALL=C pactl list sinks` +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Sink { + pub index: u32, + pub name: String, + pub description: String, + pub bluetooth: bool, +} + +pub fn sink_inputs(text: &str) -> Vec { + let mut streams: Vec = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + + if let Some(id) = line.strip_prefix("Sink Input #") { + streams.push(Stream { id: id.parse().unwrap_or(0), ..Stream::default() }); + + continue; + } + + let Some(stream) = streams.last_mut() else { + continue; + }; + + if let Some(sink) = line.strip_prefix("Sink: ") { + stream.sink = sink.parse().unwrap_or(0); + } else if let Some(mute) = line.strip_prefix("Mute: ") { + stream.muted = mute == "yes"; + } else if let Some(volume) = line.strip_prefix("Volume: ") { + stream.volume = first_percent(volume); + } else if let Some((key, value)) = line.split_once(" = ") { + let value = value.trim_matches('"'); + + match key { + "application.process.id" => stream.pid = value.parse().unwrap_or(0), + "application.process.binary" => stream.binary = value.to_lowercase(), + "application.name" => stream.name = value.to_lowercase(), + "node.name" => stream.node_name = value.to_string(), + _ => {} + } + } + } + + streams +} + +pub fn sinks(text: &str) -> Vec { + let mut sinks: Vec = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + + if let Some(index) = line.strip_prefix("Sink #") { + sinks.push(Sink { index: index.parse().unwrap_or(0), ..Sink::default() }); + + continue; + } + + let Some(sink) = sinks.last_mut() else { + continue; + }; + + if let Some(name) = line.strip_prefix("Name: ") { + sink.name = name.to_string(); + } else if let Some(description) = line.strip_prefix("Description: ") { + sink.description = description.to_string(); + } else if line == "device.api = \"bluez5\"" { + sink.bluetooth = true; + } + } + + sinks +} + +// "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80 +fn first_percent(volume: &str) -> u8 { + let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%')); + + percent.and_then(|part| part.parse::().ok()).map_or(0, |part| part.min(255) as u8) +} + +// one frame of cava's raw ascii output, "12;80;3;" with ascii_max_range 100, as 0 to 1 +pub fn levels(line: &str) -> Vec { + line.split(';') + .filter_map(|value| value.trim().parse::().ok()) + .map(|value| (value / 100.0).clamp(0.0, 1.0)) + .collect() +} + +/* + * the streams a player owns: the ones its process (or a child, like a browser's + * audio process) plays, else the ones named after it, for sandboxes that hide pids + */ +pub fn owned<'a>( + streams: &'a [Stream], + player_pid: u32, + app: &str, + ancestry: impl Fn(u32) -> Vec, +) -> Vec<&'a Stream> { + let by_process: Vec<&Stream> = streams + .iter() + .filter(|stream| player_pid > 1 && stream.pid > 1 && ancestry(stream.pid).contains(&player_pid)) + .collect(); + + if !by_process.is_empty() || app.is_empty() { + return by_process; + } + + streams + .iter() + .filter(|stream| stream.binary.contains(app) || stream.name.contains(app)) + .collect() +} + +// "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium" +pub fn app_of(bus_name: &str) -> String { + let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2."); + + rest.split('.').next().unwrap_or("").to_lowercase() +} + +// "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium_instance123", safe in node names and paths +pub fn key_of(bus_name: &str) -> String { + let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2."); + + rest.chars().map(|letter| if letter.is_ascii_alphanumeric() { letter } else { '_' }).collect() +} + +// the parent pid in /proc//stat; the name in parentheses may hold spaces and ')' +pub fn parent_of(stat: &str) -> Option { + let after_name = &stat[stat.rfind(')')? + 1..]; + + after_name.split_whitespace().nth(1)?.parse().ok() +} + +// "file:///home/me/My%20Music/cover.jpg" gives "/home/me/My Music/cover.jpg" +pub fn file_path(url: &str) -> Option { + let bytes = url.strip_prefix("file://")?.as_bytes(); + + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + let escaped = bytes + .get(index + 1..index + 3) + .and_then(|hex| std::str::from_utf8(hex).ok()) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + + match (bytes[index], escaped) { + (b'%', Some(byte)) => { + decoded.push(byte); + index += 3; + } + (byte, _) => { + decoded.push(byte); + index += 1; + } + } + } + + Some(String::from_utf8_lossy(&decoded).into_owned()) +} + +// what may go inside a quoted pipewire config string: player names come from other programs +fn config_text(text: &str) -> String { + text.chars().filter(|letter| letter.is_alphanumeric() || " .-_:".contains(*letter)).collect() +} + +/* + * cuts the middle, not the end: "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output" + * and its "... 2 Output" sibling only differ at the end + */ +pub fn middle_ellipsis(label: &str, max: usize) -> String { + let letters: Vec = label.chars().collect(); + + if letters.len() <= max || max < 3 { + return label.to_string(); + } + + let head = (max - 1) / 2; + let tail = max - 1 - head; + + letters[..head].iter().chain(['…'].iter()).chain(letters[letters.len() - tail..].iter()).collect() +} + +/* + * a standalone `pipewire -c` config: one 10-band filter-chain sink named + * magic_island_eq., whose output plays into `target` (empty: the default) + */ +pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) -> String { + let mut nodes = String::new(); + + for (index, (band, gain)) in BANDS.iter().zip(gains).enumerate() { + let (label, q) = match index { + 0 => ("bq_lowshelf", 0.7), + 9 => ("bq_highshelf", 0.7), + _ => ("bq_peaking", 1.4), + }; + + nodes += &format!( + " {{ type = builtin name = b{} label = {label} control = {{ \"Freq\" = {band}.0 \"Q\" = {q} \"Gain\" = {gain:.1} }} }}\n", + index + 1 + ); + } + + let links: String = (1..BANDS.len()) + .map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1)) + .collect(); + + let target = config_text(target); + let target = if target.is_empty() { String::new() } else { format!("target.object = \"{target}\"") }; + + format!( + "context.spa-libs = {{ audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }} +context.modules = [ + {{ name = libpipewire-module-rt args = {{ }} flags = [ ifexists nofail ] }} + {{ name = libpipewire-module-protocol-native }} + {{ name = libpipewire-module-client-node }} + {{ name = libpipewire-module-adapter }} + {{ name = libpipewire-module-filter-chain + args = {{ + node.description = \"{description} EQ\" + media.name = \"{description} EQ\" + filter.graph = {{ + nodes = [ +{nodes} ] + links = [ +{links} ] + }} + audio.channels = 2 + audio.position = [ FL FR ] + capture.props = {{ node.name = \"{EQ_PREFIX}{key}\" media.class = Audio/Sink }} + playback.props = {{ node.name = \"{EQ_PREFIX}{key}.out\" node.passive = true {target} }} + }} + }} +] +", + description = config_text(description), + ) +} + +// the `pw-cli set-param Props` value that sets every band at once +pub fn eq_params(gains: &[f32; 10]) -> String { + let params: Vec = gains.iter().enumerate().map(|(index, gain)| format!("\"b{}:Gain\" {gain:.1}", index + 1)).collect(); + + format!("{{ params = [ {} ] }}", params.join(" ")) +} + +// saved equalizers, one per line: identity, preset name, ten gains +pub fn eq_store(text: &str) -> Vec<(String, String, [f32; 10])> { + let mut saved = Vec::new(); + + for line in text.lines() { + let mut fields = line.split('\t'); + + let (Some(identity), Some(preset), Some(gains)) = (fields.next(), fields.next(), fields.next()) else { + continue; + }; + + let values: Vec = gains.split_whitespace().filter_map(|gain| gain.parse().ok()).collect(); + + if let Ok(gains) = <[f32; 10]>::try_from(values) { + saved.push((identity.to_string(), preset.to_string(), gains)); + } + } + + saved +} + +pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String { + let clean = |text: &str| text.replace(['\t', '\n'], " "); + + let gains: Vec = gains.iter().map(|gain| format!("{gain:.1}")).collect(); + + format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACTL: &str = "Sink Input #42 +\tDriver: PipeWire +\tSink: 65 +\tFormat: pcm, format.sample_format = \"\\\"float32le\\\"\" format.rate = \"48000\" +\tMute: no +\tVolume: front-left: 52429 / 80% / -5.81 dB, front-right: 52429 / 80% / -5.81 dB +\t balance 0.00 +\tSink Latency: 0 usec +\tProperties: +\t\tapplication.name = \"Chromium\" +\t\tapplication.process.id = \"5003\" +\t\tapplication.process.binary = \"chrome\" +\t\tnode.name = \"Chromium\" + +Sink Input #57 +\tSink: 3479 +\tMute: yes +\tVolume: mono: 98304 / 150% / 10.57 dB +\tProperties: +\t\tapplication.name = \"spotify\" +\t\tapplication.process.binary = \"spotify\" +"; + + const SINKS: &str = "Sink #65 +\tState: RUNNING +\tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo +\tDescription: Yamaha AG03MK2 Analog Stereo +\tProperties: +\t\tdevice.api = \"alsa\" +\tPorts: +\t\tanalog-output: Analog Output (type: Line, priority: 9900, availability unknown) + +Sink #80 +\tName: bluez_output.00_1B_66_AA_BB_CC.1 +\tDescription: JBL Tune 720BT +\tProperties: +\t\tdevice.api = \"bluez5\" +"; + + #[test] + fn reads_streams() { + let streams = sink_inputs(PACTL); + + assert_eq!(streams.len(), 2); + assert_eq!((streams[0].id, streams[0].sink, streams[0].volume, streams[0].muted), (42, 65, 80, false)); + assert_eq!((streams[0].pid, streams[0].binary.as_str(), streams[0].node_name.as_str()), (5003, "chrome", "Chromium")); + assert_eq!((streams[1].id, streams[1].sink, streams[1].volume, streams[1].muted), (57, 3479, 150, true)); + assert_eq!((streams[1].pid, streams[1].name.as_str()), (0, "spotify")); + } + + #[test] + fn reads_sinks() { + let sinks = sinks(SINKS); + + assert_eq!(sinks.len(), 2); + assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false)); + assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo"); + assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true)); + } + + #[test] + fn matches_children_then_names() { + let streams = sink_inputs(PACTL); + + // chrome's audio process 5003 is a child of the browser 5000 that owns the mpris name + let ancestry = |pid: u32| if pid == 5003 { vec![5003, 5000, 1] } else { vec![pid] }; + + let chromium = owned(&streams, 5000, "chromium", ancestry); + assert_eq!(chromium.iter().map(|stream| stream.id).collect::>(), [42]); + + // no pid on spotify's stream, so its name decides + let spotify = owned(&streams, 777, "spotify", ancestry); + assert_eq!(spotify.iter().map(|stream| stream.id).collect::>(), [57]); + + assert!(owned(&streams, 9999, "mpv", ancestry).is_empty()); + } + + #[test] + fn reads_names_paths_and_levels() { + assert_eq!(app_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium"); + assert_eq!(key_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium_instance123"); + + assert_eq!(parent_of("5003 (Web (Content)) S 5000 5003 0"), Some(5000)); + + assert_eq!(file_path("file:///home/me/My%20Music/a%2Fb.jpg").as_deref(), Some("/home/me/My Music/a/b.jpg")); + assert_eq!(file_path("file:///x/100%"), Some(String::from("/x/100%"))); + assert_eq!(file_path("https://i.scdn.co/image/ab"), None); + + assert_eq!(levels("0;50;100;250;\n"), [0.0, 0.5, 1.0, 1.0]); + + let hdmi = "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output"; + assert_eq!(middle_ellipsis(hdmi, 30), "700 Series Chi…ayPort 3 Output"); + assert_eq!(middle_ellipsis(hdmi, 30).chars().count(), 30); + assert_eq!(middle_ellipsis("JBL Tune 720BT", 30), "JBL Tune 720BT"); + } + + #[test] + fn writes_equalizers() { + let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5]; + + let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &gains, "bluez_output.00_1B.1"); + + assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink")); + assert!(config.contains("target.object = \"bluez_output.00_1B.1\"")); + assert!(config.contains("name = b1 label = bq_lowshelf control = { \"Freq\" = 31.0 \"Q\" = 0.7 \"Gain\" = 6.0 }")); + assert!(config.contains("name = b10 label = bq_highshelf")); + assert!(config.contains("{ output = \"b9:Out\" input = \"b10:In\" }")); + + // a hostile player name can't close the quoted string + assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\"")); + assert!(!eq_config("fake0", "x", &gains, "").contains("target.object")); + + assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0")); + + let line = eq_line("Lo-Fi\tLab", "Bass", &gains); + assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]); + assert!(eq_store("broken\tline\n").is_empty()); + } +} diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..ce2b69a --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,466 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; +use std::{env, fs, thread}; + +use amane::{Argument, Bus, Media, Service}; + +use crate::art; +use crate::parse::{self, EQ_PREFIX, Sink, Stream}; + +// how far a band can lift or cut, in dB +pub const MAX_GAIN: f32 = 12.0; + +// gains for BANDS (31 Hz .. 16 kHz) +pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [ + ("Flat", [0.0; 10]), + ("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]), + ("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]), + ("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]), + ("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]), + ("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]), + ("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]), + ("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]), +]; + +#[derive(Clone, PartialEq)] +pub struct Eq { + // a preset's name, or "Custom" once a band is dragged + pub preset: String, + pub gains: [f32; 10], +} + +impl Default for Eq { + fn default() -> Self { + Self { preset: String::from("Flat"), gains: [0.0; 10] } + } +} + +// one player's sound +#[derive(Clone, PartialEq)] +pub struct Channel { + // the mpris bus name, and the same made safe for node names + pub player: String, + pub key: String, + + // the player's own sink-inputs, before any equalizer + pub streams: Vec, + + pub volume: u8, + pub muted: bool, + + // the device the sound finally reaches + pub output: Option, + + // the output stream of the player's equalizer, once it has one + pub eq_output: Option, +} + +/* + * per-player sound through pactl and pipewire: volume, output device, and a + * 10-band equalizer. a player gets its own filter-chain sink the first time + * its equalizer or output is changed; its streams are then moved into it, and + * the equalizer's output stream is what moves between devices + */ +#[derive(Default)] +pub struct Sound { + outputs: Vec, + channels: Vec, + + // by player identity, so "Cider" keeps its curve across restarts + eqs: BTreeMap, + unsaved: bool, + + // a dragged control wins over polls until pactl has caught up + held_until: Option, + + // equalizers launched but not yet seen as sinks, so they aren't launched twice + starting: BTreeMap, +} + +impl Service for Sound { + fn new() -> Self { + Self::default() + } + + // reads pactl outside the lock, so a slow sound server never stalls a frame + fn listen() { + let saved = fs::read_to_string(store_file()).unwrap_or_default(); + + Self::write().eqs = parse::eq_store(&saved).into_iter().map(|(identity, preset, gains)| (identity, Eq { preset, gains })).collect(); + + // equalizer sinks this shell has seen, so each gets the current gains once + let mut known = BTreeSet::new(); + + loop { + poll(&mut known); + save(); + + thread::sleep(Duration::from_secs(1)); + } + } +} + +impl Sound { + pub fn outputs(&self) -> &[Sink] { + &self.outputs + } + + pub fn channel(&self, player: &str) -> Option<&Channel> { + self.channels.iter().find(|channel| channel.player == player) + } + + pub fn eq(&self, identity: &str) -> Eq { + self.eqs.get(identity).cloned().unwrap_or_default() + } + + pub fn unsaved(&self) -> bool { + self.unsaved + } + + fn held(&self) -> bool { + self.held_until.is_some_and(|until| Instant::now() < until) + } + + fn hold(&mut self) { + self.held_until = Some(Instant::now() + Duration::from_millis(2500)); + } + + // from input handlers: sets and unmutes every stream of one player + pub fn set_volume(player: &str, volume: u8) { + let mut sound = Self::write(); + + sound.hold(); + + let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else { + return; + }; + + if channel.volume == volume && !channel.muted { + return; + } + + // ponytail: one pactl per step; a long-lived `pactl` pipe if drags ever stutter + for id in &channel.streams { + if channel.muted { + run(&["pactl", "set-sink-input-mute", &id.to_string(), "0"]); + } + + run(&["pactl", "set-sink-input-volume", &id.to_string(), &format!("{volume}%")]); + } + + channel.volume = volume; + channel.muted = false; + } + + pub fn toggle_mute(player: &str) { + let mut sound = Self::write(); + + sound.hold(); + + let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else { + return; + }; + + channel.muted = !channel.muted; + + for id in &channel.streams { + run(&["pactl", "set-sink-input-mute", &id.to_string(), if channel.muted { "1" } else { "0" }]); + } + } + + // moves one player's sound to another device, through its equalizer so new streams follow + pub fn set_output(player: &str, identity: &str, device: &str) { + let (key, eq_output) = { + let mut sound = Self::write(); + + sound.hold(); + + let chosen = sound.outputs.iter().find(|sink| sink.name == device).cloned(); + + let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else { + return; + }; + + channel.output = chosen; + + (channel.key.clone(), channel.eq_output) + }; + + match eq_output { + Some(stream) => run(&["pactl", "move-sink-input", &stream.to_string(), device]), + None => { + let gains = Self::read().eq(identity).gains; + + start_eq(&key, identity, &gains, device); + } + } + } + + // from a dragged equalizer band; half-dB steps keep pw-cli calls few + pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) { + let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN); + + let gains = { + let mut sound = Self::write(); + + let eq = sound.eqs.entry(identity.to_string()).or_default(); + + if eq.gains[band] == gain { + return; + } + + eq.gains[band] = gain; + eq.preset = String::from("Custom"); + + let gains = eq.gains; + + sound.unsaved = true; + + gains + }; + + apply(player, identity, &gains); + } + + // false when there is no preset by that name + pub fn preset(player: &str, identity: &str, name: &str) -> bool { + let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else { + return false; + }; + + { + let mut sound = Self::write(); + + sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains }); + sound.unsaved = true; + } + + apply(player, identity, gains); + + true + } +} + +// pushes gains to the player's running equalizer, or starts one with them +fn apply(player: &str, identity: &str, gains: &[f32; 10]) { + let (running, target) = { + let sound = Sound::read(); + + let channel = sound.channel(player); + + let running = channel.is_some_and(|channel| channel.eq_output.is_some()); + let target = channel.and_then(|channel| channel.output.as_ref()).map_or(String::new(), |sink| sink.name.clone()); + + (running, target) + }; + + let key = parse::key_of(player); + + if running { + run(&["pw-cli", "set-param", &format!("{EQ_PREFIX}{key}"), "Props", &parse::eq_params(gains)]); + } else { + start_eq(&key, identity, gains, &target); + } +} + +fn poll(known: &mut BTreeSet) { + let sinks = parse::sinks(&amane::output("LC_ALL=C pactl list sinks")); + let streams = parse::sink_inputs(&amane::output("LC_ALL=C pactl list sink-inputs")); + + // copied out, so Media isn't held while this thread waits on d-bus and /proc + let players: Vec<(String, String)> = + Media::read().players().iter().map(|player| (player.name().to_string(), player.identity().to_string())).collect(); + + let eqs = Sound::read().eqs.clone(); + + // the equalizers' own output streams belong to no player + let (eq_streams, app_streams): (Vec, Vec) = + streams.into_iter().partition(|stream| stream.node_name.starts_with(EQ_PREFIX)); + + let mut channels = Vec::new(); + + for (player, identity) in players { + let key = parse::key_of(&player); + + let eq_name = format!("{EQ_PREFIX}{key}"); + let eq_sink = sinks.iter().find(|sink| sink.name == eq_name); + let eq_output = eq_streams.iter().find(|stream| stream.node_name == format!("{eq_name}.out")); + + let owned = parse::owned(&app_streams, process_of(&player), &parse::app_of(&player), ancestry); + + let device_of = |stream: &&Stream| sinks.iter().find(|sink| sink.index == stream.sink); + + match eq_sink { + Some(eq_sink) => { + // first sight of this equalizer (just launched, or left by an earlier shell): give it the current curve + if known.insert(key.clone()) { + Sound::write().starting.remove(&key); + + if let Some(eq) = eqs.get(&identity) { + run(&["pw-cli", "set-param", &eq_name, "Props", &parse::eq_params(&eq.gains)]); + } + } + + // every stream the player opens plays through its equalizer + for stream in owned.iter().filter(|stream| stream.sink != eq_sink.index) { + run(&["pactl", "move-sink-input", &stream.id.to_string(), &eq_name]); + } + } + None => { + known.remove(&key); + + // a saved curve comes back with its player + if let Some(eq) = eqs.get(&identity).filter(|eq| eq.gains != [0.0; 10]) { + let target = owned.first().and_then(device_of).map_or("", |sink| sink.name.as_str()); + + start_eq(&key, &identity, &eq.gains, target); + } + } + } + + // where the sound ends up: through the equalizer to its output, or straight to a device + let routed = eq_sink.is_some_and(|eq_sink| owned.iter().any(|stream| stream.sink == eq_sink.index)); + + let output = match eq_output { + Some(eq_output) if routed => sinks.iter().find(|sink| sink.index == eq_output.sink), + _ => owned.first().and_then(device_of), + }; + + channels.push(Channel { + output: output.cloned(), + eq_output: eq_output.map(|stream| stream.id), + streams: owned.iter().map(|stream| stream.id).collect(), + volume: owned.first().map_or(100, |stream| stream.volume), + muted: !owned.is_empty() && owned.iter().all(|stream| stream.muted), + player, + key, + }); + } + + // an equalizer whose player has gone + for sink in &sinks { + if let Some(key) = sink.name.strip_prefix(EQ_PREFIX) + && !channels.iter().any(|channel| channel.key == key) + { + run(&["pkill", "-f", &eq_file(key).display().to_string()]); + } + } + + let outputs: Vec = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect(); + + let stale = { + let sound = Sound::read(); + + !sound.held() && (sound.channels != channels || sound.outputs != outputs) + }; + + if stale { + let mut sound = Sound::write(); + + sound.channels = channels; + sound.outputs = outputs; + } +} + +/* + * a standalone filter-chain process in its own session, so the sound keeps + * flowing while the shell restarts; the next shell finds it by node name + */ +fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) { + { + let mut sound = Sound::write(); + + if sound.starting.get(key).is_some_and(|since| since.elapsed() < Duration::from_secs(5)) { + return; + } + + sound.starting.insert(key.to_string(), Instant::now()); + } + + let config = eq_file(key); + + if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() { + run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]); + } +} + +fn eq_file(key: &str) -> PathBuf { + art::folder().join(format!("eq-{key}.conf")) +} + +// saved equalizers live with the shell's config, so they outlast logouts +fn store_file() -> PathBuf { + let config = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .unwrap_or_default(); + + config.join("amane").join("island-eq") +} + +fn save() { + let lines: String = { + let sound = Sound::read(); + + if !sound.unsaved { + return; + } + + sound.eqs.iter().map(|(identity, eq)| parse::eq_line(identity, &eq.preset, &eq.gains)).collect() + }; + + // ponytail: a change landing between this read and the write below is saved on the next change + if fs::write(store_file(), lines).is_ok() { + Sound::write().unsaved = false; + } +} + +// no shell: device and player names come from other programs and must never run as commands +fn run(command: &[&str]) { + let Some((program, arguments)) = command.split_first() else { + return; + }; + + let child = Command::new(program).args(arguments).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); + + // a finished child nobody waits for stays behind as a zombie + if let Ok(mut child) = child { + thread::spawn(move || child.wait()); + } +} + +// the pid behind a bus name, 0 when d-bus can't tell +fn process_of(bus_name: &str) -> u32 { + let pid = Bus::session().call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetConnectionUnixProcessID", + &[Argument::from(bus_name)], + ); + + pid.number() as u32 +} + +// a process and its parents, up to init +fn ancestry(mut pid: u32) -> Vec { + let mut chain = Vec::new(); + + while pid > 1 && chain.len() < 32 { + chain.push(pid); + + let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + break; + }; + + let Some(parent) = parse::parent_of(&stat) else { + break; + }; + + pid = parent; + } + + chain +} diff --git a/src/spectrum.rs b/src/spectrum.rs new file mode 100644 index 0000000..12fea22 --- /dev/null +++ b/src/spectrum.rs @@ -0,0 +1,150 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; +use std::{fs, thread}; + +use amane::{Media, Service}; + +use crate::art; +use crate::island::{self, Island}; +use crate::parse; +use crate::sound::Sound; + +// cava's bars, low to high frequencies; the ring mirrors them +pub const BARS: usize = 24; + +/* + * the shown player's own sound as bars: `pw-record --target ` + * piped into cava. only runs while the card is open and the player plays, + * since every frame it sends redraws the island + */ +#[derive(Default)] +pub struct Spectrum { + stream: Option, + + // 0 to 1 per bar; empty while nothing is captured + pub levels: Vec, +} + +impl Service for Spectrum { + fn new() -> Self { + Self::default() + } + + fn listen() { + let config = art::folder().join("cava.conf"); + + let _ = fs::write(&config, cava_config()); + + let mut running: Option = None; + + loop { + let wanted = wanted_stream(); + + if wanted != running.as_ref().map(|capture| capture.stream) { + if let Some(capture) = running.take() { + capture.stop(); + } + + *Self::write() = Self { stream: wanted, levels: Vec::new() }; + + running = wanted.and_then(|stream| Capture::start(stream, &config)); + } + + thread::sleep(Duration::from_millis(200)); + } + } +} + +fn cava_config() -> String { + format!( + "[general]\nframerate = 60\nbars = {BARS}\n\ + [input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\ + [output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\ + [smoothing]\nnoise_reduction = 70\n" + ) +} + +// the first stream of the shown player, while the card is open and it plays +fn wanted_stream() -> Option { + let (expanded, selected) = { + let island = Island::read(); + + (island.expanded(), island.selected.clone()) + }; + + if !expanded { + return None; + } + + let player = { + let media = Media::read(); + + island::shown(&media, selected.as_deref()).filter(|player| player.playing()).map(|player| player.name().to_string()) + }?; + + Sound::read().channel(&player).and_then(|channel| channel.streams.first().copied()) +} + +struct Capture { + stream: u32, + record: Child, + cava: Child, +} + +impl Capture { + fn start(stream: u32, config: &Path) -> Option { + let mut record = Command::new("pw-record") + .args(["--target", &stream.to_string(), "--rate", "44100", "--channels", "2", "--format", "s16", "-"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let audio = record.stdout.take()?; + + let cava = Command::new("cava").arg("-p").arg(config).stdin(audio).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + + let Ok(mut cava) = cava else { + let _ = record.kill(); + let _ = record.wait(); + + return None; + }; + + let frames = cava.stdout.take()?; + + // ends by itself once cava is killed and its output closes + thread::spawn(move || { + for line in BufReader::new(frames).lines() { + let Ok(line) = line else { + break; + }; + + let levels = parse::levels(&line); + + // silence repeats the same frame; skipping it keeps the island at rest + let fresh = { + let spectrum = Spectrum::read(); + + spectrum.stream == Some(stream) && spectrum.levels != levels + }; + + if fresh { + Spectrum::write().levels = levels; + } + } + }); + + Some(Self { stream, record, cava }) + } + + fn stop(mut self) { + let _ = self.record.kill(); + let _ = self.cava.kill(); + + let _ = self.record.wait(); + let _ = self.cava.wait(); + } +}