//! The component tree: one store of signals fed by the update stream, //! one action dispatcher mirroring the TUI's (`cbd-tui/src/app/mod.rs` //! dispatch), thin views. Pure logic lives in [`crate::state`] / //! [`crate::keymap`]. use std::collections::HashMap; use crabidy_core::proto::crabidy::{ get_update_stream_response::Update as StreamUpdate, LibraryNode, PlayState, QueueModifiers, Track, TrackPosition, }; use leptos::prelude::*; use leptos::task::spawn_local; use crate::keymap::{self, Action}; use crate::rpc::Rpc; use crate::state::{ delete_needs_confirmation, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, NamePurpose, QueueCursor, UiItemKind, }; const VOLUME_STEP: f32 = 0.1; const JUMP: isize = 15; /// Stream reconnect backoff bounds, milliseconds. const BACKOFF_MIN_MS: u32 = 1_000; const BACKOFF_MAX_MS: u32 = 10_000; /// How long an error toast stays visible. const TOAST_MS: u32 = 4_000; // ---- browser glue ------------------------------------------------------ fn local_storage() -> Option { web_sys::window().and_then(|w| w.local_storage().ok().flatten()) } fn load_pref(key: &str) -> Option { local_storage().and_then(|s| s.get_item(key).ok().flatten()) } fn save_pref(key: &str, value: &str) { if let Some(storage) = local_storage() { let _ = storage.set_item(key, value); } } fn now_ms() -> f64 { web_sys::window() .and_then(|w| w.performance()) .map(|p| p.now()) .unwrap_or(0.0) } /// Applies the persisted (or OS) theme by stamping `data-theme` on the /// root element; `auto` removes the override. fn apply_theme(theme: &str) { if let Some(root) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.document_element()) { if theme == "auto" { let _ = root.remove_attribute("data-theme"); } else { let _ = root.set_attribute("data-theme", theme); } } } // ---- the store --------------------------------------------------------- /// Every signal the components share. `Copy` so closures capture it /// freely (signals are arena handles). #[derive(Clone, Copy)] struct Store { connected: RwSignal, needs_login: RwSignal, queue: RwSignal>, queue_pos: RwSignal, resolving: RwSignal, now_playing: RwSignal>, play_state: RwSignal, volume: RwSignal, mute: RwSignal, mods: RwSignal, position: RwSignal, capture_lines: RwSignal>, library: RwSignal, queue_cursor: RwSignal, focus: RwSignal, dialog: RwSignal>, toast: RwSignal>, /// The transport; local because the wasm client is not `Send`. rpc: StoredValue, LocalStorage>, /// Library listing cache (`state::is_cacheable` decides entry). cache: StoredValue, LocalStorage>, board: StoredValue, } impl Store { fn new() -> Self { Self { connected: RwSignal::new(false), needs_login: RwSignal::new(false), queue: RwSignal::new(Vec::new()), queue_pos: RwSignal::new(0), resolving: RwSignal::new(false), now_playing: RwSignal::new(None), play_state: RwSignal::new(PlayState::Unspecified), volume: RwSignal::new(1.0), mute: RwSignal::new(false), mods: RwSignal::new(QueueModifiers::default()), position: RwSignal::new(TrackPosition::default()), capture_lines: RwSignal::new(Vec::new()), library: RwSignal::new(LibraryPane::default()), queue_cursor: RwSignal::new(QueueCursor::default()), focus: RwSignal::new(Focus::Library), dialog: RwSignal::new(None), toast: RwSignal::new(None), rpc: StoredValue::new_local(None), cache: StoredValue::new_local(HashMap::new()), board: StoredValue::new_local(CaptureBoard::default()), } } fn rpc(&self) -> Option { self.rpc.with_value(Clone::clone) } /// Surfaces an RPC failure. `PERMISSION_DENIED` carries the /// required role — the message is user-meaningful as-is. fn fail(&self, status: tonic::Status) { if status.code() == tonic::Code::Unauthenticated { self.needs_login.set(true); self.dialog.set(Some(Dialog::Login)); return; } let toast = self.toast; toast.set(Some(status.message().to_string())); spawn_local(async move { gloo_timers::future::TimeoutFuture::new(TOAST_MS).await; toast.set(None); }); } fn refresh_capture_lines(&self) { let lines = self .board .try_update_value(|b| b.lines(now_ms())) .unwrap_or_default(); self.capture_lines.set(lines); } fn apply(&self, update: StreamUpdate) { match update { StreamUpdate::Queue(queue) => { self.queue_pos.set(queue.current_position); self.resolving.set(queue.resolving); self.queue.set(queue.tracks); let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.clamp(len)); } StreamUpdate::Mods(mods) => self.mods.set(mods), StreamUpdate::QueueTrack(queue_track) => { self.queue_pos.set(queue_track.queue_position); self.now_playing.set(queue_track.track); } StreamUpdate::PlayState(state) => { self.play_state .set(PlayState::try_from(state).unwrap_or(PlayState::Unspecified)); } StreamUpdate::Volume(volume) => self.volume.set(volume), StreamUpdate::Mute(mute) => self.mute.set(mute), StreamUpdate::Position(position) => self.position.set(position), StreamUpdate::CaptureProgress(progress) => { self.board.update_value(|b| b.apply(progress, now_ms())); self.refresh_capture_lines(); } } } /// Fetches (or serves from cache) a listing and opens it in the /// library pane. fn open_library_node(&self, path: String) { let this = *self; if is_cacheable(&path) { let cached = self.cache.with_value(|c| c.get(&path).cloned()); if let Some(node) = cached { this.library.update(|pane| pane.update(&node)); return; } } let Some(mut rpc) = self.rpc() else { return }; spawn_local(async move { match rpc.get_library_node(&path).await { Ok(Some(node)) => { if is_cacheable(&path) { this.cache .update_value(|c| _ = c.insert(path.clone(), node.clone())); } this.library.update(|pane| pane.update(&node)); } Ok(None) => {} Err(status) => this.fail(status), } }); } /// Runs a queue-mutating RPC and clears the library marks on /// success, like every TUI queue op. fn queue_op(&self, call: F) where F: AsyncFnOnce(Rpc) -> Result<(), tonic::Status> + 'static, { let Some(rpc) = self.rpc() else { return }; let this = *self; spawn_local(async move { match call(rpc).await { Ok(()) => this.library.update(LibraryPane::remove_marks), Err(status) => this.fail(status), } }); } /// Fire-and-forget RPC (transport & playback controls). fn call(&self, call: F) where F: AsyncFnOnce(Rpc) -> Result<(), tonic::Status> + 'static, { let Some(rpc) = self.rpc() else { return }; let this = *self; spawn_local(async move { if let Err(status) = call(rpc).await { this.fail(status); } }); } fn delete_node(&self, path: String) { let Some(mut rpc) = self.rpc() else { return }; let this = *self; spawn_local(async move { match rpc.delete_library_node(&path).await { // The response is the refreshed parent listing. Ok(Some(parent)) => this.library.update(|pane| pane.update(&parent)), Ok(None) => {} Err(status) => this.fail(status), } }); } /// Executes one keymap action — the web twin of the TUI dispatch. fn dispatch(&self, action: Action) { match action { Action::OpenHelp => self.dialog.set(Some(Dialog::Help)), Action::CloseHelp => self.dialog.set(None), Action::CycleFocus => self.focus.update(|f| { *f = match f { Focus::Library => Focus::Queue, Focus::Queue => Focus::Library, } }), Action::TogglePlay => self.call(async |mut rpc: Rpc| rpc.toggle_play().await), Action::RestartTrack => self.call(async |mut rpc: Rpc| rpc.restart_track().await), Action::NextTrack => self.call(async |mut rpc: Rpc| rpc.next().await), Action::PrevTrack => self.call(async |mut rpc: Rpc| rpc.prev().await), Action::VolumeUp => { self.call(async |mut rpc: Rpc| rpc.change_volume(VOLUME_STEP).await) } Action::VolumeDown => { self.call(async |mut rpc: Rpc| rpc.change_volume(-VOLUME_STEP).await) } Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await), Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().await), Action::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await), Action::LibraryNext => self.library.update(|p| p.select_by(1)), Action::LibraryPrev => self.library.update(|p| p.select_by(-1)), Action::LibraryFirst => self.library.update(LibraryPane::select_first), Action::LibraryLast => self.library.update(LibraryPane::select_last), Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)), Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)), Action::LibraryAscend => { if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) { self.open_library_node(parent); } } Action::LibraryDive => { let target = self.library.with_untracked(|p| { p.selected_item() .filter(|i| i.kind == UiItemKind::Node) .map(|i| i.path.clone()) }); if let Some(path) = target { self.open_library_node(path); } } Action::LibraryToggleMark => self.library.update(LibraryPane::toggle_mark), Action::LibraryQueueReplace => { if let Some(paths) = self .library .with_untracked(LibraryPane::queueable_selection) { self.queue_op(async |mut rpc: Rpc| rpc.replace_queue(paths).await); } } Action::LibraryQueueAppend => { if let Some(paths) = self .library .with_untracked(LibraryPane::queueable_selection) { self.queue_op(async |mut rpc: Rpc| rpc.append_tracks(paths).await); } } Action::LibraryQueueNext => { if let Some(paths) = self .library .with_untracked(LibraryPane::queueable_selection) { self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await); } } Action::QueueInsertHere => { let position = self.queue_cursor.with_untracked(|c| c.selected as u32); if let Some(paths) = self .library .with_untracked(LibraryPane::queueable_selection) { self.queue_op(async move |mut rpc: Rpc| { rpc.insert_tracks(position, paths).await }); } } Action::LibraryCreateNode => { let creatable = self .library .with_untracked(|p| p.is_creatable.then(|| p.path.clone())); if let Some(parent_path) = creatable { self.dialog.set(Some(Dialog::Name { purpose: NamePurpose::Create { parent_path }, buffer: String::new(), })); } } Action::LibraryEditNode => { if let Some((path, title)) = self.library.with_untracked(LibraryPane::selected_editable) { self.dialog.set(Some(Dialog::Name { purpose: NamePurpose::Rename { path }, buffer: title, })); } } Action::LibraryDeleteNode => { if let Some((path, title)) = self.library.with_untracked(LibraryPane::selected_deletable) { if delete_needs_confirmation(&path) { self.dialog.set(Some(Dialog::ConfirmDelete { path, title })); } else { self.delete_node(path); } } } Action::LibraryCaptureNode => { if let Some((path, title)) = self.library.with_untracked(LibraryPane::selected_queueable) { self.dialog.set(Some(Dialog::Name { purpose: NamePurpose::Capture { path, download: false, }, buffer: title, })); } } Action::LibraryDownloadNode => { if let Some((path, title)) = self .library .with_untracked(LibraryPane::selected_downloadable) { self.dialog.set(Some(Dialog::Name { purpose: NamePurpose::Capture { path, download: true, }, buffer: title, })); } } Action::QueueNext => { let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.select_by(1, len)); } Action::QueuePrev => { let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.select_by(-1, len)); } Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0), Action::QueueLast => { let len = self.queue.with_untracked(Vec::len); self.queue_cursor .update(|c| c.selected = len.saturating_sub(1)); } Action::QueueJumpDown => { let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.select_by(JUMP, len)); } Action::QueueJumpUp => { let len = self.queue.with_untracked(Vec::len); self.queue_cursor.update(|c| c.select_by(-JUMP, len)); } Action::QueueSelectCurrent => { let current = self.queue_pos.get_untracked() as usize; let len = self.queue.with_untracked(Vec::len); self.queue_cursor .update(|c| c.selected = current.min(len.saturating_sub(1))); } Action::QueuePlaySelected => { let position = self.queue_cursor.with_untracked(|c| c.selected as u32); if self.queue.with_untracked(|q| !q.is_empty()) { self.call(async move |mut rpc: Rpc| rpc.set_current(position).await); } } Action::QueueRemoveTrack => { let position = self.queue_cursor.with_untracked(|c| c.selected as u32); if self.queue.with_untracked(|q| !q.is_empty()) { self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await); } } Action::QueueClearKeepCurrent => { self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await) } Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await), Action::QueueSaveAs => { if self.queue.with_untracked(|q| !q.is_empty()) { self.dialog.set(Some(Dialog::Name { purpose: NamePurpose::SaveQueue, buffer: String::new(), })); } } } } /// Submits the name dialog (Enter) — the TUI's `handle_input_key` /// submit arm. fn submit_name(&self, purpose: NamePurpose, title: String) { self.dialog.set(None); let title = title.trim().to_string(); if title.is_empty() { return; } let this = *self; match purpose { NamePurpose::Create { parent_path } => { let Some(mut rpc) = self.rpc() else { return }; spawn_local(async move { match rpc.create_library_node(&parent_path, &title).await { Ok(Some(node)) => this.library.update(|pane| pane.update(&node)), Ok(None) => {} Err(status) => this.fail(status), } }); } NamePurpose::Rename { path } => { let Some(mut rpc) = self.rpc() else { return }; spawn_local(async move { match rpc.rename_library_node(&path, &title).await { Ok(Some(node)) => this.library.update(|pane| pane.update(&node)), Ok(None) => {} Err(status) => this.fail(status), } }); } NamePurpose::SaveQueue => { self.call(async move |mut rpc: Rpc| rpc.save_queue(&title).await) } NamePurpose::Capture { path, download } => { self.call(async move |mut rpc: Rpc| { rpc.capture_library_node(&path, &title, download).await }); } } } } // ---- startup ----------------------------------------------------------- /// Connects, seeds the state, and pumps the update stream forever /// (capped backoff). `UNAUTHENTICATED` stops the loop and opens the /// login dialog — the submit stores credentials and reloads. fn run_stream(store: Store) { spawn_local(async move { let mut backoff = BACKOFF_MIN_MS; loop { let Some(mut rpc) = store.rpc() else { return }; match rpc.update_stream().await { Ok(mut stream) => { store.connected.set(true); backoff = BACKOFF_MIN_MS; // Seed everything the stream only reports on change. match rpc.init().await { Ok(init) => { if let Some(queue) = init.queue { store.apply(StreamUpdate::Queue(queue)); } if let Some(mods) = init.mods { store.apply(StreamUpdate::Mods(mods)); } if let Some(queue_track) = init.queue_track { store.apply(StreamUpdate::QueueTrack(queue_track)); } store.apply(StreamUpdate::PlayState(init.play_state)); store.apply(StreamUpdate::Volume(init.volume)); store.apply(StreamUpdate::Mute(init.mute)); if let Some(position) = init.position { store.apply(StreamUpdate::Position(position)); } } Err(status) => store.fail(status), } if store.library.with_untracked(|p| p.path.is_empty()) { store.open_library_node("/".to_string()); } loop { match stream.message().await { Ok(Some(response)) => { if let Some(update) = response.update { store.apply(update); } } Ok(None) => break, Err(status) => { store.fail(status); break; } } } store.connected.set(false); } Err(status) if status.code() == tonic::Code::Unauthenticated => { store.needs_login.set(true); store.dialog.set(Some(Dialog::Login)); return; } Err(_) => { store.connected.set(false); } } gloo_timers::future::TimeoutFuture::new(backoff).await; backoff = (backoff * 2).min(BACKOFF_MAX_MS); } }); // Capture lines expire on wall time, not only on stream events. spawn_local(async move { loop { gloo_timers::future::TimeoutFuture::new(1_000).await; store.refresh_capture_lines(); } }); } // ---- components -------------------------------------------------------- /// Root component: builds the store, connects, wires the keyboard. #[component] pub fn App() -> impl IntoView { let store = Store::new(); apply_theme(&load_pref("theme").unwrap_or_else(|| "auto".to_string())); let origin = web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_else(|| "http://127.0.0.1:50051".to_string()); let user = load_pref("user").unwrap_or_default(); let password = load_pref("password").unwrap_or_default(); store.rpc.set_value(Rpc::new(origin, &user, &password)); run_stream(store); // Global keyboard handling; dialogs are modal (their inputs handle // their own keys), and browser defaults for handled chords are // suppressed so Space does not scroll or Tab move focus. let _handle = window_event_listener(leptos::ev::keydown, move |ev| { let dialog_open = store.dialog.with_untracked(Option::is_some); let help_open = matches!(store.dialog.get_untracked(), Some(Dialog::Help)); if dialog_open && !help_open { return; } if ev.alt_key() || ev.meta_key() { return; } let key = ev.key(); if let Some(action) = keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key()) { ev.prevent_default(); store.dispatch(action); } }); view! {
{move || { store .toast .get() .map(|message| view! {
{message}
}) }}
} } #[component] fn TopBar(store: Store) -> impl IntoView { let cycle_theme = move |_| { let current = load_pref("theme").unwrap_or_else(|| "auto".to_string()); let next = match current.as_str() { "auto" => "light", "light" => "dark", _ => "auto", }; save_pref("theme", next); apply_theme(next); }; view! {
"crabidy" {move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }}
} } #[component] fn LibraryView(store: Store) -> impl IntoView { let focused = move || store.focus.get() == Focus::Library; let library = store.library; let toolbar = move || { let pane = library.get(); let selection = pane.selected_item(); let queueable = selection.is_some_and(|i| i.is_queable) || pane.items.iter().any(|i| i.marked); view! {
{pane.title.clone()}
} }; view! {
{toolbar}
    {move || { let pane = library.get(); pane.items .iter() .enumerate() .map(|(index, item)| { let is_node = item.kind == UiItemKind::Node; let marks = [ item.is_creatable.then_some("%"), item.is_editable.then_some("e"), item.is_deletable.then_some("d"), ]; let badge: String = marks.into_iter().flatten().collect(); let path = item.path.clone(); view! {
  • {if is_node { format!("{}/", item.title) } else { item.title.clone() }} {(!badge.is_empty()) .then(|| view! { {format!("[{badge}]")} })}
  • } }) .collect_view() }}
{move || { store .capture_lines .get() .into_iter() .map(|(line, is_error)| { view! {
{line}
} }) .collect_view() }}
} } #[component] fn QueueView(store: Store) -> impl IntoView { let focused = move || store.focus.get() == Focus::Queue; view! {
"queue" {move || store.resolving.get().then_some(" (loading…)")}
    {move || { let current = store.queue_pos.get() as usize; let cursor = store.queue_cursor.get().selected; store .queue .get() .iter() .enumerate() .map(|(index, track)| { let label = track_label(track); view! {
  • {label}
  • } }) .collect_view() }}
} } #[component] fn Transport(store: Store) -> impl IntoView { let state_symbol = move || match store.play_state.get() { PlayState::Playing => "⏸", PlayState::Loading => "…", _ => "▶", }; let on_volume = move |ev: leptos::ev::Event| { if let Ok(target) = event_target_value(&ev).parse::() { let delta = target - store.volume.get_untracked(); store.call(async move |mut rpc: Rpc| rpc.change_volume(delta).await); } }; view! {
{move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()}
{move || format_seconds(store.position.get().position)}
{move || format_seconds(store.position.get().duration)}
} } #[component] fn Dialogs(store: Store) -> impl IntoView { move || { store.dialog.get().map(|dialog| match dialog { Dialog::Name { purpose, buffer } => { view! { }.into_any() } Dialog::ConfirmDelete { path, title } => { view! { }.into_any() } Dialog::Login => view! { }.into_any(), Dialog::Help => view! { }.into_any(), }) } } #[component] fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView { let value = RwSignal::new(buffer); let label = purpose.label(); let submit_purpose = purpose.clone(); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); store.submit_name(submit_purpose.clone(), value.get_untracked()); }; view! {
} } #[component] fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView { let confirm_path = path.clone(); let confirm = move |_| { store.dialog.set(None); store.delete_node(confirm_path.clone()); }; // y/N without leaving the keyboard, like the TUI: the overlay grabs // the keys while it is open. let key_path = path; let handle = window_event_listener(leptos::ev::keydown, move |ev| { if !matches!( store.dialog.get_untracked(), Some(Dialog::ConfirmDelete { .. }) ) { return; } ev.prevent_default(); store.dialog.set(None); if matches!(ev.key().as_str(), "y" | "Y") { store.delete_node(key_path.clone()); } }); on_cleanup(move || handle.remove()); view! {

"Delete "{title} " from disk (downloaded audio included)?"

} } #[component] fn LoginDialog(store: Store) -> impl IntoView { // The dialog needs no store access: submitting reloads the page. let _ = store; let user = RwSignal::new(load_pref("user").unwrap_or_default()); let password = RwSignal::new(String::new()); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); save_pref("user", user.get_untracked().trim()); save_pref("password", &password.get_untracked()); // Reconnect from scratch with the new credentials. if let Some(window) = web_sys::window() { let _ = window.location().reload(); } }; view! {
} } #[component] fn HelpOverlay(store: Store) -> impl IntoView { let groups = ["Global", "Library", "Queue", "Help"]; view! {

"Key bindings"

{groups .into_iter() .map(|group| { view! {

{group}

{keymap::HELP .iter() .filter(|entry| entry.scope == group) .map(|entry| { view! { } }) .collect_view()}
{entry.key} {entry.description}
} }) .collect_view()}
} }