From d910d70d3957a0334d4bb73dcbb92e339bc5c241 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:14:00 -0700 Subject: [PATCH 01/72] format issues --- Cargo.lock | 92 ++++++++++++++++++----------- src/app.rs | 143 ++++++++++++++++++++++++++++++++++++++------- src/constants.rs | 9 +++ src/now_playing.rs | 8 ++- src/visualizer.rs | 9 +++ 5 files changed, 201 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e40080d..cc273be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,9 +150,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -160,9 +160,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -935,9 +935,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -951,9 +951,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" @@ -964,7 +964,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -973,9 +973,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -1130,7 +1152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.0", - "jni-sys", + "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", "num_enum", @@ -1155,7 +1177,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -1760,9 +1782,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -1862,9 +1884,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -2083,9 +2105,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -2098,18 +2120,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -2119,18 +2141,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" @@ -2742,9 +2764,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -2786,18 +2808,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", @@ -2878,9 +2900,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" dependencies = [ "zune-core", ] diff --git a/src/app.rs b/src/app.rs index ab0a6c3..9e5ace8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,9 @@ struct VizState { colors: Vec<[u8; 3]>, track_title: Option, artwork_bytes: Option>, + /// Incremented each time artwork_bytes changes, so the UI can detect updates + /// without cloning the bytes every frame. + artwork_generation: u64, } pub struct App { @@ -36,6 +39,11 @@ pub struct App { // Settings gain: f32, + time_window: f32, + transition_time: u16, + min_freq: u16, + max_freq: u16, + freq_preset_index: usize, current_palette_index: usize, palette_names: Vec, effect: Effect, @@ -51,8 +59,8 @@ pub struct App { viz_state: Arc>, album_art_stop: Option>, album_art_texture: Option, - /// Tracks which artwork bytes we've already loaded into the texture - loaded_artwork_len: usize, + /// Tracks which artwork generation we've already loaded into the texture + loaded_artwork_gen: u64, } impl App { @@ -78,6 +86,15 @@ impl App { let sort_primary = visualizer_config.sort_primary.unwrap_or_default(); let sort_secondary = visualizer_config.sort_secondary.unwrap_or_default(); let effect = visualizer_config.effect.unwrap_or_default(); + let (min_freq, max_freq) = visualizer_config + .freq_range + .unwrap_or(constants::DEFAULT_FREQ_RANGE); + let time_window = visualizer_config + .time_window + .unwrap_or(constants::DEFAULT_TIME_WINDOW); + let transition_time = visualizer_config + .transition_time + .unwrap_or(constants::DEFAULT_TRANSITION_TIME); let initial_colors = visualizer_config .colors @@ -106,6 +123,7 @@ impl App { colors: initial_colors, track_title: None, artwork_bytes: None, + artwork_generation: 0, })); nl_device.request_udp_control()?; @@ -117,6 +135,14 @@ impl App { visualizer_tx: tx, shared_colors, gain, + time_window, + transition_time, + min_freq, + max_freq, + freq_preset_index: constants::FREQ_RANGE_PRESETS + .iter() + .position(|&(lo, hi, _)| lo == min_freq && hi == max_freq) + .unwrap_or(0), current_palette_index: 0, palette_names, effect, @@ -128,7 +154,7 @@ impl App { viz_state, album_art_stop: None, album_art_texture: None, - loaded_artwork_len: 0, + loaded_artwork_gen: 0, }) } @@ -211,6 +237,38 @@ impl App { 'r' | 'R' => { let _ = self.visualizer_tx.send(VisualizerMsg::ResetPanels); } + '[' => { + self.time_window = (self.time_window - 0.025).max(0.05); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTimeWindow(self.time_window)); + } + ']' => { + self.time_window = (self.time_window + 0.025).min(0.5); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTimeWindow(self.time_window)); + } + ',' => { + self.transition_time = self.transition_time.saturating_sub(1); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTransitionTime(self.transition_time)); + } + '.' => { + self.transition_time = (self.transition_time + 1).min(10); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTransitionTime(self.transition_time)); + } + 'f' | 'F' => { + self.freq_preset_index = + (self.freq_preset_index + 1) % constants::FREQ_RANGE_PRESETS.len(); + let (lo, hi, _) = constants::FREQ_RANGE_PRESETS[self.freq_preset_index]; + self.min_freq = lo; + self.max_freq = hi; + let _ = self.visualizer_tx.send(VisualizerMsg::SetFreqRange(lo, hi)); + } ' ' => self.show_visualization = !self.show_visualization, _ => {} } @@ -363,27 +421,39 @@ impl App { // ── Album art texture ───────────────────────────────────────────────── fn update_album_art_texture(&mut self) { - let bytes_opt = self - .viz_state - .lock() - .ok() - .and_then(|s| s.artwork_bytes.clone()); - if let Some(bytes) = bytes_opt { - // Only reload if the bytes actually changed - if bytes.len() != self.loaded_artwork_len { + // Check the generation counter without cloning the (potentially large) bytes. + // Only lock briefly to read the generation and, if changed, take the bytes. + let update = self.viz_state.lock().ok().and_then(|s| { + if s.artwork_bytes.is_some() && s.artwork_generation != self.loaded_artwork_gen { + // Only clone when the artwork actually changed (not every frame) + Some((s.artwork_bytes.clone(), s.artwork_generation)) + } else if s.artwork_bytes.is_none() && self.album_art_texture.is_some() { + Some((None, s.artwork_generation)) + } else { + None + } + }); + + if let Some((bytes_opt, generation)) = update { + if let Some(bytes) = bytes_opt { if let Ok(img) = image::load_from_memory(&bytes) { let rgba = img.to_rgba8(); let (w, h) = (rgba.width() as u16, rgba.height() as u16); let tex = Texture2D::from_rgba8(w, h, rgba.as_raw()); tex.set_filter(FilterMode::Linear); + // Free the old GPU texture before replacing + if let Some(old) = self.album_art_texture.take() { + drop(old); + } self.album_art_texture = Some(tex); } - self.loaded_artwork_len = bytes.len(); + } else { + // Artwork cleared (switched to named palette) + if let Some(old) = self.album_art_texture.take() { + drop(old); + } } - } else if self.album_art_texture.is_some() { - // Artwork cleared (switched to named palette) - self.album_art_texture = None; - self.loaded_artwork_len = 0; + self.loaded_artwork_gen = generation; } } @@ -457,23 +527,38 @@ impl App { (name, state.track_title.clone()) }; + let freq_label = constants::FREQ_RANGE_PRESETS + .iter() + .find(|&&(lo, hi, _)| lo == self.min_freq && hi == self.max_freq) + .map(|&(_, _, name)| name) + .unwrap_or("Custom"); let effect_text = format!("Effect: {} | Gain: {:.2}", effect_str, self.gain); + let audio_text = format!( + "Window: {:.0}ms | Transition: {}ms | Freq: {} ({}-{}Hz)", + self.time_window * 1000.0, + self.transition_time as u32 * 100, + freq_label, + self.min_freq, + self.max_freq, + ); let mut palette_text = format!("Palette: {}", palette_name); if let Some(title) = &track_title { palette_text.push_str(&format!(" | Now playing: {}", title)); } let big = 26.0; + let small = 20.0; let em = sharp_measure(big, &effect_text); + let am = sharp_measure(small, &audio_text); let pm = sharp_measure(big, &palette_text); - let box_w = em.width.max(pm.width) + 14.0; + let box_w = em.width.max(pm.width).max(am.width) + 14.0; // Color swatches measurement let colors = self.viz_state.lock().unwrap().colors.clone(); let swatch_total_w = 22.0 * colors.len() as f32; let box_w = box_w.max(swatch_total_w + 80.0); - let box_h = big * 2.0 + 30.0 + 18.0; // two text lines + swatch row + padding + let box_h = big * 2.0 + small + 34.0 + 18.0; // three text lines + swatch row + padding let box_y = sh - box_h - 5.0; draw_rectangle(5.0, box_y, box_w, box_h, Color::from_rgba(0, 0, 0, 160)); @@ -481,17 +566,26 @@ impl App { let line1_y = box_y + big + 4.0; sharp_text(&effect_text, 12.0, line1_y, big, WHITE); - let line2_y = line1_y + big + 4.0; + let line2_y = line1_y + small + 4.0; sharp_text( - &palette_text, + &audio_text, 12.0, line2_y, + small, + Color::from_rgba(180, 180, 220, 255), + ); + + let line3_y = line2_y + big + 4.0; + sharp_text( + &palette_text, + 12.0, + line3_y, big, Color::from_rgba(100, 255, 100, 255), ); // Color swatches - let swatch_y = line2_y + 8.0; + let swatch_y = line3_y + 8.0; let mut sx = 12.0; for [r, g, b] in &colors { draw_rectangle(sx, swatch_y, 18.0, 14.0, Color::from_rgba(*r, *g, *b, 255)); @@ -562,7 +656,7 @@ impl App { ); // ── Bottom-center: controls hint ── - let hint = "? help | ESC quit | Space preview | -/+ gain | 1-0 palette | E effect | N album art | R reset"; + let hint = "? help | ESC quit | Space preview | -/+ gain | [/] window | ,/. transition | F freq | 1-0 palette | E effect | N album art"; let hm = sharp_measure(14.0, hint); let hx = (sw - hm.width) / 2.0; sharp_text( @@ -591,6 +685,9 @@ impl App { ("?", "Toggle this help"), ("Space", "Toggle panel visualization preview"), ("- / +", "Decrease / increase gain"), + ("[ / ]", "Decrease / increase sample time window"), + (", / .", "Decrease / increase transition time"), + ("F", "Cycle frequency range preset"), ("1-9, 0", "Switch color palette"), ("E", "Cycle effect: Spectrum / Energy Wave / Pulse"), ("A", "Toggle primary axis (X / Y)"), @@ -692,6 +789,7 @@ impl App { state.colors = colors.clone(); state.track_title = title; state.artwork_bytes = Some(artwork); + state.artwork_generation += 1; } let _ = tx.send(VisualizerMsg::SetPalette(colors)); } @@ -712,6 +810,7 @@ impl App { state.colors = colors.clone(); state.track_title = title; state.artwork_bytes = Some(artwork); + state.artwork_generation += 1; } let _ = tx.send(VisualizerMsg::SetPalette(colors)); } diff --git a/src/constants.rs b/src/constants.rs index 5ce3069..2073aee 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -21,6 +21,15 @@ pub const DEFAULT_GAIN: f32 = 1.0; pub const DEFAULT_TRANSITION_TIME: u16 = 2; pub const DEFAULT_TIME_WINDOW: f32 = 0.1875; +/// Frequency range presets for cycling with the F key +pub const FREQ_RANGE_PRESETS: [(u16, u16, &str); 5] = [ + (20, 4500, "Full"), + (20, 300, "Sub Bass"), + (60, 1000, "Low-Mid"), + (200, 4500, "Mid-High"), + (1000, 12000, "Treble"), +]; + // other pub const DEFAULT_CONFIG_DIR: &str = "audioleaf"; pub const DEFAULT_CONFIG_FILE: &str = "config.toml"; diff --git a/src/now_playing.rs b/src/now_playing.rs index 0f05086..5c18cac 100644 --- a/src/now_playing.rs +++ b/src/now_playing.rs @@ -58,7 +58,7 @@ pub fn fetch_artwork_and_palette() -> Option<(Vec, Vec<[u8; 3]>)> { #[cfg(target_os = "macos")] mod macos { use objc2::msg_send; - use objc2::rc::Retained; + use objc2::rc::{Retained, autoreleasepool}; use objc2::runtime::{AnyClass, AnyObject}; use objc2_foundation::NSString; @@ -66,11 +66,13 @@ mod macos { unsafe extern "C" {} pub fn get_track_title() -> Option { - sb_spotify_title().or_else(sb_apple_music_title) + // Wrap in an autorelease pool so ObjC autoreleased objects from + // ScriptingBridge calls are freed when the pool drains. + autoreleasepool(|_| sb_spotify_title().or_else(sb_apple_music_title)) } pub fn fetch_artwork_bytes() -> Option> { - sb_spotify_artwork().or_else(sb_apple_music_artwork) + autoreleasepool(|_| sb_spotify_artwork().or_else(sb_apple_music_artwork)) } // ── ScriptingBridge helpers ─────────────────────────────────────────────── diff --git a/src/visualizer.rs b/src/visualizer.rs index d9142fc..dddd74e 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -32,6 +32,9 @@ pub enum VisualizerMsg { SetPalette(Vec<[u8; 3]>), SetEffect(Effect), ResetPanels, + SetTimeWindow(f32), + SetTransitionTime(u16), + SetFreqRange(u16, u16), SetSorting { primary_axis: crate::config::Axis, sort_primary: crate::config::Sort, @@ -172,6 +175,12 @@ impl Visualizer { // Immediately send a black frame so the old palette's colors don't linger self.send_black_frame(base_colors.len()); } + VisualizerMsg::SetTimeWindow(tw) => self.time_window = tw, + VisualizerMsg::SetTransitionTime(tt) => self.trans_time = tt, + VisualizerMsg::SetFreqRange(min, max) => { + self.min_freq = min; + self.max_freq = max; + } VisualizerMsg::ResetPanels => { brightness.fill(0.0); prev_max.fill(0.0); From d6bde4bd2fe21398a3639c343fdafd9efbec0359 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:19:00 -0700 Subject: [PATCH 02/72] updated to have a webserver --- .gitignore | 4 + Cargo.lock | 347 ++++-- Cargo.toml | 4 + README.md | 63 + src/app.rs | 88 +- src/bin/audioleaf-api.rs | 1519 ++++++++++++++++++++++++ src/config.rs | 4 +- src/lib.rs | 11 + src/nanoleaf.rs | 9 + src/now_playing.rs | 140 ++- src/visualizer.rs | 2 +- web/components.json | 19 + web/index.html | 12 + web/package.json | 33 + web/pnpm-lock.yaml | 1585 +++++++++++++++++++++++++ web/postcss.config.cjs | 6 + web/src/App.tsx | 1713 +++++++++++++++++++++++++++ web/src/api.ts | 240 ++++ web/src/components/ui/badge.tsx | 27 + web/src/components/ui/button.tsx | 47 + web/src/components/ui/card.tsx | 62 + web/src/components/ui/separator.tsx | 23 + web/src/index.css | 39 + web/src/lib/utils.ts | 6 + web/src/main.tsx | 10 + web/src/vite-env.d.ts | 1 + web/tailwind.config.ts | 41 + web/tsconfig.app.json | 22 + web/tsconfig.json | 7 + web/tsconfig.node.json | 10 + web/vite.config.ts | 25 + 31 files changed, 5945 insertions(+), 174 deletions(-) create mode 100644 src/bin/audioleaf-api.rs create mode 100644 src/lib.rs create mode 100644 web/components.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/postcss.config.cjs create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 0592392..bb47dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /target +/node_modules .DS_Store +web/node_modules +web/dist +web/*.tsbuildinfo diff --git a/Cargo.lock b/Cargo.lock index cc273be..481b6d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,8 @@ version = "4.0.0" dependencies = [ "anyhow", "auto-palette", + "axum", + "base64", "clap", "cpal", "dasp_sample", @@ -127,7 +129,9 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tokio", "toml", + "tower-http", ] [[package]] @@ -160,9 +164,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -170,6 +174,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -235,9 +291,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -305,9 +361,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -362,9 +418,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coreaudio-rs" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15c3c3cee7c087938f7ad1c3098840b3ef1f1bdc7f6e496336c3b1e7a6f3914" +checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" dependencies = [ "bitflags 2.11.0", "libc", @@ -664,9 +720,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -713,11 +769,17 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -727,9 +789,9 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -737,15 +799,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -778,12 +839,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -791,9 +853,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -804,9 +866,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -818,15 +880,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -838,15 +900,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -919,12 +981,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -935,9 +997,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1011,34 +1073,36 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "libc" -version = "0.2.183" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" @@ -1090,6 +1154,12 @@ dependencies = [ "libc", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1126,9 +1196,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1431,17 +1501,11 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" @@ -1477,9 +1541,9 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1559,7 +1623,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1611,9 +1675,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -1713,15 +1777,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", @@ -1782,9 +1846,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", @@ -1798,6 +1862,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1882,15 +1952,38 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1899,9 +1992,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" @@ -2043,9 +2136,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2068,18 +2161,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2105,9 +2210,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -2120,18 +2225,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -2141,18 +2246,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -2167,6 +2272,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2205,6 +2311,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -2302,9 +2409,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -2315,23 +2422,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2339,9 +2442,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -2352,18 +2455,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2764,9 +2867,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -2779,15 +2882,15 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2796,9 +2899,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2808,18 +2911,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2828,18 +2931,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2855,9 +2958,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2866,9 +2969,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2877,9 +2980,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -2900,9 +3003,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 7f742e1..3cdaf11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,10 @@ repository = "https://github.com/weekendsuperhero-io/audioleaf" [dependencies] anyhow = "1.0.102" +base64 = "0.22.1" clap = { version = "4.6.0", features = ["derive"] } auto-palette = "0.9" +axum = "0.8.4" cpal = "0.17.3" dasp_sample = "0.11.0" dirs = "6.0.0" @@ -26,7 +28,9 @@ pollster = "0.4" reqwest = { version = "0.13", features = ["blocking", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "net"] } toml = "1" +tower-http = { version = "0.6.6", features = ["cors"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/README.md b/README.md index e86e9aa..31ba0a1 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,69 @@ To connect to a specific device: audioleaf -d "Shapes AC01" ``` +### Web Control Panel (Axum + React) + +Audioleaf now includes an HTTP API and a Vite/React control panel scaffold. + +Start the API server: + +```bash +cargo run --bin audioleaf-api +``` + +The API process also starts a live visualizer engine (using your saved config), so web effect/palette/settings changes apply to panels immediately. Web updates modify the running state and are not auto-written to `config.toml`. + +In a second terminal, start the web app: + +```bash +cd web +pnpm install +pnpm dev +``` + +The Vite dev server runs at `http://127.0.0.1:5173` and proxies `/api/*` to `http://127.0.0.1:8787`. + +Current API routes: + +- `GET /api/health` +- `GET /api/config` +- `POST /api/config/save` (persist current runtime config to `config.toml`) +- `PUT /api/config/visualizer/effect` (`effect`: Spectrum | EnergyWave | Pulse) +- `PUT /api/config/visualizer/palette` (`palette_name`: preset name) +- `PUT /api/config/visualizer/sort` (`primary_axis`, `sort_primary`, `sort_secondary`) +- `PUT /api/config/visualizer/settings` (`audio_backend`, `freq_range`, `default_gain`, `transition_time`, `time_window`) +- `GET /api/now-playing` (latest Shairport track metadata + extracted artwork colors) +- `PUT /api/now-playing/settings` (`drive_visualizer_palette`) +- `GET /api/now-playing/artwork` (latest album artwork image bytes) +- `GET /api/visualizer/preview` (live panel colors from running visualizer) +- `GET /api/audio/backends` +- `GET /api/devices` +- `GET /api/devices/{name}/info` +- `GET /api/devices/{name}/layout` +- `PUT /api/devices/{name}/state` (`power_on` and/or `brightness` 0-100) +- `GET /api/palettes` + +#### Shairport Sync Metadata Setup + +To power the web "Now Playing" panel from AirPlay metadata, enable metadata in `shairport-sync.conf`: + +```conf +metadata = +{ + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "/tmp/shairport-sync-metadata"; +}; +``` + +If you use a different metadata pipe path, set: + +```bash +export AUDIOLEAF_SHAIRPORT_METADATA_PIPE=/absolute/path/to/your/pipe +``` + +before starting `audioleaf-api`. + ### Controls Press ? in the app to see all keybinds. diff --git a/src/app.rs b/src/app.rs index 9e5ace8..dc85e42 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, mpsc, }; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Display state shared between the main thread and the album art watcher thread. struct VizState { @@ -55,6 +55,11 @@ pub struct App { show_visualization: bool, show_help: bool, + // Color interpolation for smooth panel preview + prev_colors: HashMap, + target_colors: HashMap, + color_transition_start: Instant, + // Album art viz_state: Arc>, album_art_stop: Option>, @@ -151,6 +156,9 @@ impl App { sort_secondary, show_visualization: false, show_help: false, + prev_colors: HashMap::new(), + target_colors: HashMap::new(), + color_transition_start: Instant::now(), viz_state, album_art_stop: None, album_art_texture: None, @@ -217,10 +225,22 @@ impl App { '?' => self.show_help = !self.show_help, _ if self.show_help => {} // Swallow other keys while help is shown '-' | '_' => { + self.transition_time = self.transition_time.saturating_sub(1); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTransitionTime(self.transition_time)); + } + '=' | '+' => { + self.transition_time = (self.transition_time + 1).min(10); + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetTransitionTime(self.transition_time)); + } + 'g' => { self.gain -= 0.05; let _ = self.visualizer_tx.send(VisualizerMsg::SetGain(self.gain)); } - '=' | '+' => { + 'G' => { self.gain += 0.05; let _ = self.visualizer_tx.send(VisualizerMsg::SetGain(self.gain)); } @@ -249,18 +269,6 @@ impl App { .visualizer_tx .send(VisualizerMsg::SetTimeWindow(self.time_window)); } - ',' => { - self.transition_time = self.transition_time.saturating_sub(1); - let _ = self - .visualizer_tx - .send(VisualizerMsg::SetTransitionTime(self.transition_time)); - } - '.' => { - self.transition_time = (self.transition_time + 1).min(10); - let _ = self - .visualizer_tx - .send(VisualizerMsg::SetTransitionTime(self.transition_time)); - } 'f' | 'F' => { self.freq_preset_index = (self.freq_preset_index + 1) % constants::FREQ_RANGE_PRESETS.len(); @@ -277,9 +285,38 @@ impl App { false } + // ── Color interpolation ───────────────────────────────────────────── + + fn interpolated_colors(&self) -> HashMap { + // transition_time is in units of 100ms + let duration = Duration::from_millis(self.transition_time as u64 * 100); + let elapsed = self.color_transition_start.elapsed(); + let t = if duration.is_zero() { + 1.0_f32 + } else { + (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0) + }; + + let mut result = self.target_colors.clone(); + if t < 1.0 { + for (id, target) in &self.target_colors { + let prev = self.prev_colors.get(id).copied().unwrap_or([0, 0, 0]); + result.insert( + *id, + [ + lerp_u8(prev[0], target[0], t), + lerp_u8(prev[1], target[1], t), + lerp_u8(prev[2], target[2], t), + ], + ); + } + } + result + } + // ── Panel rendering ────────────────────────────────────────────────── - fn draw_panels(&self) { + fn draw_panels(&mut self) { let sw = screen_width(); let sh = screen_height(); @@ -318,9 +355,16 @@ impl App { let offset_x = (sw - layout_width * scale) / 2.0; let offset_y = padding_top + (available_height - layout_height * scale) / 2.0; - // Snapshot visualization colors once per frame + // Snapshot visualization colors with smooth interpolation let vis_colors = if self.show_visualization { - self.shared_colors.lock().ok().map(|map| map.clone()) + if let Ok(map) = self.shared_colors.lock() { + if *map != self.target_colors { + self.prev_colors = self.interpolated_colors(); + self.target_colors = map.clone(); + self.color_transition_start = Instant::now(); + } + } + Some(self.interpolated_colors()) } else { None }; @@ -656,7 +700,7 @@ impl App { ); // ── Bottom-center: controls hint ── - let hint = "? help | ESC quit | Space preview | -/+ gain | [/] window | ,/. transition | F freq | 1-0 palette | E effect | N album art"; + let hint = "? help | ESC quit | Space preview | -/+ speed | g/G gain | [/] window | F freq | 1-0 palette | E effect | N album art"; let hm = sharp_measure(14.0, hint); let hx = (sw - hm.width) / 2.0; sharp_text( @@ -684,9 +728,9 @@ impl App { ("ESC / Q", "Quit"), ("?", "Toggle this help"), ("Space", "Toggle panel visualization preview"), - ("- / +", "Decrease / increase gain"), + ("- / +", "Decrease / increase transition speed"), + ("g / G", "Decrease / increase gain"), ("[ / ]", "Decrease / increase sample time window"), - (", / .", "Decrease / increase transition time"), ("F", "Cycle frequency range preset"), ("1-9, 0", "Switch color palette"), ("E", "Cycle effect: Spectrum / Energy Wave / Pulse"), @@ -939,6 +983,10 @@ fn draw_controller( // ── Sharp text helpers (DPI-aware via camera_font_scale) ───────────────── +fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round() as u8 +} + fn sharp_text(text: &str, x: f32, y: f32, logical_size: f32, color: Color) { let (fs, sx, sy) = camera_font_scale(logical_size); draw_text_ex( diff --git a/src/bin/audioleaf-api.rs b/src/bin/audioleaf-api.rs new file mode 100644 index 0000000..d9e0868 --- /dev/null +++ b/src/bin/audioleaf-api.rs @@ -0,0 +1,1519 @@ +use anyhow::Result; +use axum::{ + Json, Router, + body::Body, + extract::{Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post, put}, +}; +use base64::Engine; +use clap::Parser; +use cpal::traits::{DeviceTrait, HostTrait}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::OpenOptions, + io::{BufRead, BufReader}, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex, mpsc::Sender}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::task::JoinError; +use tower_http::cors::{Any, CorsLayer}; + +#[derive(Parser, Debug)] +#[command(version, about = "Audioleaf HTTP API", author)] +struct ApiOptions { + /// Host interface to bind + #[arg(long, default_value = "127.0.0.1")] + host: String, + + /// HTTP port for the API server + #[arg(long, default_value_t = 8787)] + port: u16, + + /// Path to audioleaf's configuration file + #[arg(long = "config")] + config_file_path: Option, + + /// Path to audioleaf's database of known Nanoleaf devices + #[arg(long = "devices")] + devices_file_path: Option, +} + +#[derive(Clone)] +struct ApiState { + config_file_path: Option, + devices_file_path: Option, + runtime_config: Arc>, + live_visualizer: Arc>>, + now_playing: Arc>, +} + +#[derive(Clone, Debug)] +struct LiveVisualizerRuntime { + sender: Sender, + global_orientation: u16, + device: DeviceSummary, + shared_colors: Arc>>, +} + +const DEFAULT_SHAIRPORT_METADATA_PIPE: &str = "/tmp/shairport-sync-metadata"; +const NOW_PLAYING_RETRY_DELAY: Duration = Duration::from_secs(3); + +#[derive(Clone, Debug, Default)] +struct NowPlayingTrackData { + title: Option, + artist: Option, + album: Option, + stream_url: Option, + source_name: Option, + source_ip: Option, + user_agent: Option, +} + +impl NowPlayingTrackData { + fn has_data(&self) -> bool { + self.title.as_deref().is_some_and(|value| !value.is_empty()) + || self + .artist + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self.album.as_deref().is_some_and(|value| !value.is_empty()) + || self + .stream_url + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .source_name + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .source_ip + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .user_agent + .as_deref() + .is_some_and(|value| !value.is_empty()) + } +} + +#[derive(Clone, Debug)] +struct NowPlayingRuntimeState { + metadata_pipe_path: String, + reader_running: bool, + last_error: Option, + drive_visualizer_palette: bool, + track: NowPlayingTrackData, + palette_colors: Vec<[u8; 3]>, + artwork_bytes: Option>, + artwork_mime_type: Option, + artwork_generation: u64, + updated_at_ms: Option, +} + +impl NowPlayingRuntimeState { + fn new(metadata_pipe_path: String) -> Self { + Self { + metadata_pipe_path, + reader_running: false, + last_error: None, + drive_visualizer_palette: false, + track: NowPlayingTrackData::default(), + palette_colors: Vec::new(), + artwork_bytes: None, + artwork_mime_type: None, + artwork_generation: 0, + updated_at_ms: None, + } + } + + fn clear_session_data(&mut self) { + self.track = NowPlayingTrackData::default(); + self.palette_colors.clear(); + self.artwork_bytes = None; + self.artwork_mime_type = None; + self.artwork_generation = self.artwork_generation.saturating_add(1); + self.updated_at_ms = Some(now_unix_ms()); + } + + fn snapshot(&self) -> NowPlayingResponse { + NowPlayingResponse { + reader_running: self.reader_running, + metadata_pipe_path: self.metadata_pipe_path.clone(), + last_error: self.last_error.clone(), + drive_visualizer_palette: self.drive_visualizer_palette, + track: self.track.has_data().then_some(NowPlayingTrackResponse { + title: self.track.title.clone(), + artist: self.track.artist.clone(), + album: self.track.album.clone(), + stream_url: self.track.stream_url.clone(), + source_name: self.track.source_name.clone(), + source_ip: self.track.source_ip.clone(), + user_agent: self.track.user_agent.clone(), + }), + palette_colors: self.palette_colors.clone(), + artwork_available: self.artwork_bytes.is_some(), + artwork_generation: self.artwork_generation, + updated_at_ms: self.updated_at_ms, + } + } +} + +#[derive(Clone, Debug, Serialize)] +struct NowPlayingTrackResponse { + title: Option, + artist: Option, + album: Option, + stream_url: Option, + source_name: Option, + source_ip: Option, + user_agent: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct NowPlayingResponse { + reader_running: bool, + metadata_pipe_path: String, + last_error: Option, + drive_visualizer_palette: bool, + track: Option, + palette_colors: Vec<[u8; 3]>, + artwork_available: bool, + artwork_generation: u64, + updated_at_ms: Option, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +type ApiResult = Result, ApiError>; + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn internal(err: E) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: err.to_string(), + } + } + + fn not_found(err: E) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: err.to_string(), + } + } + + fn bad_request(err: E) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: err.to_string(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + ( + self.status, + Json(ErrorResponse { + error: self.message, + }), + ) + .into_response() + } +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, + version: &'static str, +} + +#[derive(Debug, Serialize)] +struct PathsResponse { + config_file_path: String, + config_file_exists: bool, + devices_file_path: String, + devices_file_exists: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigResponse { + paths: PathsResponse, + config: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct DeviceSummary { + name: String, + ip: String, +} + +#[derive(Debug, Serialize)] +struct DevicesResponse { + devices: Vec, + devices_file_path: String, + devices_file_exists: bool, +} + +#[derive(Debug, Serialize)] +struct DeviceInfoResponse { + device: DeviceSummary, + info: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct DeviceLayoutPanel { + panel_id: u16, + x: i16, + y: i16, + orientation: u16, + shape_type_id: u64, + shape_type_name: String, + num_sides: usize, + side_length: f32, +} + +#[derive(Debug, Serialize)] +struct DeviceLayoutResponse { + device: DeviceSummary, + global_orientation: u16, + panels: Vec, +} + +#[derive(Debug, Deserialize)] +struct VisualizerEffectUpdateRequest { + effect: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerPaletteUpdateRequest { + palette_name: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerSortUpdateRequest { + primary_axis: String, + sort_primary: String, + sort_secondary: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerSettingsUpdateRequest { + audio_backend: Option, + freq_range: Option<(u16, u16)>, + default_gain: Option, + transition_time: Option, + time_window: Option, +} + +#[derive(Debug, Deserialize)] +struct NowPlayingSettingsUpdateRequest { + drive_visualizer_palette: Option, +} + +#[derive(Debug, Deserialize)] +struct DeviceStateUpdateRequest { + power_on: Option, + brightness: Option, +} + +#[derive(Debug, Serialize)] +struct DeviceStateUpdateResponse { + device: DeviceSummary, + power_on: Option, + brightness: Option, +} + +#[derive(Debug, Serialize)] +struct PaletteEntry { + name: String, + colors: Vec<[u8; 3]>, +} + +#[derive(Debug, Serialize)] +struct PalettesResponse { + palettes: Vec, +} + +#[derive(Debug, Serialize)] +struct AudioBackendsResponse { + current_audio_backend: Option, + available_audio_backends: Vec, +} + +#[derive(Debug, Serialize)] +struct VisualizerPreviewPanelColor { + panel_id: u16, + rgb: [u8; 3], +} + +#[derive(Debug, Serialize)] +struct VisualizerPreviewResponse { + enabled: bool, + device: Option, + panel_colors: Vec, +} + +#[tokio::main] +async fn main() -> Result<()> { + let options = ApiOptions::parse(); + let ((resolved_config_path, config_exists), _) = audioleaf::config::resolve_paths( + options.config_file_path.clone(), + options.devices_file_path.clone(), + )?; + let initial_config = if config_exists { + audioleaf::config::Config::parse_from_file(&resolved_config_path)? + } else { + audioleaf::config::Config::new(None, None) + }; + let metadata_pipe_path = std::env::var("AUDIOLEAF_SHAIRPORT_METADATA_PIPE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SHAIRPORT_METADATA_PIPE.to_string()); + + let state = ApiState { + config_file_path: options.config_file_path, + devices_file_path: options.devices_file_path, + runtime_config: Arc::new(Mutex::new(initial_config)), + live_visualizer: Arc::new(Mutex::new(None)), + now_playing: Arc::new(Mutex::new(NowPlayingRuntimeState::new( + metadata_pipe_path.clone(), + ))), + }; + + if let Err(err) = restart_live_visualizer(&state).await { + eprintln!( + "WARNING: Live visualizer startup failed. API will still run, but effect changes will not be applied live: {}", + err.message + ); + } else { + println!("Live visualizer initialized."); + } + start_now_playing_reader(&state); + println!( + "Now-playing metadata reader initialized (pipe: {}).", + metadata_pipe_path + ); + + let app = Router::new() + .route("/api/health", get(get_health)) + .route("/api/config", get(get_config)) + .route("/api/config/save", post(post_config_save)) + .route("/api/config/visualizer/effect", put(put_visualizer_effect)) + .route( + "/api/config/visualizer/palette", + put(put_visualizer_palette), + ) + .route("/api/config/visualizer/sort", put(put_visualizer_sort)) + .route( + "/api/config/visualizer/settings", + put(put_visualizer_settings), + ) + .route("/api/now-playing", get(get_now_playing)) + .route("/api/now-playing/artwork", get(get_now_playing_artwork)) + .route("/api/now-playing/settings", put(put_now_playing_settings)) + .route("/api/visualizer/preview", get(get_visualizer_preview)) + .route("/api/audio/backends", get(get_audio_backends)) + .route("/api/devices", get(get_devices)) + .route("/api/devices/{name}/info", get(get_device_info)) + .route("/api/devices/{name}/layout", get(get_device_layout)) + .route("/api/devices/{name}/state", put(put_device_state)) + .route("/api/palettes", get(get_palettes)) + .with_state(state) + .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any)); + + let addr: SocketAddr = format!("{}:{}", options.host, options.port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + println!( + "Audioleaf API listening on http://{}", + listener.local_addr()? + ); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn get_health() -> Json { + Json(HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + }) +} + +async fn get_config(State(state): State) -> ApiResult { + let paths = resolve_paths(&state)?; + let config = Some(get_runtime_config_clone(&state)?); + + Ok(Json(ConfigResponse { paths, config })) +} + +async fn post_config_save(State(state): State) -> ApiResult { + let config = get_runtime_config_clone(&state)?; + let mut paths = resolve_paths(&state)?; + config + .write_to_file(PathBuf::from(&paths.config_file_path).as_path()) + .map_err(ApiError::internal)?; + paths.config_file_exists = true; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_effect( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let effect = parse_effect(&payload.effect).ok_or_else(|| { + ApiError::bad_request("Invalid effect. Use Spectrum, EnergyWave, or Pulse.") + })?; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.effect = Some(effect); + })?; + let paths = resolve_paths(&state)?; + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetEffect(effect), + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_palette( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let colors = audioleaf::palettes::get_palette(&payload.palette_name).ok_or_else(|| { + let mut names = audioleaf::palettes::get_palette_names(); + names.sort(); + ApiError::bad_request(format!( + "Unknown palette '{}'. Available: {}", + payload.palette_name, + names.join(", ") + )) + })?; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.colors = Some(colors.clone()); + })?; + let paths = resolve_paths(&state)?; + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetPalette(colors), + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_sort( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let primary_axis = parse_axis(&payload.primary_axis) + .ok_or_else(|| ApiError::bad_request("Invalid primary_axis. Use X or Y."))?; + let sort_primary = parse_sort(&payload.sort_primary) + .ok_or_else(|| ApiError::bad_request("Invalid sort_primary. Use Asc or Desc."))?; + let sort_secondary = parse_sort(&payload.sort_secondary) + .ok_or_else(|| ApiError::bad_request("Invalid sort_secondary. Use Asc or Desc."))?; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.primary_axis = Some(primary_axis); + config.visualizer_config.sort_primary = Some(sort_primary); + config.visualizer_config.sort_secondary = Some(sort_secondary); + })?; + let paths = resolve_paths(&state)?; + let live = ensure_live_visualizer(&state).await?; + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetSorting { + primary_axis, + sort_primary, + sort_secondary, + global_orientation: live.global_orientation, + }, + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_settings( + State(state): State, + Json(payload): Json, +) -> ApiResult { + if payload.audio_backend.is_none() + && payload.freq_range.is_none() + && payload.default_gain.is_none() + && payload.transition_time.is_none() + && payload.time_window.is_none() + { + return Err(ApiError::bad_request( + "Request must include at least one visualizer setting.", + )); + } + + if let Some((low, high)) = payload.freq_range + && low >= high + { + return Err(ApiError::bad_request( + "freq_range must have min < max (e.g. [20, 4500]).", + )); + } + if let Some(default_gain) = payload.default_gain + && (!default_gain.is_finite() || default_gain < 0.0) + { + return Err(ApiError::bad_request( + "default_gain must be a finite number >= 0.", + )); + } + if let Some(transition_time) = payload.transition_time + && !(1..=10).contains(&transition_time) + { + return Err(ApiError::bad_request( + "transition_time must be between 1 and 10 (0.1s to 1.0s in 100ms units).", + )); + } + if let Some(time_window) = payload.time_window + && (!time_window.is_finite() || !(0.1..=1.0).contains(&time_window)) + { + return Err(ApiError::bad_request( + "time_window must be between 0.1 and 1.0 seconds.", + )); + } + + let audio_backend = payload.audio_backend.clone(); + let freq_range = payload.freq_range; + let default_gain = payload.default_gain; + let transition_time = payload.transition_time; + let time_window = payload.time_window; + + let config = update_runtime_config(&state, |config| { + if let Some(audio_backend) = audio_backend.clone() { + config.visualizer_config.audio_backend = Some(audio_backend); + } + if let Some(freq_range) = freq_range { + config.visualizer_config.freq_range = Some(freq_range); + } + if let Some(default_gain) = default_gain { + config.visualizer_config.default_gain = Some(default_gain); + } + if let Some(transition_time) = transition_time { + config.visualizer_config.transition_time = Some(transition_time); + } + if let Some(time_window) = time_window { + config.visualizer_config.time_window = Some(time_window); + } + })?; + let paths = resolve_paths(&state)?; + + if payload.audio_backend.is_some() { + restart_live_visualizer(&state).await?; + } else { + if let Some(freq_range) = payload.freq_range { + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetFreqRange(freq_range.0, freq_range.1), + ) + .await?; + } + if let Some(default_gain) = payload.default_gain { + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetGain(default_gain), + ) + .await?; + } + if let Some(transition_time) = payload.transition_time { + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetTransitionTime(transition_time), + ) + .await?; + } + if let Some(time_window) = payload.time_window { + send_live_message_with_recovery( + &state, + audioleaf::visualizer::VisualizerMsg::SetTimeWindow(time_window), + ) + .await?; + } + } + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn get_now_playing(State(state): State) -> ApiResult { + let snapshot = current_now_playing_snapshot(&state)?; + Ok(Json(snapshot)) +} + +async fn get_now_playing_artwork( + State(state): State, +) -> std::result::Result { + let (bytes, mime_type) = { + let guard = state + .now_playing + .lock() + .map_err(|_| ApiError::internal("Now-playing state lock poisoned"))?; + let Some(bytes) = guard.artwork_bytes.clone() else { + return Err(ApiError::not_found("No album artwork available yet.")); + }; + let mime_type = guard + .artwork_mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()); + (bytes, mime_type) + }; + + let mut response = Response::new(Body::from(bytes)); + let content_type = header::HeaderValue::from_str(&mime_type) + .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")); + response + .headers_mut() + .insert(header::CONTENT_TYPE, content_type); + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + Ok(response) +} + +async fn put_now_playing_settings( + State(state): State, + Json(payload): Json, +) -> ApiResult { + if payload.drive_visualizer_palette.is_none() { + return Err(ApiError::bad_request( + "Request must include drive_visualizer_palette.", + )); + } + + let maybe_palette_to_apply = { + let mut guard = state + .now_playing + .lock() + .map_err(|_| ApiError::internal("Now-playing state lock poisoned"))?; + if let Some(enabled) = payload.drive_visualizer_palette { + guard.drive_visualizer_palette = enabled; + } + guard.updated_at_ms = Some(now_unix_ms()); + if guard.drive_visualizer_palette && !guard.palette_colors.is_empty() { + Some(guard.palette_colors.clone()) + } else { + None + } + }; + + if let Some(colors) = maybe_palette_to_apply { + apply_now_playing_palette_to_live_runtime(&state, colors); + } + + let snapshot = current_now_playing_snapshot(&state)?; + Ok(Json(snapshot)) +} + +async fn get_devices(State(state): State) -> ApiResult { + let paths = resolve_paths(&state)?; + + let devices = if paths.devices_file_exists { + audioleaf::nanoleaf::NlDevice::all_from_file( + PathBuf::from(&paths.devices_file_path).as_path(), + ) + .map_err(ApiError::internal)? + .into_iter() + .map(|device| DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }) + .collect() + } else { + Vec::new() + }; + + Ok(Json(DevicesResponse { + devices, + devices_file_path: paths.devices_file_path, + devices_file_exists: paths.devices_file_exists, + })) +} + +async fn get_device_info( + Path(name): Path, + State(state): State, +) -> ApiResult { + let device = load_device_by_name(&state, &name)?; + + let summary = DeviceSummary { + name: device.name.clone(), + ip: device.ip.to_string(), + }; + + let info = run_nanoleaf_io(move || device.get_device_info()).await?; + + Ok(Json(DeviceInfoResponse { + device: summary, + info, + })) +} + +async fn get_device_layout( + Path(name): Path, + State(state): State, +) -> ApiResult { + let device = load_device_by_name(&state, &name)?; + let layout_device = device.clone(); + + let (layout_json, orientation_json) = run_nanoleaf_io(move || { + let layout = layout_device.get_panel_layout()?; + let orientation = layout_device.get_global_orientation()?; + Ok((layout, orientation)) + }) + .await?; + + let panels = audioleaf::layout_visualizer::parse_layout(&layout_json) + .map_err(ApiError::internal)? + .into_iter() + .map(|panel| DeviceLayoutPanel { + panel_id: panel.panel_id, + x: panel.x, + y: panel.y, + orientation: panel.orientation, + shape_type_id: panel.shape_type.id, + shape_type_name: panel.shape_type.name.to_string(), + num_sides: panel.shape_type.num_sides(), + side_length: panel.shape_type.side_length, + }) + .collect(); + + let global_orientation = orientation_json["value"].as_u64().unwrap_or(0) as u16; + + Ok(Json(DeviceLayoutResponse { + device: DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }, + global_orientation, + panels, + })) +} + +async fn put_device_state( + Path(name): Path, + State(state): State, + Json(payload): Json, +) -> ApiResult { + if payload.power_on.is_none() && payload.brightness.is_none() { + return Err(ApiError::bad_request( + "Request must include `power_on` and/or `brightness`.", + )); + } + if payload + .brightness + .is_some_and(|brightness| brightness > 100) + { + return Err(ApiError::bad_request( + "`brightness` must be between 0 and 100.", + )); + } + + let device = load_device_by_name(&state, &name)?; + let write_device = device.clone(); + run_nanoleaf_io(move || write_device.set_state(payload.power_on, payload.brightness)).await?; + + Ok(Json(DeviceStateUpdateResponse { + device: DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }, + power_on: payload.power_on, + brightness: payload.brightness, + })) +} + +async fn get_palettes() -> Json { + let mut names = audioleaf::palettes::get_palette_names(); + names.sort(); + + let palettes = names + .into_iter() + .filter_map(|name| { + audioleaf::palettes::get_palette(&name).map(|colors| PaletteEntry { name, colors }) + }) + .collect(); + + Json(PalettesResponse { palettes }) +} + +async fn get_audio_backends(State(state): State) -> ApiResult { + let current_audio_backend = get_runtime_config_clone(&state)? + .visualizer_config + .audio_backend; + + let host = cpal::default_host(); + let mut available_audio_backends: Vec = match host.input_devices() { + Ok(devices) => devices + .filter_map(|device| { + device + .description() + .ok() + .map(|description| description.name().to_string()) + }) + .collect(), + Err(_) => Vec::new(), + }; + available_audio_backends.sort(); + available_audio_backends.dedup(); + + if !available_audio_backends + .iter() + .any(|name| name == audioleaf::constants::DEFAULT_AUDIO_BACKEND) + { + available_audio_backends.insert(0, audioleaf::constants::DEFAULT_AUDIO_BACKEND.to_string()); + } + + Ok(Json(AudioBackendsResponse { + current_audio_backend, + available_audio_backends, + })) +} + +async fn get_visualizer_preview( + State(state): State, +) -> ApiResult { + let Some(runtime) = current_live_visualizer(&state)? else { + return Ok(Json(VisualizerPreviewResponse { + enabled: false, + device: None, + panel_colors: Vec::new(), + })); + }; + + let colors_map = runtime + .shared_colors + .lock() + .map_err(|_| ApiError::internal("Live preview color state lock poisoned"))?; + let mut panel_colors: Vec = colors_map + .iter() + .map(|(panel_id, rgb)| VisualizerPreviewPanelColor { + panel_id: *panel_id, + rgb: *rgb, + }) + .collect(); + panel_colors.sort_by_key(|entry| entry.panel_id); + + Ok(Json(VisualizerPreviewResponse { + enabled: true, + device: Some(runtime.device), + panel_colors, + })) +} + +fn current_now_playing_snapshot(state: &ApiState) -> Result { + let guard = state + .now_playing + .lock() + .map_err(|_| ApiError::internal("Now-playing state lock poisoned"))?; + Ok(guard.snapshot()) +} + +fn start_now_playing_reader(state: &ApiState) { + let state = state.clone(); + thread::spawn(move || { + loop { + let metadata_pipe_path = match state.now_playing.lock() { + Ok(guard) => guard.metadata_pipe_path.clone(), + Err(_) => { + eprintln!("WARNING: now-playing state lock poisoned; retrying."); + thread::sleep(NOW_PLAYING_RETRY_DELAY); + continue; + } + }; + + match OpenOptions::new().read(true).open(&metadata_pipe_path) { + Ok(file) => { + if let Ok(mut guard) = state.now_playing.lock() { + guard.reader_running = true; + guard.last_error = None; + guard.updated_at_ms = Some(now_unix_ms()); + } + + let mut reader = BufReader::new(file); + let result = process_shairport_metadata_stream(&state, &mut reader); + + if let Ok(mut guard) = state.now_playing.lock() { + guard.reader_running = false; + guard.updated_at_ms = Some(now_unix_ms()); + if let Err(err) = &result { + guard.last_error = Some(err.clone()); + } + } + if let Err(err) = result { + eprintln!("WARNING: metadata stream error: {}", err); + } + } + Err(err) => { + if let Ok(mut guard) = state.now_playing.lock() { + guard.reader_running = false; + guard.last_error = Some(format!( + "Failed to open metadata pipe '{}': {}", + metadata_pipe_path, err + )); + guard.updated_at_ms = Some(now_unix_ms()); + } + } + } + + thread::sleep(NOW_PLAYING_RETRY_DELAY); + } + }); +} + +fn process_shairport_metadata_stream( + state: &ApiState, + reader: &mut R, +) -> std::result::Result<(), String> { + loop { + let mut header_line = String::new(); + let read = reader + .read_line(&mut header_line) + .map_err(|err| format!("Failed to read metadata header: {}", err))?; + if read == 0 { + return Ok(()); + } + + let trimmed = header_line.trim(); + if trimmed.is_empty() { + continue; + } + + let Some((item_type, code, payload_len)) = parse_metadata_header(trimmed) else { + continue; + }; + + let payload = if payload_len > 0 { + read_metadata_payload(reader, payload_len)? + } else { + Vec::new() + }; + + apply_metadata_item_to_state(state, &item_type, &code, payload); + } +} + +fn parse_metadata_header(line: &str) -> Option<(String, String, usize)> { + let mut type_hex: Option<&str> = None; + let mut code_hex: Option<&str> = None; + let mut payload_len: Option = None; + + for token in line.split(|ch: char| !ch.is_ascii_alphanumeric()) { + if token.is_empty() { + continue; + } + if type_hex.is_none() && token.len() == 8 && token.chars().all(|ch| ch.is_ascii_hexdigit()) + { + type_hex = Some(token); + continue; + } + if type_hex.is_some() + && code_hex.is_none() + && token.len() == 8 + && token.chars().all(|ch| ch.is_ascii_hexdigit()) + { + code_hex = Some(token); + continue; + } + if type_hex.is_some() + && code_hex.is_some() + && payload_len.is_none() + && token.chars().all(|ch| ch.is_ascii_digit()) + { + payload_len = token.parse::().ok(); + break; + } + } + + let item_type = decode_fourcc(type_hex?)?.to_ascii_lowercase(); + let code = decode_fourcc(code_hex?)?; + Some((item_type, code, payload_len?)) +} + +fn read_metadata_payload( + reader: &mut R, + payload_len: usize, +) -> std::result::Result, String> { + let mut separator = String::new(); + let separator_read = reader + .read_line(&mut separator) + .map_err(|err| format!("Failed to read payload separator: {}", err))?; + if separator_read == 0 { + return Err("Unexpected EOF before payload.".to_string()); + } + + let base64_line = if separator.trim().is_empty() { + let mut line = String::new(); + let payload_read = reader + .read_line(&mut line) + .map_err(|err| format!("Failed to read base64 payload: {}", err))?; + if payload_read == 0 { + return Err("Unexpected EOF while reading payload.".to_string()); + } + line + } else { + separator + }; + + let mut decoded = base64::engine::general_purpose::STANDARD + .decode(base64_line.trim()) + .map_err(|err| format!("Failed to decode base64 payload: {}", err))?; + if decoded.len() > payload_len { + decoded.truncate(payload_len); + } + + let mut trailing_line = String::new(); + let _ = reader.read_line(&mut trailing_line); + Ok(decoded) +} + +fn apply_metadata_item_to_state(state: &ApiState, item_type: &str, code: &str, payload: Vec) { + let payload_text = payload_bytes_to_string(&payload); + let mut maybe_palette_to_apply: Option> = None; + + if let Ok(mut guard) = state.now_playing.lock() { + guard.reader_running = true; + guard.last_error = None; + guard.updated_at_ms = Some(now_unix_ms()); + + if item_type == "core" { + if code.eq_ignore_ascii_case("minm") { + guard.track.title = payload_text; + } else if code.eq_ignore_ascii_case("asar") { + guard.track.artist = payload_text; + } else if code.eq_ignore_ascii_case("asal") { + guard.track.album = payload_text; + } else if code.eq_ignore_ascii_case("asul") { + guard.track.stream_url = payload_text; + } + } else if item_type == "ssnc" { + if code.eq_ignore_ascii_case("snam") { + guard.track.source_name = payload_text; + } else if code.eq_ignore_ascii_case("snua") { + guard.track.user_agent = payload_text; + } else if code.eq_ignore_ascii_case("clip") || code.eq_ignore_ascii_case("conn") { + guard.track.source_ip = payload_text; + } else if code.eq_ignore_ascii_case("PICT") { + if !payload.is_empty() { + let colors = extract_prominent_colors(&payload).unwrap_or_default(); + guard.artwork_mime_type = detect_image_mime_type(&payload).map(str::to_string); + guard.artwork_bytes = Some(payload); + guard.artwork_generation = guard.artwork_generation.saturating_add(1); + guard.palette_colors = colors.clone(); + if guard.drive_visualizer_palette && !colors.is_empty() { + maybe_palette_to_apply = Some(colors); + } + } + } else if code.eq_ignore_ascii_case("pbeg") + || code.eq_ignore_ascii_case("pend") + || code.eq_ignore_ascii_case("pfls") + || code.eq_ignore_ascii_case("disc") + { + guard.clear_session_data(); + } + } + } + + if let Some(colors) = maybe_palette_to_apply { + apply_now_playing_palette_to_live_runtime(state, colors); + } +} + +fn payload_bytes_to_string(payload: &[u8]) -> Option { + if payload.is_empty() { + return None; + } + let value = String::from_utf8_lossy(payload) + .trim_matches('\0') + .trim() + .to_string(); + if value.is_empty() { None } else { Some(value) } +} + +fn decode_fourcc(hex_value: &str) -> Option { + let raw = u32::from_str_radix(hex_value, 16).ok()?.to_be_bytes(); + Some(raw.iter().map(|byte| *byte as char).collect()) +} + +fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("image/jpeg"); + } + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + return Some("image/png"); + } + if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Some("image/webp"); + } + None +} + +fn extract_prominent_colors(image_bytes: &[u8]) -> Option> { + use auto_palette::{ImageData, Palette}; + + let image = image::load_from_memory(image_bytes).ok()?; + let rgba = image.to_rgba8(); + let image_data = ImageData::new(rgba.width(), rgba.height(), rgba.as_raw()).ok()?; + let palette: Palette = Palette::extract(&image_data).ok()?; + let mut swatches = palette.swatches().to_vec(); + swatches.sort_by_key(|swatch| std::cmp::Reverse(swatch.population())); + + let colors: Vec<[u8; 3]> = swatches + .iter() + .filter(|swatch| swatch.color().to_oklch().l > 0.15) + .take(4) + .map(|swatch| { + let rgb = swatch.color().to_rgb(); + [rgb.r, rgb.g, rgb.b] + }) + .collect(); + + if colors.is_empty() { + None + } else { + Some(colors) + } +} + +fn apply_now_playing_palette_to_live_runtime(state: &ApiState, colors: Vec<[u8; 3]>) { + if colors.is_empty() { + return; + } + + let Ok(Some(runtime)) = current_live_visualizer(state) else { + return; + }; + + if runtime + .sender + .send(audioleaf::visualizer::VisualizerMsg::SetPalette( + colors.clone(), + )) + .is_ok() + { + return; + } + + if let Err(err) = restart_live_visualizer_sync(state) { + eprintln!( + "WARNING: failed to restart live visualizer while applying metadata palette: {}", + err.message + ); + return; + } + + if let Ok(Some(restarted)) = current_live_visualizer(state) { + let _ = restarted + .sender + .send(audioleaf::visualizer::VisualizerMsg::SetPalette(colors)); + } +} + +fn now_unix_ms() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(_) => 0, + } +} + +fn resolve_paths(state: &ApiState) -> Result { + let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = + audioleaf::config::resolve_paths( + state.config_file_path.clone(), + state.devices_file_path.clone(), + ) + .map_err(ApiError::internal)?; + + Ok(PathsResponse { + config_file_path: config_file_path.to_string_lossy().into_owned(), + config_file_exists, + devices_file_path: devices_file_path.to_string_lossy().into_owned(), + devices_file_exists, + }) +} + +fn load_device_by_name( + state: &ApiState, + name: &str, +) -> Result { + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Err(ApiError::not_found(format!( + "No devices file found at {}", + paths.devices_file_path + ))); + } + let devices_path = PathBuf::from(&paths.devices_file_path); + audioleaf::nanoleaf::NlDevice::find_in_file(&devices_path, Some(name)) + .map_err(|err| ApiError::not_found(err.to_string())) +} + +fn get_runtime_config_clone(state: &ApiState) -> Result { + let guard = state + .runtime_config + .lock() + .map_err(|_| ApiError::internal("Runtime config lock poisoned"))?; + Ok(guard.clone()) +} + +fn update_runtime_config( + state: &ApiState, + updater: F, +) -> Result +where + F: FnOnce(&mut audioleaf::config::Config), +{ + let mut guard = state + .runtime_config + .lock() + .map_err(|_| ApiError::internal("Runtime config lock poisoned"))?; + updater(&mut guard); + Ok(guard.clone()) +} + +fn current_live_visualizer(state: &ApiState) -> Result, ApiError> { + let guard = state + .live_visualizer + .lock() + .map_err(|_| ApiError::internal("Live visualizer state lock poisoned"))?; + Ok(guard.clone()) +} + +async fn ensure_live_visualizer(state: &ApiState) -> Result { + if let Some(runtime) = current_live_visualizer(state)? { + return Ok(runtime); + } + + restart_live_visualizer(state).await?; + current_live_visualizer(state)? + .ok_or_else(|| ApiError::internal("Live visualizer failed to initialize")) +} + +async fn send_live_message_with_recovery( + state: &ApiState, + message: audioleaf::visualizer::VisualizerMsg, +) -> Result<(), ApiError> { + let live = ensure_live_visualizer(state).await?; + if live.sender.send(message.clone()).is_ok() { + return Ok(()); + } + + restart_live_visualizer(state).await?; + let restarted = ensure_live_visualizer(state).await?; + restarted + .sender + .send(message) + .map_err(|_| ApiError::internal("Failed to send command to live visualizer")) +} + +async fn restart_live_visualizer(state: &ApiState) -> Result<(), ApiError> { + let state = state.clone(); + tokio::task::spawn_blocking(move || restart_live_visualizer_sync(&state)) + .await + .map_err(handle_join_error)? +} + +fn restart_live_visualizer_sync(state: &ApiState) -> Result<(), ApiError> { + let new_runtime = build_live_visualizer(state)?; + let old_runtime = { + let mut guard = state + .live_visualizer + .lock() + .map_err(|_| ApiError::internal("Live visualizer state lock poisoned"))?; + guard.replace(new_runtime) + }; + + if let Some(old_runtime) = old_runtime { + let _ = old_runtime + .sender + .send(audioleaf::visualizer::VisualizerMsg::End); + } + Ok(()) +} + +fn build_live_visualizer(state: &ApiState) -> Result { + let config = get_runtime_config_clone(state)?; + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Err(ApiError::not_found(format!( + "No devices file found at {}", + paths.devices_file_path + ))); + } + + let devices_path = PathBuf::from(&paths.devices_file_path); + let known_devices = + audioleaf::nanoleaf::NlDevice::all_from_file(&devices_path).map_err(ApiError::internal)?; + if known_devices.is_empty() { + return Err(ApiError::not_found(format!( + "No Nanoleaf devices found in {}", + paths.devices_file_path + ))); + } + + let preferred_name = config.default_nl_device_name.clone(); + let nl_device = if let Some(default_name) = preferred_name.as_deref() { + match known_devices + .iter() + .find(|device| device.name == default_name) + { + Some(device) => device.clone(), + None => { + let fallback = known_devices[0].clone(); + eprintln!( + "WARNING: default_nl_device_name '{}' not found. Falling back to '{}'.", + default_name, fallback.name + ); + fallback + } + } + } else { + known_devices[0].clone() + }; + + nl_device + .ensure_device_ready() + .map_err(ApiError::internal)?; + nl_device + .request_udp_control() + .map_err(ApiError::internal)?; + + let global_orientation = nl_device + .get_global_orientation() + .ok() + .and_then(|orientation| orientation["value"].as_u64()) + .unwrap_or(0) as u16; + + let configured_backend = config.visualizer_config.audio_backend.clone(); + let audio_stream = match audioleaf::audio::AudioStream::new(configured_backend.as_deref()) { + Ok(stream) => stream, + Err(primary_err) => { + let should_try_default = configured_backend + .as_deref() + .is_some_and(|name| name != audioleaf::constants::DEFAULT_AUDIO_BACKEND); + if !should_try_default { + return Err(ApiError::internal(primary_err)); + } + + eprintln!( + "WARNING: Failed to initialize audio backend '{}': {}. Falling back to '{}'.", + configured_backend.as_deref().unwrap_or("unknown"), + primary_err, + audioleaf::constants::DEFAULT_AUDIO_BACKEND + ); + audioleaf::audio::AudioStream::new(Some(audioleaf::constants::DEFAULT_AUDIO_BACKEND)) + .map_err(ApiError::internal)? + } + }; + + let shared_colors = Arc::new(Mutex::new(HashMap::new())); + let sender = audioleaf::visualizer::Visualizer::new( + config.visualizer_config, + audio_stream, + &nl_device, + Arc::clone(&shared_colors), + ) + .map_err(ApiError::internal)? + .init(); + + println!( + "Live visualizer attached to '{}' at {}", + nl_device.name, nl_device.ip + ); + + Ok(LiveVisualizerRuntime { + sender, + global_orientation, + device: DeviceSummary { + name: nl_device.name, + ip: nl_device.ip.to_string(), + }, + shared_colors, + }) +} + +fn parse_axis(input: &str) -> Option { + if input.eq_ignore_ascii_case("x") { + Some(audioleaf::config::Axis::X) + } else if input.eq_ignore_ascii_case("y") { + Some(audioleaf::config::Axis::Y) + } else { + None + } +} + +fn parse_sort(input: &str) -> Option { + if input.eq_ignore_ascii_case("asc") { + Some(audioleaf::config::Sort::Asc) + } else if input.eq_ignore_ascii_case("desc") { + Some(audioleaf::config::Sort::Desc) + } else { + None + } +} + +fn parse_effect(input: &str) -> Option { + match input { + x if x.eq_ignore_ascii_case("spectrum") => Some(audioleaf::config::Effect::Spectrum), + x if x.eq_ignore_ascii_case("energywave") + || x.eq_ignore_ascii_case("energy_wave") + || x.eq_ignore_ascii_case("energy-wave") => + { + Some(audioleaf::config::Effect::EnergyWave) + } + x if x.eq_ignore_ascii_case("pulse") => Some(audioleaf::config::Effect::Pulse), + _ => None, + } +} + +async fn run_nanoleaf_io(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(handle_join_error)? + .map_err(ApiError::internal) +} + +fn handle_join_error(err: JoinError) -> ApiError { + ApiError::internal(format!("Background I/O task failed: {err}")) +} diff --git a/src/config.rs b/src/config.rs index 0e8c89c..bed4690 100644 --- a/src/config.rs +++ b/src/config.rs @@ -86,7 +86,7 @@ pub enum Effect { Pulse, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct VisualizerConfig { pub audio_backend: Option, pub freq_range: Option<(u16, u16)>, @@ -135,7 +135,7 @@ impl VisualizerConfig { } } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct Config { pub default_nl_device_name: Option, pub visualizer_config: VisualizerConfig, diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6c12df6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod audio; +pub mod config; +pub mod constants; +pub mod layout_visualizer; +pub mod nanoleaf; +pub mod palettes; +pub mod panic; +pub mod processing; +pub mod ssdp; +pub mod utils; +pub mod visualizer; diff --git a/src/nanoleaf.rs b/src/nanoleaf.rs index f05010d..c6b34d6 100644 --- a/src/nanoleaf.rs +++ b/src/nanoleaf.rs @@ -278,6 +278,15 @@ impl NlDevice { } } + #[allow(dead_code)] + pub fn all_from_file(path: &Path) -> Result> { + let mut devices_file = File::open(path)?; + let mut contents = String::new(); + devices_file.read_to_string(&mut contents)?; + let devices: NlDevices = toml::from_str(&contents)?; + Ok(devices.nl_devices) + } + pub fn append_to_file(&self, path: &Path) -> Result<()> { // Create parent directory if it doesn't exist if let Some(parent) = path.parent() { diff --git a/src/now_playing.rs b/src/now_playing.rs index 5c18cac..dacb329 100644 --- a/src/now_playing.rs +++ b/src/now_playing.rs @@ -59,7 +59,7 @@ pub fn fetch_artwork_and_palette() -> Option<(Vec, Vec<[u8; 3]>)> { mod macos { use objc2::msg_send; use objc2::rc::{Retained, autoreleasepool}; - use objc2::runtime::{AnyClass, AnyObject}; + use objc2::runtime::{AnyClass, AnyObject, Sel}; use objc2_foundation::NSString; #[link(name = "ScriptingBridge", kind = "framework")] @@ -96,6 +96,64 @@ mod macos { } } + /// Check if an ObjC object responds to `length` and `bytes` (i.e. is NSData). + fn is_nsdata(obj: *mut AnyObject) -> bool { + unsafe { + let sel_length = Sel::register(c"length"); + let sel_bytes = Sel::register(c"bytes"); + let has_length: bool = msg_send![obj, respondsToSelector: sel_length]; + let has_bytes: bool = msg_send![obj, respondsToSelector: sel_bytes]; + has_length && has_bytes + } + } + + /// Check if an ObjC object responds to a given selector. + fn responds_to(obj: *mut AnyObject, sel: &str) -> bool { + unsafe { + let cstr = std::ffi::CString::new(sel).unwrap(); + let sel = Sel::register(&cstr); + msg_send![obj, respondsToSelector: sel] + } + } + + /// If `obj` is an NSImage, extract bytes via TIFFRepresentation; otherwise return None. + fn nsimage_to_bytes(obj: *mut AnyObject) -> Option> { + unsafe { + if !responds_to(obj, "TIFFRepresentation") { + return None; + } + let tiff: *mut AnyObject = msg_send![obj, TIFFRepresentation]; + if tiff.is_null() { + return None; + } + if !is_nsdata(tiff) { + return None; + } + let len: usize = msg_send![tiff, length]; + if len == 0 { + return None; + } + let ptr: *const u8 = msg_send![tiff, bytes]; + if ptr.is_null() { + return None; + } + Some(std::slice::from_raw_parts(ptr, len).to_vec()) + } + } + + /// Resolve an SBObject proxy by calling `get`, or return the object as-is + /// if it doesn't respond to `get` (i.e. it's already materialized). + fn resolve_proxy(obj: *mut AnyObject) -> *mut AnyObject { + if obj.is_null() { + return obj; + } + if responds_to(obj, "get") { + unsafe { msg_send![obj, get] } + } else { + obj + } + } + /// Check if an app's player state is "playing" (four-char code 'kPSP'). fn is_playing(app: &AnyObject) -> bool { unsafe { @@ -189,50 +247,74 @@ mod macos { artwork.is_null() ); if !artwork.is_null() { - // Properties on SBObject return lazy proxies — call `get` - // to force the Apple Event and materialize the real object. + // Properties on SBObject may return lazy proxies or + // already-materialized objects (e.g. NSImage). Use + // resolve_proxy to call `get` only when supported. // Try rawData first let raw_proxy: *mut AnyObject = msg_send![artwork, rawData]; if !raw_proxy.is_null() { - let raw: *mut AnyObject = msg_send![raw_proxy, get]; - debug_log!("DEBUG now_playing: rawData.get null = {}", raw.is_null()); + let raw = resolve_proxy(raw_proxy); + debug_log!( + "DEBUG now_playing: rawData resolved null = {}", + raw.is_null() + ); if !raw.is_null() { - let len: usize = msg_send![raw, length]; - debug_log!("DEBUG now_playing: rawData length = {}", len); - if len > 0 { - let ptr: *const u8 = msg_send![raw, bytes]; - if !ptr.is_null() { - let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); - eprintln!( - "DEBUG now_playing: rawData artwork {} bytes", - bytes.len() - ); - return Some(bytes); + // It might be NSData directly + if is_nsdata(raw) { + let len: usize = msg_send![raw, length]; + debug_log!("DEBUG now_playing: rawData length = {}", len); + if len > 0 { + let ptr: *const u8 = msg_send![raw, bytes]; + if !ptr.is_null() { + let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); + eprintln!( + "DEBUG now_playing: rawData artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } } } + // It might be an NSImage + if let Some(bytes) = nsimage_to_bytes(raw) { + eprintln!( + "DEBUG now_playing: rawData NSImage artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } } } // Try data property (MusicPicture) let data_proxy: *mut AnyObject = msg_send![artwork, data]; if !data_proxy.is_null() { - let data: *mut AnyObject = msg_send![data_proxy, get]; - debug_log!("DEBUG now_playing: data.get null = {}", data.is_null()); + let data = resolve_proxy(data_proxy); + debug_log!("DEBUG now_playing: data resolved null = {}", data.is_null()); if !data.is_null() { - let len: usize = msg_send![data, length]; - debug_log!("DEBUG now_playing: data length = {}", len); - if len > 0 { - let ptr: *const u8 = msg_send![data, bytes]; - if !ptr.is_null() { - let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); - eprintln!( - "DEBUG now_playing: data artwork {} bytes", - bytes.len() - ); - return Some(bytes); + if is_nsdata(data) { + let len: usize = msg_send![data, length]; + debug_log!("DEBUG now_playing: data length = {}", len); + if len > 0 { + let ptr: *const u8 = msg_send![data, bytes]; + if !ptr.is_null() { + let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); + eprintln!( + "DEBUG now_playing: data artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } } } + if let Some(bytes) = nsimage_to_bytes(data) { + eprintln!( + "DEBUG now_playing: data NSImage artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } } } } diff --git a/src/visualizer.rs b/src/visualizer.rs index dddd74e..3aa50e0 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -25,7 +25,7 @@ enum VisualizerState { Done, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum VisualizerMsg { End, SetGain(f32), diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..a4d44d5 --- /dev/null +++ b/web/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..816fcca --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Audioleaf Control Panel + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8a24451 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "audioleaf-web", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@10.33.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", + "preview": "vite preview", + "check": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.9", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..3c6da92 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1585 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.5) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + devDependencies: + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.9) + postcss: + specifier: ^8.5.9 + version: 8.5.9 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.18: + resolution: {integrity: sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + electron-to-chromium@1.5.335: + resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.124.0': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.15': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@1.21.7))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@1.21.7) + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + autoprefixer@10.5.0(postcss@8.5.9): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001787 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.9 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.18: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.18 + caniuse-lite: 1.0.30001787 + electron-to-chromium: 1.5.335 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001787: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + commander@4.1.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + electron-to-chromium@1.5.335: {} + + es-errors@1.3.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + optional: true + + escalade@3.2.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + node-releases@2.0.37: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.9): + dependencies: + postcss: 8.5.9 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.9): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.9 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.9): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.9 + + postcss-nested@6.2.0(postcss@8.5.9): + dependencies: + postcss: 8.5.9 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + queue-microtask@1.2.3: {} + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react@19.2.5: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rolldown@1.0.0-rc.15: + dependencies: + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.9 + postcss-import: 15.1.0(postcss@8.5.9) + postcss-js: 4.1.0(postcss@8.5.9) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.9) + postcss-nested: 6.2.0(postcss@8.5.9) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.2: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@1.21.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.9 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 1.21.7 diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..4f11e18 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,1713 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + api, + type AudioBackendsResponse, + type ConfigResponse, + type DeviceInfoResponse, + type DeviceLayoutPanel, + type DeviceLayoutResponse, + type DevicesResponse, + type DeviceStateUpdateRequest, + type NowPlayingResponse, + type VisualizerSettingsUpdateRequest, + type VisualizerSortUpdateRequest, + type HealthResponse, + type PaletteEntry, + type PalettesResponse, +} from "@/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +type LoadState = "idle" | "loading" | "ready" | "error"; +const DEFAULT_BRIGHTNESS_DRAFT = "50"; +const EFFECT_OPTIONS = ["Spectrum", "EnergyWave", "Pulse"] as const; +type EffectOption = (typeof EFFECT_OPTIONS)[number]; + +function isValidBrightnessInput(value: string): boolean { + return /^\d{0,3}$/.test(value); +} + +function parseBrightness(value: string): number | null { + if (value.trim() === "") { + return null; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + return null; + } + return parsed; +} + +function parseInteger(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isInteger(parsed)) { + return null; + } + return parsed; +} + +function parseNonNegativeFloat(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +function parsePositiveFloat(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function formatTenths(value: number): string { + return (Math.round(value * 10) / 10).toFixed(1); +} + +function extractBrightnessFromInfo(info: Record): number | null { + const state = info.state; + if (!state || typeof state !== "object") { + return null; + } + const brightness = (state as Record).brightness; + if (!brightness || typeof brightness !== "object") { + return null; + } + const value = (brightness as Record).value; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + return null; + } + return parsed; +} + +function normalizeEffect(value: string | null | undefined): EffectOption { + if (value === "EnergyWave") { + return "EnergyWave"; + } + if (value === "Pulse") { + return "Pulse"; + } + return "Spectrum"; +} + +function inferPaletteName( + colors: Array<[number, number, number]> | null | undefined, + palettes: PaletteEntry[], +): string | null { + if (!colors?.length) { + return null; + } + + for (const palette of palettes) { + if (palette.colors.length !== colors.length) { + continue; + } + const allEqual = palette.colors.every((color, index) => { + const current = colors[index]; + return ( + color[0] === current[0] && color[1] === current[1] && color[2] === current[2] + ); + }); + if (allEqual) { + return palette.name; + } + } + return null; +} + +function resolveInitialLayoutDeviceName( + nextConfig: ConfigResponse, + nextDevices: DevicesResponse, +): string | null { + const configured = nextConfig.config?.default_nl_device_name; + if (configured && nextDevices.devices.some((device) => device.name === configured)) { + return configured; + } + return nextDevices.devices[0]?.name ?? null; +} + +function App() { + const [loadState, setLoadState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const [updatingDeviceName, setUpdatingDeviceName] = useState(null); + const [loadingDeviceName, setLoadingDeviceName] = useState(null); + const [savingConfigSection, setSavingConfigSection] = useState< + "effect" | "palette" | "sort" | "settings" | "persist" | null + >(null); + const [brightnessDraftByDevice, setBrightnessDraftByDevice] = useState< + Record + >({}); + const [effectDraft, setEffectDraft] = useState("Spectrum"); + const [paletteDraft, setPaletteDraft] = useState(""); + const [sortDraft, setSortDraft] = useState({ + primary_axis: "Y", + sort_primary: "Asc", + sort_secondary: "Asc", + }); + const [settingsDraft, setSettingsDraft] = useState({ + audio_backend: "default", + freq_min: "20", + freq_max: "4500", + default_gain: "1", + transition_time: "0.2", + time_window: "0.2", + }); + const [audioBackends, setAudioBackends] = useState(["default"]); + const [showLivePreview, setShowLivePreview] = useState(false); + const [livePreviewDeviceName, setLivePreviewDeviceName] = useState(null); + const [livePreviewColorsByPanel, setLivePreviewColorsByPanel] = useState< + Record + >({}); + const [nowPlaying, setNowPlaying] = useState(null); + const brightnessCommitTimersRef = useRef>({}); + const lastAppliedBrightnessRef = useRef>({}); + + const [health, setHealth] = useState(null); + const [config, setConfig] = useState(null); + const [devices, setDevices] = useState(null); + const [palettes, setPalettes] = useState(null); + const [selectedDeviceInfo, setSelectedDeviceInfo] = + useState(null); + const [selectedDeviceLayout, setSelectedDeviceLayout] = + useState(null); + + const visualizerConfig = config?.config?.visualizer_config; + const availableBackendOptions = Array.from( + new Set([settingsDraft.audio_backend, ...audioBackends].filter((name) => name.trim().length)), + ); + + useEffect(() => { + let isMounted = true; + + async function loadData() { + try { + setLoadState("loading"); + setErrorMessage(null); + + const [healthData, configData, devicesData, palettesData] = await Promise.all([ + api.health(), + api.config(), + api.devices(), + api.palettes(), + ]); + let nowPlayingData: NowPlayingResponse | null = null; + try { + nowPlayingData = await api.nowPlaying(); + } catch { + // Keep the dashboard usable if now-playing metadata is unavailable. + } + const brightnessEntries = await Promise.all( + devicesData.devices.map(async (device) => { + try { + const info = await api.deviceInfo(device.name); + const currentBrightness = extractBrightnessFromInfo(info.info); + return [ + device.name, + String(currentBrightness ?? Number(DEFAULT_BRIGHTNESS_DRAFT)), + ] as const; + } catch { + return [device.name, DEFAULT_BRIGHTNESS_DRAFT] as const; + } + }), + ); + const brightnessDraftMap = Object.fromEntries(brightnessEntries); + const brightnessAppliedMap = Object.fromEntries( + brightnessEntries.map(([name, value]) => [ + name, + parseBrightness(value) ?? Number(DEFAULT_BRIGHTNESS_DRAFT), + ]), + ); + const initialLayoutDeviceName = resolveInitialLayoutDeviceName(configData, devicesData); + let initialDeviceInfo: DeviceInfoResponse | null = null; + let initialDeviceLayout: DeviceLayoutResponse | null = null; + if (initialLayoutDeviceName) { + try { + [initialDeviceInfo, initialDeviceLayout] = await Promise.all([ + api.deviceInfo(initialLayoutDeviceName), + api.deviceLayout(initialLayoutDeviceName), + ]); + } catch { + // Keep dashboard usable even if initial layout preload fails. + } + } + let audioBackendsData: AudioBackendsResponse = { + current_audio_backend: configData.config?.visualizer_config.audio_backend ?? null, + available_audio_backends: ["default"], + }; + try { + audioBackendsData = await api.audioBackends(); + } catch { + // Keep UI usable even if backend enumeration is unavailable. + } + + if (!isMounted) { + return; + } + + setHealth(healthData); + setConfig(configData); + setDevices(devicesData); + setBrightnessDraftByDevice(brightnessDraftMap); + lastAppliedBrightnessRef.current = brightnessAppliedMap; + setSelectedDeviceInfo(initialDeviceInfo); + setSelectedDeviceLayout(initialDeviceLayout); + const availableBackends = + audioBackendsData.available_audio_backends.length > 0 + ? audioBackendsData.available_audio_backends + : ["default"]; + setAudioBackends(availableBackends); + setPalettes(palettesData); + setNowPlaying(nowPlayingData); + hydrateVisualizerDrafts( + configData, + palettesData.palettes, + availableBackends, + ); + setLoadState("ready"); + } catch (error) { + if (!isMounted) { + return; + } + setLoadState("error"); + setErrorMessage( + error instanceof Error ? error.message : "Unknown error contacting API", + ); + } + } + + void loadData(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + return () => { + for (const timerId of Object.values(brightnessCommitTimersRef.current)) { + window.clearTimeout(timerId); + } + brightnessCommitTimersRef.current = {}; + }; + }, []); + + useEffect(() => { + if (!showLivePreview) { + setLivePreviewColorsByPanel({}); + setLivePreviewDeviceName(null); + return; + } + + let cancelled = false; + let timerId: number | undefined; + const pollPreview = async () => { + try { + const preview = await api.visualizerPreview(); + if (cancelled) { + return; + } + setLivePreviewDeviceName(preview.device?.name ?? null); + setLivePreviewColorsByPanel( + Object.fromEntries(preview.panel_colors.map((entry) => [entry.panel_id, entry.rgb])), + ); + } catch { + if (!cancelled) { + setLivePreviewColorsByPanel({}); + setLivePreviewDeviceName(null); + } + } finally { + if (!cancelled) { + timerId = window.setTimeout(() => void pollPreview(), 180); + } + } + }; + + void pollPreview(); + + return () => { + cancelled = true; + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; + }, [showLivePreview]); + + useEffect(() => { + let cancelled = false; + let timerId: number | undefined; + const pollNowPlaying = async () => { + try { + const snapshot = await api.nowPlaying(); + if (!cancelled) { + setNowPlaying(snapshot); + } + } catch { + // Keep previous now-playing snapshot visible if polling fails. + } finally { + if (!cancelled) { + timerId = window.setTimeout(() => void pollNowPlaying(), 1200); + } + } + }; + + void pollNowPlaying(); + + return () => { + cancelled = true; + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; + }, []); + + async function handleLoadDeviceDetails(name: string) { + try { + setErrorMessage(null); + setLoadingDeviceName(name); + const [info, layout] = await Promise.all([ + api.deviceInfo(name), + api.deviceLayout(name), + ]); + setSelectedDeviceInfo(info); + setSelectedDeviceLayout(layout); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to load device info and layout", + ); + } finally { + setLoadingDeviceName(null); + } + } + + async function handleSetState( + name: string, + payload: DeviceStateUpdateRequest, + actionLabel: string, + ) { + try { + setErrorMessage(null); + setActionMessage(null); + setUpdatingDeviceName(name); + await api.setDeviceState(name, payload); + setActionMessage(`${actionLabel} applied on ${name}`); + + if (selectedDeviceInfo?.device.name === name) { + const refreshedInfo = await api.deviceInfo(name); + setSelectedDeviceInfo(refreshedInfo); + } + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update device state", + ); + } finally { + setUpdatingDeviceName(null); + } + } + + function hydrateVisualizerDrafts( + nextConfig: ConfigResponse, + nextPalettes: PaletteEntry[], + nextAudioBackends: string[], + ) { + const visualizer = nextConfig.config?.visualizer_config; + setEffectDraft(normalizeEffect(visualizer?.effect)); + const inferredPalette = inferPaletteName(visualizer?.colors, nextPalettes); + setPaletteDraft(inferredPalette ?? ""); + setSortDraft({ + primary_axis: visualizer?.primary_axis === "X" ? "X" : "Y", + sort_primary: visualizer?.sort_primary === "Desc" ? "Desc" : "Asc", + sort_secondary: visualizer?.sort_secondary === "Desc" ? "Desc" : "Asc", + }); + + const configuredBackend = visualizer?.audio_backend ?? "default"; + const resolvedBackend = configuredBackend.trim().length > 0 ? configuredBackend : "default"; + setSettingsDraft({ + audio_backend: resolvedBackend, + freq_min: String(visualizer?.freq_range?.[0] ?? 20), + freq_max: String(visualizer?.freq_range?.[1] ?? 4500), + default_gain: String(visualizer?.default_gain ?? 1), + transition_time: formatTenths((visualizer?.transition_time ?? 2) / 10), + time_window: formatTenths(visualizer?.time_window ?? 0.1875), + }); + + if ( + resolvedBackend !== "default" && + !nextAudioBackends.includes(resolvedBackend) && + nextAudioBackends.includes("default") + ) { + setActionMessage((prev) => + prev ?? `Configured audio backend "${resolvedBackend}" is not currently available.`, + ); + } + } + + async function applyEffect(nextEffect: EffectOption) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("effect"); + const updated = await api.setVisualizerEffect(nextEffect); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(`Effect set to ${nextEffect}`); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update effect"); + } finally { + setSavingConfigSection(null); + } + } + + async function applyPalette(nextPalette: string) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("palette"); + const updated = await api.setVisualizerPalette(nextPalette); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(`Palette set to ${nextPalette}`); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update palette"); + } finally { + setSavingConfigSection(null); + } + } + + async function applySort(nextSort: VisualizerSortUpdateRequest) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("sort"); + const updated = await api.setVisualizerSort(nextSort); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage( + `Sort updated (${nextSort.primary_axis}, ${nextSort.sort_primary}/${nextSort.sort_secondary})`, + ); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update sort"); + } finally { + setSavingConfigSection(null); + } + } + + async function applySettingsPatch( + payload: VisualizerSettingsUpdateRequest, + successMessage: string, + ) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("settings"); + const updated = await api.setVisualizerSettings(payload); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(successMessage); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update visualizer settings", + ); + } finally { + setSavingConfigSection(null); + } + } + + function handleEffectChange(nextEffect: EffectOption) { + setEffectDraft(nextEffect); + if (nextEffect === effectDraft) { + return; + } + void applyEffect(nextEffect); + } + + function handlePaletteChange(nextPalette: string) { + setPaletteDraft(nextPalette); + if (!nextPalette) { + setErrorMessage("Current colors are custom. Select a named palette to apply."); + return; + } + if (nextPalette === paletteDraft) { + return; + } + void applyPalette(nextPalette); + } + + function handleSortAxisChange(primary_axis: "X" | "Y") { + setSortDraft((prev) => { + if (prev.primary_axis === primary_axis) { + return prev; + } + const next = { ...prev, primary_axis }; + void applySort(next); + return next; + }); + } + + function handleSortPrimaryChange(sort_primary: "Asc" | "Desc") { + setSortDraft((prev) => { + if (prev.sort_primary === sort_primary) { + return prev; + } + const next = { ...prev, sort_primary }; + void applySort(next); + return next; + }); + } + + function handleSortSecondaryChange(sort_secondary: "Asc" | "Desc") { + setSortDraft((prev) => { + if (prev.sort_secondary === sort_secondary) { + return prev; + } + const next = { ...prev, sort_secondary }; + void applySort(next); + return next; + }); + } + + function handleAudioBackendChange(nextBackend: string) { + setSettingsDraft((prev) => ({ + ...prev, + audio_backend: nextBackend, + })); + if ((visualizerConfig?.audio_backend ?? "default") === nextBackend) { + return; + } + void applySettingsPatch( + { audio_backend: nextBackend }, + `Audio backend set to ${nextBackend}.`, + ); + } + + function handleFreqRangeBlur() { + const freqMin = parseInteger(settingsDraft.freq_min); + const freqMax = parseInteger(settingsDraft.freq_max); + if (freqMin === null || freqMax === null) { + setErrorMessage("Frequency range must use integer values."); + return; + } + if (freqMin < 0 || freqMax < 0 || freqMin > 65535 || freqMax > 65535 || freqMin >= freqMax) { + setErrorMessage("Frequency range must be 0-65535 with min < max."); + return; + } + const currentMin = visualizerConfig?.freq_range?.[0] ?? 20; + const currentMax = visualizerConfig?.freq_range?.[1] ?? 4500; + if (currentMin === freqMin && currentMax === freqMax) { + return; + } + void applySettingsPatch( + { freq_range: [freqMin, freqMax] }, + `Frequency range set to ${freqMin}-${freqMax} Hz.`, + ); + } + + function handleDefaultGainBlur() { + const defaultGain = parseNonNegativeFloat(settingsDraft.default_gain); + if (defaultGain === null) { + setErrorMessage("Default gain must be a finite number >= 0."); + return; + } + const currentGain = visualizerConfig?.default_gain ?? 1; + if (Math.abs(currentGain - defaultGain) < 1e-6) { + return; + } + void applySettingsPatch({ default_gain: defaultGain }, `Default gain set to ${defaultGain}.`); + } + + function handleTransitionBlur() { + const transitionSeconds = parsePositiveFloat(settingsDraft.transition_time); + if (transitionSeconds === null || transitionSeconds < 0.1 || transitionSeconds > 1.0) { + setErrorMessage("Transition time must be between 0.1s and 1.0s."); + return; + } + const transitionTenths = transitionSeconds * 10; + if (Math.abs(transitionTenths - Math.round(transitionTenths)) > 1e-6) { + setErrorMessage("Transition time must use 0.1 second steps."); + return; + } + const transitionTime = Math.round(transitionTenths); + const currentTransition = visualizerConfig?.transition_time ?? 2; + if (currentTransition === transitionTime) { + return; + } + void applySettingsPatch( + { transition_time: transitionTime }, + `Transition time set to ${formatTenths(transitionSeconds)}s.`, + ); + } + + function handleTimeWindowBlur() { + const timeWindow = parsePositiveFloat(settingsDraft.time_window); + if (timeWindow === null || timeWindow < 0.1 || timeWindow > 1.0) { + setErrorMessage("Time window must be between 0.1s and 1.0s."); + return; + } + const timeWindowTenths = timeWindow * 10; + if (Math.abs(timeWindowTenths - Math.round(timeWindowTenths)) > 1e-6) { + setErrorMessage("Time window must use 0.1 second steps."); + return; + } + const normalizedTimeWindow = Math.round(timeWindowTenths) / 10; + const currentTimeWindow = visualizerConfig?.time_window ?? 0.1875; + if (Math.abs(currentTimeWindow - normalizedTimeWindow) < 1e-6) { + return; + } + void applySettingsPatch( + { time_window: normalizedTimeWindow }, + `Time window set to ${formatTenths(normalizedTimeWindow)}s.`, + ); + } + + async function handleSaveRuntimeConfig() { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("persist"); + const updated = await api.saveConfig(); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage("Runtime config saved to config.toml."); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to save runtime config", + ); + } finally { + setSavingConfigSection(null); + } + } + + async function handleNowPlayingDrivePaletteToggle(enabled: boolean) { + try { + setErrorMessage(null); + setActionMessage(null); + const updated = await api.setNowPlayingSettings({ + drive_visualizer_palette: enabled, + }); + setNowPlaying(updated); + setActionMessage( + enabled + ? "Now playing palette mode enabled." + : "Now playing palette mode disabled.", + ); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update now playing settings", + ); + } + } + + function getBrightnessDraft(name: string): string { + return brightnessDraftByDevice[name] ?? DEFAULT_BRIGHTNESS_DRAFT; + } + + function handleBrightnessSliderChange(name: string, value: string) { + setBrightnessDraftByDevice((prev) => ({ ...prev, [name]: value })); + const parsed = parseBrightness(value); + if (parsed !== null) { + scheduleBrightnessUpdate(name, parsed); + } + } + + function handleBrightnessInputChange(name: string, value: string) { + if (!isValidBrightnessInput(value)) { + return; + } + setBrightnessDraftByDevice((prev) => ({ ...prev, [name]: value })); + } + + function handleBrightnessInputBlur(name: string) { + const parsed = parseBrightness(getBrightnessDraft(name)); + if (parsed === null) { + setErrorMessage("Brightness must be an integer between 0 and 100."); + return; + } + scheduleBrightnessUpdate(name, parsed); + } + + function scheduleBrightnessUpdate(name: string, brightness: number) { + const timers = brightnessCommitTimersRef.current; + const existingTimer = timers[name]; + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer); + } + timers[name] = window.setTimeout(() => { + delete timers[name]; + void commitBrightnessUpdate(name, brightness); + }, 120); + } + + async function commitBrightnessUpdate(name: string, brightness: number) { + const previouslyApplied = lastAppliedBrightnessRef.current[name]; + if (previouslyApplied === brightness) { + return; + } + + try { + setErrorMessage(null); + await api.setDeviceState(name, { brightness }); + lastAppliedBrightnessRef.current[name] = brightness; + setActionMessage(`Brightness ${brightness}% applied on ${name}`); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update device brightness", + ); + } + } + + return ( +
+
+
+

+ Audioleaf Web Control +

+

+ Axum + React Dashboard +

+
+
+ + {loadState === "ready" ? "API Connected" : "Connecting"} + + {health ? v{health.version} : null} +
+
+ + {errorMessage ? ( + + + {errorMessage} + + + ) : null} + + {actionMessage ? ( + + {actionMessage} + + ) : null} + +
+ + + Runtime + + Backend health, file paths, and explicit runtime config persistence + + + + + {health?.status ?? (loadState === "loading" ? "Loading..." : "-")} + + + {config?.paths.config_file_path ?? "-"} + + + {config?.paths.devices_file_path ?? "-"} + + + {config?.config?.default_nl_device_name ?? "Not configured"} + +
+

+ Runtime Config +

+

+ Live changes are in-memory until you save them. +

+ +
+
+
+ + + + Visualizer Settings + + Loaded from live runtime state. Update effect, palette, sort, and core visualizer + settings without immediate disk writes. + + + + {visualizerConfig ? ( +
+
+ + + + + + + + +
+
+

+ Configured Colors +

+ {visualizerConfig.colors?.length ? ( +
+ {visualizerConfig.colors.map(([r, g, b], idx) => ( + + ))} +
+ ) : ( +

No colors configured.

+ )} +
+ +
+

+ Effect +

+
+ +

Applies immediately on change.

+
+
+ +
+

+ Palette +

+
+ +

Applies immediately on change.

+
+
+ +
+

+ Sort +

+
+ + + +
+

+ Applies immediately when a value changes. +

+
+ +
+

+ Core Visualizer Settings +

+
+ + + + + + + + + + + +
+

+ Applies on focus loss for numeric fields and immediately for backend selection. +

+
+
+ ) : ( +

+ Visualizer config not found in your config file. +

+ )} +
+
+ + + + Now Playing (AirPlay Metadata) + + Track and artwork data from Shairport metadata pipe{" "} + + {nowPlaying?.metadata_pipe_path ?? "/tmp/shairport-sync-metadata"} + + + + +
+ + {nowPlaying?.reader_running ? "Reader Running" : "Reader Waiting"} + + +
+ + {nowPlaying?.last_error ? ( +

+ {nowPlaying.last_error} +

+ ) : null} + +
+
+ {nowPlaying?.artwork_available ? ( + Album artwork + ) : ( +
+ No artwork available yet +
+ )} +
+
+ + + + +
+
+ +
+

+ Extracted Artwork Colors +

+ {nowPlaying?.palette_colors.length ? ( +
+ {nowPlaying.palette_colors.map(([r, g, b], idx) => ( + + ))} +
+ ) : ( +

+ Artwork palette unavailable. Start playback through Shairport Sync. +

+ )} +
+
+
+ + + + Devices + + Known Nanoleaf devices loaded from your devices TOML + + + + {devices?.devices.length ? ( + devices.devices.map((device) => { + const isUpdating = updatingDeviceName === device.name; + const isLoadingDetails = loadingDeviceName === device.name; + const isBusy = isUpdating || isLoadingDetails; + const brightnessDraft = getBrightnessDraft(device.name); + return ( +
+
+
+

{device.name}

+

{device.ip}

+
+ +
+ +
+ + +
+ +
+

+ Brightness +

+
+ + handleBrightnessSliderChange(device.name, event.currentTarget.value) + } + disabled={isBusy} + className="w-full accent-[hsl(var(--primary))]" + /> + + handleBrightnessInputChange(device.name, event.currentTarget.value) + } + onBlur={() => handleBrightnessInputBlur(device.name)} + disabled={isBusy} + className="h-10 w-20 rounded-md border border-input bg-background px-3 text-sm" + aria-label={`Brightness value for ${device.name}`} + /> +
+

+ Slider applies while dragging. Typed values apply on focus loss. +

+
+
+ ); + }) + ) : ( +

+ {loadState === "loading" + ? "Loading devices..." + : "No known devices found yet. Pair one in the CLI first."} +

+ )} +
+
+ + + + All Palettes + + {palettes?.palettes.length ?? 0} palettes from /api/palettes + + + + {palettes?.palettes.length ? ( + palettes.palettes.map((palette) => ( + + )) + ) : ( +

+ {loadState === "loading" ? "Loading palettes..." : "No palettes found"} +

+ )} +
+
+
+ +
+ + +
+
+ Panel Layout + + {selectedDeviceLayout + ? `${selectedDeviceLayout.device.name} (${selectedDeviceLayout.device.ip}) • Global orientation ${selectedDeviceLayout.global_orientation}° • ${selectedDeviceLayout.panels.length} panels` + : "Default device layout is loading or unavailable."} + +
+ +
+
+ + {selectedDeviceLayout ? ( + + ) : ( +

+ No layout loaded yet. Use Load Details on a device to load manually. +

+ )} +
+
+
+ + {selectedDeviceInfo ? ( +
+ + + Selected Device Info + + {selectedDeviceInfo.device.name} ({selectedDeviceInfo.device.ip}) + + + +
+                {JSON.stringify(selectedDeviceInfo.info, null, 2)}
+              
+
+
+
+ ) : null} +
+ ); +} + +function DataRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
+ {label} + {children} +
+ +
+ ); +} + +function SettingCell({ label, value }: { label: string; value: ReactNode }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function PaletteCard({ palette }: { palette: PaletteEntry }) { + return ( +
+

{palette.name}

+
+ {palette.colors.map(([r, g, b], idx) => ( + + ))} +
+
+ ); +} + +function DeviceLayoutViewer({ + layout, + livePreviewEnabled, + livePreviewColorsByPanel, +}: { + layout: DeviceLayoutResponse; + livePreviewEnabled: boolean; + livePreviewColorsByPanel: Record; +}) { + const width = 1400; + const height = 780; + const padding = 56; + + if (!layout.panels.length) { + return

No panel layout data found.

; + } + + const minX = Math.min(...layout.panels.map((panel) => panel.x)); + const maxX = Math.max(...layout.panels.map((panel) => panel.x)); + const minY = Math.min(...layout.panels.map((panel) => panel.y)); + const maxY = Math.max(...layout.panels.map((panel) => panel.y)); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const angle = (-layout.global_orientation * Math.PI) / 180; + + const rotated = layout.panels.map((panel) => { + const relX = panel.x - centerX; + const relY = panel.y - centerY; + const rx = relX * Math.cos(angle) - relY * Math.sin(angle); + const ry = relX * Math.sin(angle) + relY * Math.cos(angle); + return { + panel, + rx, + ry, + radius: panelBaseRadius(panel), + }; + }); + + const minRx = Math.min(...rotated.map((item) => item.rx - item.radius)); + const maxRx = Math.max(...rotated.map((item) => item.rx + item.radius)); + const minRy = Math.min(...rotated.map((item) => item.ry - item.radius)); + const maxRy = Math.max(...rotated.map((item) => item.ry + item.radius)); + + const spanX = Math.max(maxRx - minRx, 1); + const spanY = Math.max(maxRy - minRy, 1); + const scale = Math.min((width - 2 * padding) / spanX, (height - 2 * padding) / spanY); + + const offsetX = (width - spanX * scale) / 2 - minRx * scale; + const offsetY = (height - spanY * scale) / 2 - minRy * scale; + + const renderPanels = rotated.map(({ panel, rx, ry, radius }) => { + const sx = rx * scale + offsetX; + const sy = height - (ry * scale + offsetY); + return { + ...panel, + sx, + sy, + scaledRadius: radius * scale, + }; + }); + const lightPanels = renderPanels.filter((panel) => panel.side_length >= 1); + const controllerPanels = renderPanels.filter((panel) => panel.side_length < 1); + + return ( +
+ + {lightPanels.map((panel) => ( + + + + Panel {panel.panel_id} • {panel.shape_type_name} + + + ))} + {controllerPanels.map((panel) => ( + + + Controller + + ))} + +

+ Rendering {layout.panels.length} panels from Nanoleaf layout data. Controller + panels are shown as trapezoids. Live animation preview is{" "} + {livePreviewEnabled ? "enabled" : "disabled"}. +

+
+ ); +} + +function panelBaseRadius(panel: DeviceLayoutPanel): number { + if (panel.side_length < 1) { + return 14; + } + if (panel.num_sides === 3) { + return panel.side_length / Math.sqrt(3); + } + if (panel.num_sides === 4) { + return panel.side_length / Math.sqrt(2); + } + if (panel.num_sides === 6) { + return panel.side_length; + } + return Math.max(panel.side_length * 0.65, 20); +} + +function panelFillColor( + panel: DeviceLayoutPanel, + liveRgb?: [number, number, number], +): string { + if (panel.side_length < 1) { + return "hsl(var(--accent) / 0.95)"; + } + if (liveRgb) { + return `rgb(${liveRgb[0]}, ${liveRgb[1]}, ${liveRgb[2]})`; + } + return "hsl(var(--primary) / 0.78)"; +} + +function buildPanelPolygonPoints(panel: { + sx: number; + sy: number; + scaledRadius: number; + orientation: number; + num_sides: number; +}) { + const sides = Math.max(3, Math.round(panel.num_sides || 4)); + const orientationRadians = (panel.orientation * Math.PI) / 180; + const points: string[] = []; + + for (let index = 0; index < sides; index += 1) { + const theta = orientationRadians + (2 * Math.PI * index) / sides; + points.push( + `${panel.sx + panel.scaledRadius * Math.cos(theta)},${panel.sy + panel.scaledRadius * Math.sin(theta)}`, + ); + } + + return points.join(" "); +} + +function buildControllerTrapezoidPoints( + controller: { + sx: number; + sy: number; + scaledRadius: number; + }, + lightPanels: Array<{ + sx: number; + sy: number; + scaledRadius: number; + orientation: number; + num_sides: number; + }>, +) { + if (!lightPanels.length) { + return buildPanelPolygonPoints({ + ...controller, + orientation: 0, + num_sides: 4, + }); + } + + const nearestPanel = lightPanels.reduce((best, panel) => { + const bestDistance = Math.hypot(best.sx - controller.sx, best.sy - controller.sy); + const panelDistance = Math.hypot(panel.sx - controller.sx, panel.sy - controller.sy); + return panelDistance < bestDistance ? panel : best; + }, lightPanels[0]); + + const numSides = Math.max(3, Math.round(nearestPanel.num_sides || 4)); + const parentRadius = nearestPanel.scaledRadius; + const parentOrientation = (nearestPanel.orientation * Math.PI) / 180; + const angleToController = Math.atan2( + controller.sy - nearestPanel.sy, + controller.sx - nearestPanel.sx, + ); + const anglePerSide = (2 * Math.PI) / numSides; + + let closestEdge = 0; + let minAngleDiff = Number.POSITIVE_INFINITY; + for (let index = 0; index < numSides; index += 1) { + const vertexAngle = parentOrientation + index * anglePerSide; + const rawDiff = Math.abs(angleToController - vertexAngle) % (2 * Math.PI); + const angleDiff = Math.min(rawDiff, 2 * Math.PI - rawDiff); + if (angleDiff < minAngleDiff) { + minAngleDiff = angleDiff; + closestEdge = index; + } + } + + const v1Angle = parentOrientation + closestEdge * anglePerSide; + const v2Angle = parentOrientation + (closestEdge + 1) * anglePerSide; + + const v1x = nearestPanel.sx + parentRadius * Math.cos(v1Angle); + const v1y = nearestPanel.sy + parentRadius * Math.sin(v1Angle); + const v2x = nearestPanel.sx + parentRadius * Math.cos(v2Angle); + const v2y = nearestPanel.sy + parentRadius * Math.sin(v2Angle); + + const edgeMidX = (v1x + v2x) / 2; + const edgeMidY = (v1y + v2y) / 2; + const perpDx = edgeMidX - nearestPanel.sx; + const perpDy = edgeMidY - nearestPanel.sy; + const perpLen = Math.hypot(perpDx, perpDy); + const perpNormX = perpLen < 1 ? 0 : perpDx / perpLen; + const perpNormY = perpLen < 1 ? -1 : perpDy / perpLen; + + const trapezoidHeight = Math.max(16, Math.min(28, parentRadius * 0.32)); + const narrowRatio = 0.6; + + const p1 = `${v1x},${v1y}`; + const p2 = `${v2x},${v2y}`; + const p3 = `${v2x + perpNormX * trapezoidHeight - (v2x - edgeMidX) * (1 - narrowRatio)},${v2y + perpNormY * trapezoidHeight - (v2y - edgeMidY) * (1 - narrowRatio)}`; + const p4 = `${v1x + perpNormX * trapezoidHeight - (v1x - edgeMidX) * (1 - narrowRatio)},${v1y + perpNormY * trapezoidHeight - (v1y - edgeMidY) * (1 - narrowRatio)}`; + + return `${p1} ${p2} ${p3} ${p4}`; +} + +export default App; diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..400ef40 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,240 @@ +export type HealthResponse = { + status: string; + version: string; +}; + +export type PathsResponse = { + config_file_path: string; + config_file_exists: boolean; + devices_file_path: string; + devices_file_exists: boolean; +}; + +export type VisualizerConfig = { + audio_backend: string | null; + freq_range: [number, number] | null; + colors: Array<[number, number, number]> | null; + default_gain: number | null; + transition_time: number | null; + time_window: number | null; + primary_axis: string | null; + sort_primary: string | null; + sort_secondary: string | null; + effect: string | null; +}; + +export type ConfigPayload = { + default_nl_device_name: string | null; + visualizer_config: VisualizerConfig; +}; + +export type ConfigResponse = { + paths: PathsResponse; + config: ConfigPayload | null; +}; + +export type VisualizerSortUpdateRequest = { + primary_axis: "X" | "Y"; + sort_primary: "Asc" | "Desc"; + sort_secondary: "Asc" | "Desc"; +}; + +export type VisualizerSettingsUpdateRequest = { + audio_backend?: string; + freq_range?: [number, number]; + default_gain?: number; + transition_time?: number; + time_window?: number; +}; + +export type NowPlayingTrack = { + title: string | null; + artist: string | null; + album: string | null; + stream_url: string | null; + source_name: string | null; + source_ip: string | null; + user_agent: string | null; +}; + +export type NowPlayingResponse = { + reader_running: boolean; + metadata_pipe_path: string; + last_error: string | null; + drive_visualizer_palette: boolean; + track: NowPlayingTrack | null; + palette_colors: Array<[number, number, number]>; + artwork_available: boolean; + artwork_generation: number; + updated_at_ms: number | null; +}; + +export type NowPlayingSettingsUpdateRequest = { + drive_visualizer_palette?: boolean; +}; + +export type DeviceSummary = { + name: string; + ip: string; +}; + +export type DevicesResponse = { + devices: DeviceSummary[]; + devices_file_path: string; + devices_file_exists: boolean; +}; + +export type DeviceInfoResponse = { + device: DeviceSummary; + info: Record; +}; + +export type DeviceLayoutPanel = { + panel_id: number; + x: number; + y: number; + orientation: number; + shape_type_id: number; + shape_type_name: string; + num_sides: number; + side_length: number; +}; + +export type DeviceLayoutResponse = { + device: DeviceSummary; + global_orientation: number; + panels: DeviceLayoutPanel[]; +}; + +export type DeviceStateUpdateRequest = { + power_on?: boolean; + brightness?: number; +}; + +export type DeviceStateUpdateResponse = { + device: DeviceSummary; + power_on: boolean | null; + brightness: number | null; +}; + +export type PaletteEntry = { + name: string; + colors: Array<[number, number, number]>; +}; + +export type PalettesResponse = { + palettes: PaletteEntry[]; +}; + +export type AudioBackendsResponse = { + current_audio_backend: string | null; + available_audio_backends: string[]; +}; + +export type VisualizerPreviewPanelColor = { + panel_id: number; + rgb: [number, number, number]; +}; + +export type VisualizerPreviewResponse = { + enabled: boolean; + device: DeviceSummary | null; + panel_colors: VisualizerPreviewPanelColor[]; +}; + +async function apiGet(path: string): Promise { + const response = await fetch(path); + return parseResponse(response); +} + +async function apiSend(path: string, init: RequestInit): Promise { + const response = await fetch(path, init); + return parseResponse(response); +} + +async function parseResponse(response: Response): Promise { + if (!response.ok) { + let errorMessage = `${response.status} ${response.statusText}`; + try { + const parsed = (await response.json()) as { error?: string }; + if (parsed.error) { + errorMessage = parsed.error; + } + } catch { + // Keep fallback status text if body is not JSON. + } + throw new Error(errorMessage); + } + + return (await response.json()) as T; +} + +export const api = { + health: () => apiGet("/api/health"), + config: () => apiGet("/api/config"), + saveConfig: () => + apiSend("/api/config/save", { + method: "POST", + }), + setVisualizerEffect: (effect: string) => + apiSend("/api/config/visualizer/effect", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ effect }), + }), + setVisualizerPalette: (palette_name: string) => + apiSend("/api/config/visualizer/palette", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ palette_name }), + }), + setVisualizerSort: (payload: VisualizerSortUpdateRequest) => + apiSend("/api/config/visualizer/sort", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + setVisualizerSettings: (payload: VisualizerSettingsUpdateRequest) => + apiSend("/api/config/visualizer/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + nowPlaying: () => apiGet("/api/now-playing"), + setNowPlayingSettings: (payload: NowPlayingSettingsUpdateRequest) => + apiSend("/api/now-playing/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + visualizerPreview: () => + apiGet("/api/visualizer/preview"), + audioBackends: () => apiGet("/api/audio/backends"), + devices: () => apiGet("/api/devices"), + deviceInfo: (name: string) => + apiGet(`/api/devices/${encodeURIComponent(name)}/info`), + deviceLayout: (name: string) => + apiGet(`/api/devices/${encodeURIComponent(name)}/layout`), + setDeviceState: (name: string, payload: DeviceStateUpdateRequest) => + apiSend( + `/api/devices/${encodeURIComponent(name)}/state`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + ), + palettes: () => apiGet("/api/palettes"), +}; diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..8cc890b --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium tracking-wide", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..47b2d32 --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85", + ghost: "hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 0000000..805df6c --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..5be6558 --- /dev/null +++ b/web/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..5e67d2a --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,39 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 38 40% 96%; + --foreground: 203 32% 12%; + --card: 0 0% 100%; + --card-foreground: 203 32% 12%; + --primary: 176 82% 28%; + --primary-foreground: 0 0% 98%; + --secondary: 188 28% 91%; + --secondary-foreground: 203 32% 16%; + --muted: 44 20% 90%; + --muted-foreground: 201 12% 37%; + --accent: 20 88% 90%; + --accent-foreground: 14 50% 24%; + --destructive: 0 84% 52%; + --border: 190 24% 82%; + --input: 190 24% 82%; + --ring: 176 82% 28%; +} + +* { + @apply border-border; +} + +body { + @apply bg-background text-foreground font-sans; + margin: 0; + background-image: + radial-gradient(circle at 12% 10%, rgba(62, 184, 171, 0.28), transparent 44%), + radial-gradient(circle at 88% 82%, rgba(255, 164, 88, 0.3), transparent 36%), + linear-gradient(165deg, #f7f2e6 0%, #eef3ef 45%, #e5f2f1 100%); +} + +code { + font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace; +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c2a145c --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts new file mode 100644 index 0000000..9e94ffe --- /dev/null +++ b/web/tailwind.config.ts @@ -0,0 +1,41 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: "class", + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: "hsl(var(--card))", + "card-foreground": "hsl(var(--card-foreground))", + primary: "hsl(var(--primary))", + "primary-foreground": "hsl(var(--primary-foreground))", + secondary: "hsl(var(--secondary))", + "secondary-foreground": "hsl(var(--secondary-foreground))", + muted: "hsl(var(--muted))", + "muted-foreground": "hsl(var(--muted-foreground))", + accent: "hsl(var(--accent))", + "accent-foreground": "hsl(var(--accent-foreground))", + destructive: "hsl(var(--destructive))", + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + }, + borderRadius: { + lg: "0.8rem", + md: "0.6rem", + sm: "0.4rem", + }, + fontFamily: { + sans: ["Space Grotesk", "IBM Plex Sans", "Avenir Next", "sans-serif"], + display: ["Sora", "Avenir Next Condensed", "sans-serif"], + }, + boxShadow: { + card: "0 12px 30px rgba(4, 23, 33, 0.08)", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..502b07c --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..ed70b8a --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["vite.config.ts", "tailwind.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..8a056ac --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,25 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + host: "127.0.0.1", + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + }, + }, +}); From bf5fea10fc81f470be92cb6e6c293d361734162c Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:46:14 -0700 Subject: [PATCH 03/72] updated to have install script --- install_share_port_sync.sh | 94 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100755 install_share_port_sync.sh diff --git a/install_share_port_sync.sh b/install_share_port_sync.sh new file mode 100755 index 0000000..97f130d --- /dev/null +++ b/install_share_port_sync.sh @@ -0,0 +1,94 @@ + #!/usr/bin/env bash + set -euo pipefail + + echo "[1/8] Stop old services" + sudo systemctl disable --now shairport-sync >/dev/null 2>&1 || true + sudo systemctl disable --now nqptp >/dev/null 2>&1 || true + + echo "[2/8] Remove distro packages (if present)" + sudo apt remove -y shairport-sync nqptp || true + sudo apt autoremove -y + + echo "[3/8] Remove old manual binaries/service files" + for f in \ + /usr/local/bin/shairport-sync /usr/local/sbin/shairport-sync \ + /usr/local/bin/nqptp /usr/local/sbin/nqptp \ + /etc/systemd/system/shairport-sync.service /lib/systemd/system/shairport-sync.service \ + /etc/systemd/user/shairport-sync.service /lib/systemd/user/shairport-sync.service \ + /etc/systemd/system/nqptp.service /lib/systemd/system/nqptp.service \ + /etc/systemd/user/nqptp.service /lib/systemd/user/nqptp.service \ + /etc/init.d/shairport-sync /etc/init.d/nqptp \ + /etc/dbus-1/system.d/shairport-sync-dbus.conf /etc/dbus-1/system.d/shairport-sync-mpris.conf + do + sudo rm -f "$f" + done + + if [ -f /etc/shairport-sync.conf ]; then + sudo cp /etc/shairport-sync.conf "/etc/shairport-sync.conf.bak.$(date +%Y%m%d%H%M%S)" + fi + + sudo systemctl daemon-reload + sudo systemctl reset-failed || true + + echo "[4/8] Install build dependencies" + sudo apt update + sudo apt install -y --no-install-recommends \ + build-essential git autoconf automake libtool \ + libpopt-dev libconfig-dev libasound2-dev \ + avahi-daemon libavahi-client-dev libssl-dev libsoxr-dev \ + libplist-dev libsodium-dev uuid-dev libgcrypt-dev xxd libplist-utils \ + libavutil-dev libavcodec-dev libavformat-dev + sudo apt install -y --no-install-recommends systemd-dev || true + + echo "[5/8] Build/install NQPTP" + cd "$HOME" + if [ ! -d nqptp ]; then git clone https://github.com/mikebrady/nqptp.git; fi + cd nqptp + git pull --ff-only || true + autoreconf -fi + ./configure --with-systemd-startup + make -j"$(nproc)" + sudo make install + sudo systemctl enable --now nqptp + + echo "[6/8] Build/install Shairport Sync (AirPlay 2)" + cd "$HOME" + if [ ! -d shairport-sync ]; then git clone https://github.com/mikebrady/shairport-sync.git; fi + cd shairport-sync + git pull --ff-only || true + autoreconf -fi + ./configure --sysconfdir=/etc --with-alsa --with-soxr --with-avahi \ + --with-ssl=openssl --with-systemd-startup --with-airplay-2 + make -j"$(nproc)" + sudo make install + + echo "[7/8] Write minimal config" + sudo tee /etc/shairport-sync.conf >/dev/null <<'CONF' + general = { + name = "Audioleaf Pi"; + output_backend = "alsa"; + }; + + alsa = { + output_device = "default"; + }; + + metadata = { + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "/tmp/shairport-sync-metadata"; + }; + CONF + + echo "[8/8] Enable/start services and verify" + sudo systemctl enable --now avahi-daemon shairport-sync + sudo systemctl restart shairport-sync + + echo + echo "Versions:" + nqptp -V || true + shairport-sync -V || true + + echo + echo "Service status:" + systemctl --no-pager --full status nqptp shairport-sync | sed -n '1,120p' From 10f0b0fd2ea2d163fc0d9a478478ce4f6e37ea9c Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:00:29 -0700 Subject: [PATCH 04/72] updated to use better path resolution with paths --- install_share_port_sync.sh | 199 +++++++++++++++++--------------- piWebServer/shairport-sync.conf | 14 +++ src/app.rs | 12 +- src/config.rs | 6 +- 4 files changed, 128 insertions(+), 103 deletions(-) create mode 100644 piWebServer/shairport-sync.conf diff --git a/install_share_port_sync.sh b/install_share_port_sync.sh index 97f130d..518493a 100755 --- a/install_share_port_sync.sh +++ b/install_share_port_sync.sh @@ -1,94 +1,105 @@ - #!/usr/bin/env bash - set -euo pipefail - - echo "[1/8] Stop old services" - sudo systemctl disable --now shairport-sync >/dev/null 2>&1 || true - sudo systemctl disable --now nqptp >/dev/null 2>&1 || true - - echo "[2/8] Remove distro packages (if present)" - sudo apt remove -y shairport-sync nqptp || true - sudo apt autoremove -y - - echo "[3/8] Remove old manual binaries/service files" - for f in \ - /usr/local/bin/shairport-sync /usr/local/sbin/shairport-sync \ - /usr/local/bin/nqptp /usr/local/sbin/nqptp \ - /etc/systemd/system/shairport-sync.service /lib/systemd/system/shairport-sync.service \ - /etc/systemd/user/shairport-sync.service /lib/systemd/user/shairport-sync.service \ - /etc/systemd/system/nqptp.service /lib/systemd/system/nqptp.service \ - /etc/systemd/user/nqptp.service /lib/systemd/user/nqptp.service \ - /etc/init.d/shairport-sync /etc/init.d/nqptp \ - /etc/dbus-1/system.d/shairport-sync-dbus.conf /etc/dbus-1/system.d/shairport-sync-mpris.conf - do - sudo rm -f "$f" - done - - if [ -f /etc/shairport-sync.conf ]; then - sudo cp /etc/shairport-sync.conf "/etc/shairport-sync.conf.bak.$(date +%Y%m%d%H%M%S)" - fi - - sudo systemctl daemon-reload - sudo systemctl reset-failed || true - - echo "[4/8] Install build dependencies" - sudo apt update - sudo apt install -y --no-install-recommends \ - build-essential git autoconf automake libtool \ - libpopt-dev libconfig-dev libasound2-dev \ - avahi-daemon libavahi-client-dev libssl-dev libsoxr-dev \ - libplist-dev libsodium-dev uuid-dev libgcrypt-dev xxd libplist-utils \ - libavutil-dev libavcodec-dev libavformat-dev - sudo apt install -y --no-install-recommends systemd-dev || true - - echo "[5/8] Build/install NQPTP" - cd "$HOME" - if [ ! -d nqptp ]; then git clone https://github.com/mikebrady/nqptp.git; fi - cd nqptp - git pull --ff-only || true - autoreconf -fi - ./configure --with-systemd-startup - make -j"$(nproc)" - sudo make install - sudo systemctl enable --now nqptp - - echo "[6/8] Build/install Shairport Sync (AirPlay 2)" - cd "$HOME" - if [ ! -d shairport-sync ]; then git clone https://github.com/mikebrady/shairport-sync.git; fi - cd shairport-sync - git pull --ff-only || true - autoreconf -fi - ./configure --sysconfdir=/etc --with-alsa --with-soxr --with-avahi \ - --with-ssl=openssl --with-systemd-startup --with-airplay-2 - make -j"$(nproc)" - sudo make install - - echo "[7/8] Write minimal config" - sudo tee /etc/shairport-sync.conf >/dev/null <<'CONF' - general = { - name = "Audioleaf Pi"; - output_backend = "alsa"; - }; - - alsa = { - output_device = "default"; - }; - - metadata = { - enabled = "yes"; - include_cover_art = "yes"; - pipe_name = "/tmp/shairport-sync-metadata"; - }; - CONF - - echo "[8/8] Enable/start services and verify" - sudo systemctl enable --now avahi-daemon shairport-sync - sudo systemctl restart shairport-sync - - echo - echo "Versions:" - nqptp -V || true - shairport-sync -V || true - - echo - echo "Service status:" - systemctl --no-pager --full status nqptp shairport-sync | sed -n '1,120p' +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +CONFIG_SOURCE="${SCRIPT_DIR}/piWebServer/shairport-sync.conf" + +if [[ ! -f "${CONFIG_SOURCE}" ]]; then + echo "ERROR: Expected config file not found: ${CONFIG_SOURCE}" >&2 + exit 1 +fi + +if ! command -v sudo >/dev/null 2>&1; then + echo "ERROR: sudo is required." >&2 + exit 1 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + echo "ERROR: apt-get not found. This script targets Debian/Raspberry Pi OS." >&2 + exit 1 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "ERROR: systemctl not found. This script expects a systemd host." >&2 + exit 1 +fi + +echo "[0/8] Validate sudo access" +sudo -v + +echo "[1/8] Stop old services" +sudo systemctl disable --now shairport-sync >/dev/null 2>&1 || true +sudo systemctl disable --now nqptp >/dev/null 2>&1 || true + +echo "[2/8] Remove distro packages (if present)" +sudo apt-get remove -y shairport-sync nqptp || true +sudo apt-get autoremove -y + +echo "[3/8] Remove old manual binaries/service files" +for f in \ + /usr/local/bin/shairport-sync /usr/local/sbin/shairport-sync \ + /usr/local/bin/nqptp /usr/local/sbin/nqptp \ + /etc/systemd/system/shairport-sync.service /lib/systemd/system/shairport-sync.service \ + /etc/systemd/user/shairport-sync.service /lib/systemd/user/shairport-sync.service \ + /etc/systemd/system/nqptp.service /lib/systemd/system/nqptp.service \ + /etc/systemd/user/nqptp.service /lib/systemd/user/nqptp.service \ + /etc/init.d/shairport-sync /etc/init.d/nqptp \ + /etc/dbus-1/system.d/shairport-sync-dbus.conf /etc/dbus-1/system.d/shairport-sync-mpris.conf +do + sudo rm -f "$f" +done + +if [[ -f /etc/shairport-sync.conf ]]; then + sudo cp /etc/shairport-sync.conf "/etc/shairport-sync.conf.bak.$(date +%Y%m%d%H%M%S)" +fi + +sudo systemctl daemon-reload +sudo systemctl reset-failed || true + +echo "[4/8] Install build dependencies" +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + build-essential git autoconf automake libtool \ + libpopt-dev libconfig-dev libasound2-dev \ + avahi-daemon libavahi-client-dev libssl-dev libsoxr-dev \ + libplist-dev libsodium-dev uuid-dev libgcrypt-dev xxd libplist-utils \ + libavutil-dev libavcodec-dev libavformat-dev +sudo apt-get install -y --no-install-recommends systemd-dev || true + +echo "[5/8] Build/install NQPTP" +cd "$HOME" +if [[ ! -d nqptp ]]; then git clone https://github.com/mikebrady/nqptp.git; fi +cd nqptp +git pull --ff-only || true +autoreconf -fi +./configure --with-systemd-startup +make -j"$(nproc)" +sudo make install +sudo systemctl enable --now nqptp + +echo "[6/8] Build/install Shairport Sync (AirPlay 2)" +cd "$HOME" +if [[ ! -d shairport-sync ]]; then git clone https://github.com/mikebrady/shairport-sync.git; fi +cd shairport-sync +git pull --ff-only || true +autoreconf -fi +./configure --sysconfdir=/etc --with-alsa --with-soxr --with-avahi \ + --with-ssl=openssl --with-systemd-startup --with-airplay-2 +make -j"$(nproc)" +sudo make install + +echo "[7/8] Write minimal config" +sudo install -m 0644 "${CONFIG_SOURCE}" /etc/shairport-sync.conf + +echo "[8/8] Enable/start services and verify" +sudo systemctl enable --now avahi-daemon shairport-sync +sudo systemctl restart shairport-sync + +echo +echo "Versions:" +nqptp -V || true +shairport-sync -V || true + +echo +echo "Service status:" +systemctl --no-pager --full status nqptp shairport-sync | sed -n '1,120p' diff --git a/piWebServer/shairport-sync.conf b/piWebServer/shairport-sync.conf new file mode 100644 index 0000000..76662bf --- /dev/null +++ b/piWebServer/shairport-sync.conf @@ -0,0 +1,14 @@ + general = { + name = "Audioleaf Pi"; + output_backend = "alsa"; + }; + + alsa = { + output_device = "default"; + }; + + metadata = { + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "/tmp/shairport-sync-metadata"; + }; diff --git a/src/app.rs b/src/app.rs index dc85e42..96412db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -357,12 +357,12 @@ impl App { // Snapshot visualization colors with smooth interpolation let vis_colors = if self.show_visualization { - if let Ok(map) = self.shared_colors.lock() { - if *map != self.target_colors { - self.prev_colors = self.interpolated_colors(); - self.target_colors = map.clone(); - self.color_transition_start = Instant::now(); - } + if let Ok(map) = self.shared_colors.lock() + && *map != self.target_colors + { + self.prev_colors = self.interpolated_colors(); + self.target_colors = map.clone(); + self.color_transition_start = Instant::now(); } Some(self.interpolated_colors()) } else { diff --git a/src/config.rs b/src/config.rs index bed4690..589406d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -100,7 +100,7 @@ pub struct VisualizerConfig { pub effect: Option, } -impl VisualizerConfig { +impl Default for VisualizerConfig { /// Returns the default visualizer configuration. /// /// Initializes with constants: @@ -111,7 +111,7 @@ impl VisualizerConfig { /// - `transition_time`: 2 (200ms) /// - `time_window`: 0.1875 s /// - Sorting: Y axis ascending, secondary ascending - pub fn default() -> Self { + fn default() -> Self { VisualizerConfig { audio_backend: Some("default".to_string()), freq_range: Some(constants::DEFAULT_FREQ_RANGE), @@ -156,7 +156,7 @@ impl Config { ) -> Self { Config { default_nl_device_name, - visualizer_config: visualizer_config.unwrap_or(VisualizerConfig::default()), + visualizer_config: visualizer_config.unwrap_or_default(), } } From c3c65bda839f1bae5308e1525ba3a60ad3cd7719 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:25:13 -0700 Subject: [PATCH 05/72] updated config --- web/vite.config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index 8a056ac..de8a1e0 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ }, }, server: { - host: "127.0.0.1", + host: "0.0.0.0", port: 5173, proxy: { "/api": { @@ -22,4 +22,14 @@ export default defineConfig({ }, }, }, + preview: { + host: "0.0.0.0", + port: 4173, + proxy: { + "/api": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + }, + }, }); From 009e3be4066e665ffe8d4cbacb6399184de96f97 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:58:15 -0700 Subject: [PATCH 06/72] fixed viszualizer colors --- src/visualizer.rs | 62 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/src/visualizer.rs b/src/visualizer.rs index 3aa50e0..f79f5b6 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -229,7 +229,7 @@ impl Visualizer { .fold(f32::NEG_INFINITY, f32::max), ); } - tx.send(samples).expect("sending samples failed"); + let _ = tx.send(samples); } /// Creates a closure suitable for CPAL `build_input_stream` callback. @@ -271,21 +271,20 @@ impl Visualizer { let (tx_audio, rx_audio) = mpsc::channel(); macro_rules! build_input_stream { ($type:ty) => { - self.audio_stream - .device - .build_input_stream( - &self.audio_stream.stream_config, - Self::create_data_callback::<$type>( - self.audio_stream.stream_config.channels as usize, - tx_audio, - ), - move |_| panic!("building the audio stream failed"), - None, - ) - .expect("stream initialization failed") + self.audio_stream.device.build_input_stream( + &self.audio_stream.stream_config, + Self::create_data_callback::<$type>( + self.audio_stream.stream_config.channels as usize, + tx_audio.clone(), + ), + move |err| { + eprintln!("WARNING: audio input stream callback error: {}", err); + }, + None, + ) }; } - let stream = match self.audio_stream.sample_format { + let stream_result = match self.audio_stream.sample_format { SampleFormat::I8 => build_input_stream!(i8), SampleFormat::I16 => build_input_stream!(i16), SampleFormat::I32 => build_input_stream!(i32), @@ -296,9 +295,25 @@ impl Visualizer { SampleFormat::U64 => build_input_stream!(u64), SampleFormat::F32 => build_input_stream!(f32), SampleFormat::F64 => build_input_stream!(f64), - _ => panic!("unsupported sample format"), + _ => { + eprintln!( + "WARNING: Unsupported sample format for live visualizer: {:?}", + self.audio_stream.sample_format + ); + return; + } + }; + let stream = match stream_result { + Ok(stream) => stream, + Err(err) => { + eprintln!("WARNING: stream initialization failed: {}", err); + return; + } }; - stream.play().expect("running the audio stream failed"); + if let Err(err) = stream.play() { + eprintln!("WARNING: running the audio stream failed: {}", err); + return; + } let n = self.nl_udp.panels.len(); let sample_rate = self.audio_stream.stream_config.sample_rate; @@ -325,14 +340,25 @@ impl Visualizer { ), Err(err) => { if err == TryRecvError::Disconnected { - panic!("events sender disconnected"); + eprintln!( + "WARNING: visualizer events channel disconnected; stopping thread." + ); + break; } } } let to_collect = ((sample_rate as f32) * self.time_window).round() as usize; let mut samples = Vec::with_capacity(2 * to_collect); while samples.len() < to_collect { - let mut new_samples = rx_audio.recv().expect("receiving samples failed"); + let mut new_samples = match rx_audio.recv() { + Ok(samples) => samples, + Err(_) => { + eprintln!( + "WARNING: audio sample channel disconnected; stopping thread." + ); + return; + } + }; samples.append(&mut new_samples); } let spectrum = processing::process(samples, self.gain); From adf216b3c62171c93817b676e04430300a191f3c Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:13:46 -0700 Subject: [PATCH 07/72] changed loop back device --- piWebServer/shairport-sync.conf | 2 + src/audio.rs | 120 ++++++++++++++++++++++++++------ src/bin/audioleaf-api.rs | 17 +---- 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/piWebServer/shairport-sync.conf b/piWebServer/shairport-sync.conf index 76662bf..fae162f 100644 --- a/piWebServer/shairport-sync.conf +++ b/piWebServer/shairport-sync.conf @@ -5,6 +5,8 @@ alsa = { output_device = "default"; + output_rate = 44100; + output_format = "S16"; }; metadata = { diff --git a/src/audio.rs b/src/audio.rs index d1e5f76..7ed0c41 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,6 +1,7 @@ use crate::constants; use anyhow::{Result, bail}; use cpal::{Device, SampleFormat, StreamConfig, traits::*}; +use std::collections::HashMap; pub struct AudioStream { pub device: Device, @@ -33,6 +34,7 @@ impl AudioStream { None => constants::DEFAULT_AUDIO_BACKEND, }; let host = cpal::default_host(); + let input_devices = enumerate_input_devices(&host)?; // Try to find the device in input devices (for loopback/monitor devices) let device = match device_name { @@ -50,16 +52,12 @@ impl AudioStream { ]; let mut loopback_device = None; - if let Ok(devices) = host.input_devices() { - for device in devices { - if let Ok(name) = device.description().map(|d| d.name().to_string()) - && loopback_names.iter().any(|lb| name.contains(lb)) - { - #[cfg(debug_assertions)] - eprintln!("INFO: Found loopback device: {}", name); - loopback_device = Some(device); - break; - } + for (device, raw_name, _) in &input_devices { + if loopback_names.iter().any(|lb| raw_name.contains(lb)) { + #[cfg(debug_assertions)] + eprintln!("INFO: Found loopback device: {}", raw_name); + loopback_device = Some(device.clone()); + break; } } @@ -70,24 +68,14 @@ impl AudioStream { host.default_input_device() }) } - _ => host.input_devices()?.find(|x| { - x.description() - .map(|d| d.name() == device_name) - .unwrap_or(false) - }), + _ => select_input_device(&input_devices, device_name).map(|(device, _, _)| device), }; let Some(device) = device else { bail!(format!( "Audio backend `{}` not found, available options: {}", device_name, - host.input_devices()? - .map(|dev| dev - .description() - .map(|d| d.name().to_string()) - .unwrap_or_default()) - .collect::>() - .join(", ") + list_input_backend_names()?.join(", ") )); }; let audio_config = device.default_input_config()?; @@ -101,3 +89,91 @@ impl AudioStream { }) } } + +/// Returns input backend names with stable disambiguation suffixes for duplicates. +/// +/// Example: +/// - "Loopback, Loopback PCM [#1]" +/// - "Loopback, Loopback PCM [#2]" +pub fn list_input_backend_names() -> Result> { + let host = cpal::default_host(); + let devices = enumerate_input_devices(&host)?; + Ok(devices.into_iter().map(|(_, _, display)| display).collect()) +} + +fn enumerate_input_devices(host: &cpal::Host) -> Result> { + let raw_devices: Vec<(Device, String)> = host + .input_devices()? + .filter_map(|device| { + device + .description() + .ok() + .map(|description| (device, description.name().to_string())) + }) + .collect(); + + let mut counts: HashMap = HashMap::new(); + for (_, raw_name) in &raw_devices { + *counts.entry(raw_name.clone()).or_default() += 1; + } + + let mut seen: HashMap = HashMap::new(); + let mut result = Vec::with_capacity(raw_devices.len()); + for (device, raw_name) in raw_devices { + let total = counts.get(&raw_name).copied().unwrap_or(1); + let display_name = if total > 1 { + let next = seen.entry(raw_name.clone()).or_default(); + *next += 1; + format!("{} [#{}]", raw_name, *next) + } else { + raw_name.clone() + }; + result.push((device, raw_name, display_name)); + } + + Ok(result) +} + +fn select_input_device( + devices: &[(Device, String, String)], + selected_name: &str, +) -> Option<(Device, String, String)> { + if let Some((base_name, duplicate_index)) = parse_indexed_name(selected_name) { + let mut matched_index = 0usize; + for (device, raw_name, display_name) in devices { + if raw_name == &base_name { + matched_index += 1; + if matched_index == duplicate_index { + return Some((device.clone(), raw_name.clone(), display_name.clone())); + } + } + } + return None; + } + + devices + .iter() + .find(|(_, raw_name, display_name)| { + raw_name == selected_name || display_name == selected_name + }) + .map(|(device, raw_name, display_name)| { + (device.clone(), raw_name.clone(), display_name.clone()) + }) +} + +fn parse_indexed_name(name: &str) -> Option<(String, usize)> { + if !name.ends_with(']') { + return None; + } + let marker_start = name.rfind(" [#")?; + let index_part = &name[(marker_start + 3)..(name.len() - 1)]; + let parsed_index = index_part.parse::().ok()?; + if parsed_index == 0 { + return None; + } + let base_name = name[..marker_start].to_string(); + if base_name.is_empty() { + return None; + } + Some((base_name, parsed_index)) +} diff --git a/src/bin/audioleaf-api.rs b/src/bin/audioleaf-api.rs index d9e0868..6e381e0 100644 --- a/src/bin/audioleaf-api.rs +++ b/src/bin/audioleaf-api.rs @@ -9,7 +9,6 @@ use axum::{ }; use base64::Engine; use clap::Parser; -use cpal::traits::{DeviceTrait, HostTrait}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, @@ -879,20 +878,8 @@ async fn get_audio_backends(State(state): State) -> ApiResult = match host.input_devices() { - Ok(devices) => devices - .filter_map(|device| { - device - .description() - .ok() - .map(|description| description.name().to_string()) - }) - .collect(), - Err(_) => Vec::new(), - }; - available_audio_backends.sort(); - available_audio_backends.dedup(); + let mut available_audio_backends = + audioleaf::audio::list_input_backend_names().unwrap_or_else(|_| Vec::new()); if !available_audio_backends .iter() From b0292ba53137f0f3b192380171fba73df568fc14 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:50:57 -0700 Subject: [PATCH 08/72] fixed cargo plus hardware interfaces list --- src/audio.rs | 309 ++++++++++++++++++++++++++++++++++++++++++------ web/src/App.tsx | 33 ++++-- 2 files changed, 300 insertions(+), 42 deletions(-) diff --git a/src/audio.rs b/src/audio.rs index 7ed0c41..268c580 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -9,6 +9,14 @@ pub struct AudioStream { pub stream_config: StreamConfig, } +#[derive(Clone)] +struct InputDeviceEntry { + device: Device, + backend_name: String, + friendly_name: String, + legacy_display_name: String, +} + impl AudioStream { /// Creates a new `AudioStream` instance for capturing audio from an input device. /// @@ -29,7 +37,7 @@ impl AudioStream { /// /// Propagates `cpal` errors for device discovery or config retrieval. Bail with available devices list if none match. pub fn new(device_name: Option<&str>) -> Result { - let device_name = match device_name { + let requested_name = match device_name { Some(name) => name, None => constants::DEFAULT_AUDIO_BACKEND, }; @@ -37,7 +45,7 @@ impl AudioStream { let input_devices = enumerate_input_devices(&host)?; // Try to find the device in input devices (for loopback/monitor devices) - let device = match device_name { + let device = match requested_name { constants::DEFAULT_AUDIO_BACKEND => { // Check for common loopback device names first let loopback_names = [ @@ -52,11 +60,16 @@ impl AudioStream { ]; let mut loopback_device = None; - for (device, raw_name, _) in &input_devices { - if loopback_names.iter().any(|lb| raw_name.contains(lb)) { + for entry in &input_devices { + if loopback_names.iter().any(|lb| { + entry.friendly_name.contains(lb) || entry.backend_name.contains(lb) + }) { #[cfg(debug_assertions)] - eprintln!("INFO: Found loopback device: {}", raw_name); - loopback_device = Some(device.clone()); + eprintln!( + "INFO: Found loopback device: {} ({})", + entry.friendly_name, entry.backend_name + ); + loopback_device = Some(entry.device.clone()); break; } } @@ -68,13 +81,13 @@ impl AudioStream { host.default_input_device() }) } - _ => select_input_device(&input_devices, device_name).map(|(device, _, _)| device), + _ => select_input_device(&input_devices, requested_name).map(|entry| entry.device), }; let Some(device) = device else { bail!(format!( "Audio backend `{}` not found, available options: {}", - device_name, + requested_name, list_input_backend_names()?.join(", ") )); }; @@ -98,67 +111,109 @@ impl AudioStream { pub fn list_input_backend_names() -> Result> { let host = cpal::default_host(); let devices = enumerate_input_devices(&host)?; - Ok(devices.into_iter().map(|(_, _, display)| display).collect()) + Ok(devices + .into_iter() + .map(|entry| entry.backend_name) + .collect()) } -fn enumerate_input_devices(host: &cpal::Host) -> Result> { - let raw_devices: Vec<(Device, String)> = host +fn enumerate_input_devices(host: &cpal::Host) -> Result> { + let raw_devices: Vec<(Device, String, String)> = host .input_devices()? .filter_map(|device| { - device + let backend_name = device + .id() + .ok() + .map(|id| id.1.trim().to_string()) + .filter(|name| !name.is_empty()); + let friendly_name = device .description() .ok() - .map(|description| (device, description.name().to_string())) + .map(|description| description.name().trim().to_string()) + .filter(|name| !name.is_empty()); + + let backend_name = match (backend_name, friendly_name.as_deref()) { + (Some(name), _) => name, + (None, Some(name)) => name.to_string(), + (None, None) => return None, + }; + let friendly_name = friendly_name.unwrap_or_else(|| backend_name.clone()); + Some((device, backend_name, friendly_name)) }) .collect(); let mut counts: HashMap = HashMap::new(); - for (_, raw_name) in &raw_devices { - *counts.entry(raw_name.clone()).or_default() += 1; + for (_, _, friendly_name) in &raw_devices { + *counts.entry(friendly_name.clone()).or_default() += 1; } let mut seen: HashMap = HashMap::new(); let mut result = Vec::with_capacity(raw_devices.len()); - for (device, raw_name) in raw_devices { - let total = counts.get(&raw_name).copied().unwrap_or(1); - let display_name = if total > 1 { - let next = seen.entry(raw_name.clone()).or_default(); + for (device, backend_name, friendly_name) in raw_devices { + let total = counts.get(&friendly_name).copied().unwrap_or(1); + let legacy_display_name = if total > 1 { + let next = seen.entry(friendly_name.clone()).or_default(); *next += 1; - format!("{} [#{}]", raw_name, *next) + format!("{} [#{}]", friendly_name, *next) } else { - raw_name.clone() + friendly_name.clone() }; - result.push((device, raw_name, display_name)); + result.push(InputDeviceEntry { + device, + backend_name, + friendly_name, + legacy_display_name, + }); } Ok(result) } fn select_input_device( - devices: &[(Device, String, String)], + devices: &[InputDeviceEntry], selected_name: &str, -) -> Option<(Device, String, String)> { +) -> Option { + let selected_name = selected_name.trim(); + if selected_name.is_empty() { + return None; + } + + if let Some(entry) = devices.iter().find(|entry| { + entry.backend_name == selected_name + || entry.friendly_name == selected_name + || entry.legacy_display_name == selected_name + }) { + return Some(entry.clone()); + } + + let selected_without_host_prefix = strip_host_prefix(selected_name); + if selected_without_host_prefix != selected_name + && let Some(entry) = devices.iter().find(|entry| { + entry.backend_name == selected_without_host_prefix + || entry.friendly_name == selected_without_host_prefix + || entry.legacy_display_name == selected_without_host_prefix + }) + { + return Some(entry.clone()); + } + if let Some((base_name, duplicate_index)) = parse_indexed_name(selected_name) { let mut matched_index = 0usize; - for (device, raw_name, display_name) in devices { - if raw_name == &base_name { + for entry in devices { + if entry.friendly_name == base_name { matched_index += 1; if matched_index == duplicate_index { - return Some((device.clone(), raw_name.clone(), display_name.clone())); + return Some(entry.clone()); } } } - return None; } + let candidate_names = expand_selector_candidates(selected_name); devices .iter() - .find(|(_, raw_name, display_name)| { - raw_name == selected_name || display_name == selected_name - }) - .map(|(device, raw_name, display_name)| { - (device.clone(), raw_name.clone(), display_name.clone()) - }) + .find(|entry| candidate_names.contains(&entry.backend_name)) + .cloned() } fn parse_indexed_name(name: &str) -> Option<(String, usize)> { @@ -177,3 +232,189 @@ fn parse_indexed_name(name: &str) -> Option<(String, usize)> { } Some((base_name, parsed_index)) } + +fn strip_host_prefix(name: &str) -> &str { + let Some((prefix, remainder)) = name.split_once(':') else { + return name; + }; + if prefix.eq_ignore_ascii_case("alsa") + || prefix.eq_ignore_ascii_case("coreaudio") + || prefix.eq_ignore_ascii_case("wasapi") + || prefix.eq_ignore_ascii_case("asio") + || prefix.eq_ignore_ascii_case("jack") + || prefix.eq_ignore_ascii_case("pipewire") + || prefix.eq_ignore_ascii_case("aaudio") + || prefix.eq_ignore_ascii_case("webaudio") + || prefix.eq_ignore_ascii_case("emscripten") + || prefix.eq_ignore_ascii_case("null") + { + return remainder.trim(); + } + name +} + +fn expand_selector_candidates(selected_name: &str) -> Vec { + let mut candidates = Vec::new(); + push_candidate(&mut candidates, selected_name); + + let Some((prefix_raw, remainder_raw)) = selected_name.split_once(':') else { + return candidates; + }; + let prefix = prefix_raw.trim().to_ascii_lowercase(); + if prefix != "hw" && prefix != "plughw" { + return candidates; + } + let remainder = remainder_raw.trim(); + if remainder.is_empty() { + return candidates; + } + + let Some((card, dev, subdev)) = parse_alsa_selector_parts(remainder) else { + return candidates; + }; + let normalized_prefix = prefix.as_str(); + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card, dev), + ); + push_candidate( + &mut candidates, + &format!("{}:CARD={},DEV={}", normalized_prefix, card, dev), + ); + + if let Some(subdev) = subdev.as_deref() { + push_candidate( + &mut candidates, + &format!("{}:{},{},{}", normalized_prefix, card, dev, subdev), + ); + if subdev == "0" { + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card, dev), + ); + } else { + push_candidate( + &mut candidates, + &format!( + "{}:CARD={},DEV={},SUBDEV={}", + normalized_prefix, card, dev, subdev + ), + ); + } + } else { + push_candidate( + &mut candidates, + &format!("{}:{},{},0", normalized_prefix, card, dev), + ); + } + + if !is_ascii_digits(&card) + && let Some(card_index) = lookup_alsa_card_index(&card) + { + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card_index, dev), + ); + push_candidate( + &mut candidates, + &format!("{}:CARD={},DEV={}", normalized_prefix, card_index, dev), + ); + } + + candidates +} + +fn push_candidate(candidates: &mut Vec, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + if candidates.iter().any(|existing| existing == trimmed) { + return; + } + candidates.push(trimmed.to_string()); +} + +fn parse_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + if remainder.contains("CARD=") || remainder.contains("DEV=") { + parse_named_alsa_selector_parts(remainder) + } else { + parse_short_alsa_selector_parts(remainder) + } +} + +fn parse_short_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + let mut parts = remainder.split(',').map(str::trim); + let card = parts.next()?.to_string(); + let dev = parts.next()?.to_string(); + if card.is_empty() || dev.is_empty() { + return None; + } + let subdev = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + Some((card, dev, subdev)) +} + +fn parse_named_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + let mut card: Option = None; + let mut dev: Option = None; + let mut subdev: Option = None; + + for token in remainder.split(',') { + let token = token.trim(); + let Some((key, value)) = token.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_uppercase(); + let value = value.trim().to_string(); + if value.is_empty() { + continue; + } + match key.as_str() { + "CARD" => card = Some(value), + "DEV" => dev = Some(value), + "SUBDEV" => subdev = Some(value), + _ => {} + } + } + + Some((card?, dev?, subdev)) +} + +fn is_ascii_digits(value: &str) -> bool { + !value.is_empty() && value.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(target_os = "linux")] +fn lookup_alsa_card_index(card_name: &str) -> Option { + let cards = std::fs::read_to_string("/proc/asound/cards").ok()?; + for line in cards.lines() { + let line = line.trim_start(); + let Some(bracket_start) = line.find('[') else { + continue; + }; + let Some(bracket_end_offset) = line[bracket_start + 1..].find(']') else { + continue; + }; + let bracket_end = bracket_start + 1 + bracket_end_offset; + let short_name = line[bracket_start + 1..bracket_end].trim(); + if !short_name.eq_ignore_ascii_case(card_name) { + continue; + } + let Some(index_str) = line.split_whitespace().next() else { + continue; + }; + if let Ok(index) = index_str.parse::() { + return Some(index); + } + } + None +} + +#[cfg(not(target_os = "linux"))] +fn lookup_alsa_card_index(_card_name: &str) -> Option { + None +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 4f11e18..3c28556 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -592,16 +592,17 @@ function App() { } function handleAudioBackendChange(nextBackend: string) { + const normalizedBackend = nextBackend.trim() || "default"; setSettingsDraft((prev) => ({ ...prev, - audio_backend: nextBackend, + audio_backend: normalizedBackend, })); - if ((visualizerConfig?.audio_backend ?? "default") === nextBackend) { + if ((visualizerConfig?.audio_backend ?? "default") === normalizedBackend) { return; } void applySettingsPatch( - { audio_backend: nextBackend }, - `Audio backend set to ${nextBackend}.`, + { audio_backend: normalizedBackend }, + `Audio backend set to ${normalizedBackend}.`, ); } @@ -1030,18 +1031,34 @@ function App() {