1166 lines
46 KiB
Rust
1166 lines
46 KiB
Rust
//! 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::Storage> {
|
||
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||
}
|
||
|
||
fn load_pref(key: &str) -> Option<String> {
|
||
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<bool>,
|
||
needs_login: RwSignal<bool>,
|
||
queue: RwSignal<Vec<Track>>,
|
||
queue_pos: RwSignal<u32>,
|
||
resolving: RwSignal<bool>,
|
||
now_playing: RwSignal<Option<Track>>,
|
||
play_state: RwSignal<PlayState>,
|
||
volume: RwSignal<f32>,
|
||
mute: RwSignal<bool>,
|
||
mods: RwSignal<QueueModifiers>,
|
||
position: RwSignal<TrackPosition>,
|
||
capture_lines: RwSignal<Vec<(String, bool)>>,
|
||
library: RwSignal<LibraryPane>,
|
||
queue_cursor: RwSignal<QueueCursor>,
|
||
focus: RwSignal<Focus>,
|
||
dialog: RwSignal<Option<Dialog>>,
|
||
toast: RwSignal<Option<String>>,
|
||
/// The transport; local because the wasm client is not `Send`.
|
||
rpc: StoredValue<Option<Rpc>, LocalStorage>,
|
||
/// Library listing cache (`state::is_cacheable` decides entry).
|
||
cache: StoredValue<HashMap<String, LibraryNode>, LocalStorage>,
|
||
board: StoredValue<CaptureBoard, LocalStorage>,
|
||
}
|
||
|
||
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<Rpc> {
|
||
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<F>(&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<F>(&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! {
|
||
<div class="shell">
|
||
<TopBar store=store />
|
||
<main class="panes">
|
||
<LibraryView store=store />
|
||
<QueueView store=store />
|
||
</main>
|
||
<Transport store=store />
|
||
<Dialogs store=store />
|
||
{move || {
|
||
store
|
||
.toast
|
||
.get()
|
||
.map(|message| view! { <div class="toast">{message}</div> })
|
||
}}
|
||
</div>
|
||
}
|
||
}
|
||
|
||
#[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! {
|
||
<header class="topbar">
|
||
<span class="brand">"crabidy"</span>
|
||
<span
|
||
class="conn"
|
||
class:offline=move || !store.connected.get()
|
||
>
|
||
{move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }}
|
||
</span>
|
||
<span class="topbar-actions">
|
||
<button class="ghost" title="cycle theme (auto/light/dark)" on:click=cycle_theme>
|
||
"◐"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
title="help (?)"
|
||
on:click=move |_| store.dialog.set(Some(Dialog::Help))
|
||
>
|
||
"?"
|
||
</button>
|
||
</span>
|
||
</header>
|
||
}
|
||
}
|
||
|
||
#[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! {
|
||
<div class="toolbar">
|
||
<button
|
||
class="ghost"
|
||
title="parent folder (h)"
|
||
disabled=pane.parent.is_none()
|
||
on:click=move |_| store.dispatch(Action::LibraryAscend)
|
||
>
|
||
"‹"
|
||
</button>
|
||
<span class="path" title=pane.path.clone()>{pane.title.clone()}</span>
|
||
<span class="spacer"></span>
|
||
<Show when=move || library.with(|p| p.is_creatable)>
|
||
<button
|
||
class="ghost"
|
||
title="create node here (%)"
|
||
on:click=move |_| store.dispatch(Action::LibraryCreateNode)
|
||
>
|
||
"+"
|
||
</button>
|
||
</Show>
|
||
<button
|
||
class="ghost"
|
||
disabled=!queueable
|
||
title="replace queue (Enter)"
|
||
on:click=move |_| store.dispatch(Action::LibraryQueueReplace)
|
||
>
|
||
"play"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
disabled=!queueable
|
||
title="queue after current (L)"
|
||
on:click=move |_| store.dispatch(Action::LibraryQueueNext)
|
||
>
|
||
"next"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
disabled=!queueable
|
||
title="append to queue (a)"
|
||
on:click=move |_| store.dispatch(Action::LibraryQueueAppend)
|
||
>
|
||
"add"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
disabled=!queueable
|
||
title="bookmark (w)"
|
||
on:click=move |_| store.dispatch(Action::LibraryCaptureNode)
|
||
>
|
||
"★"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
disabled=!selection.is_some_and(|i| i.is_queable && i.is_downloadable)
|
||
title="capture: download audio (W)"
|
||
on:click=move |_| store.dispatch(Action::LibraryDownloadNode)
|
||
>
|
||
"⭳"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
disabled=selection.is_none_or(|i| !i.is_editable)
|
||
title="rename (e)"
|
||
on:click=move |_| store.dispatch(Action::LibraryEditNode)
|
||
>
|
||
"e"
|
||
</button>
|
||
<button
|
||
class="ghost danger"
|
||
disabled=selection.is_none_or(|i| !i.is_deletable)
|
||
title="delete (d)"
|
||
on:click=move |_| store.dispatch(Action::LibraryDeleteNode)
|
||
>
|
||
"d"
|
||
</button>
|
||
</div>
|
||
}
|
||
};
|
||
|
||
view! {
|
||
<section
|
||
class="pane library"
|
||
class:focused=focused
|
||
aria-label="library"
|
||
on:mousedown=move |_| store.focus.set(Focus::Library)
|
||
>
|
||
{toolbar}
|
||
<ul class="list">
|
||
{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! {
|
||
<li
|
||
class:selected=index == pane.selected
|
||
class:marked=item.marked
|
||
class:skipped=item.is_skipped
|
||
class:node=is_node
|
||
on:click=move |_| store.library.update(|p| p.select(index))
|
||
on:dblclick=move |_| {
|
||
if is_node {
|
||
store.open_library_node(path.clone());
|
||
} else {
|
||
store.library.update(|p| p.select(index));
|
||
store.dispatch(Action::LibraryQueueReplace);
|
||
}
|
||
}
|
||
>
|
||
<span class="title">
|
||
{if is_node { format!("{}/", item.title) } else { item.title.clone() }}
|
||
</span>
|
||
{(!badge.is_empty())
|
||
.then(|| view! { <span class="badge">{format!("[{badge}]")}</span> })}
|
||
</li>
|
||
}
|
||
})
|
||
.collect_view()
|
||
}}
|
||
</ul>
|
||
<div class="capture-lines">
|
||
{move || {
|
||
store
|
||
.capture_lines
|
||
.get()
|
||
.into_iter()
|
||
.map(|(line, is_error)| {
|
||
view! { <div class="capture-line" class:error=is_error>{line}</div> }
|
||
})
|
||
.collect_view()
|
||
}}
|
||
</div>
|
||
</section>
|
||
}
|
||
}
|
||
|
||
#[component]
|
||
fn QueueView(store: Store) -> impl IntoView {
|
||
let focused = move || store.focus.get() == Focus::Queue;
|
||
view! {
|
||
<section
|
||
class="pane queue"
|
||
class:focused=focused
|
||
aria-label="queue"
|
||
on:mousedown=move |_| store.focus.set(Focus::Queue)
|
||
>
|
||
<div class="toolbar">
|
||
<span class="path">
|
||
"queue"
|
||
{move || store.resolving.get().then_some(" (loading…)")}
|
||
</span>
|
||
<span class="spacer"></span>
|
||
<button
|
||
class="ghost"
|
||
title="insert library selection after selected (p)"
|
||
on:click=move |_| store.dispatch(Action::QueueInsertHere)
|
||
>
|
||
"insert"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
title="save queue (w)"
|
||
on:click=move |_| store.dispatch(Action::QueueSaveAs)
|
||
>
|
||
"save"
|
||
</button>
|
||
<button
|
||
class="ghost"
|
||
title="clear, keep current (c)"
|
||
on:click=move |_| store.dispatch(Action::QueueClearKeepCurrent)
|
||
>
|
||
"clear"
|
||
</button>
|
||
<button
|
||
class="ghost danger"
|
||
title="clear all (C)"
|
||
on:click=move |_| store.dispatch(Action::QueueClearAll)
|
||
>
|
||
"clear all"
|
||
</button>
|
||
</div>
|
||
<ul class="list">
|
||
{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! {
|
||
<li
|
||
class:selected=index == cursor
|
||
class:current=index == current
|
||
class:skipped=track.is_skipped
|
||
on:click=move |_| {
|
||
store.queue_cursor.update(|c| c.selected = index)
|
||
}
|
||
on:dblclick=move |_| {
|
||
store.queue_cursor.update(|c| c.selected = index);
|
||
store.dispatch(Action::QueuePlaySelected);
|
||
}
|
||
>
|
||
<span class="title">{label}</span>
|
||
<button
|
||
class="ghost danger row-action"
|
||
title="remove (d)"
|
||
on:click=move |ev| {
|
||
ev.stop_propagation();
|
||
store.queue_cursor.update(|c| c.selected = index);
|
||
store.dispatch(Action::QueueRemoveTrack);
|
||
}
|
||
>
|
||
"×"
|
||
</button>
|
||
</li>
|
||
}
|
||
})
|
||
.collect_view()
|
||
}}
|
||
</ul>
|
||
</section>
|
||
}
|
||
}
|
||
|
||
#[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::<f32>() {
|
||
let delta = target - store.volume.get_untracked();
|
||
store.call(async move |mut rpc: Rpc| rpc.change_volume(delta).await);
|
||
}
|
||
};
|
||
view! {
|
||
<footer class="transport">
|
||
<div class="controls">
|
||
<button class="ghost" title="previous (Ctrl-p)"
|
||
on:click=move |_| store.dispatch(Action::PrevTrack)>"⏮"</button>
|
||
<button class="ghost big" title="play/pause (Space)"
|
||
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
|
||
<button class="ghost" title="next (Ctrl-n)"
|
||
on:click=move |_| store.dispatch(Action::NextTrack)>"⏭"</button>
|
||
<button class="ghost" title="restart track (r)"
|
||
on:click=move |_| store.dispatch(Action::RestartTrack)>"↺"</button>
|
||
<button
|
||
class="ghost"
|
||
class:active=move || store.mods.get().shuffle
|
||
title="shuffle (z)"
|
||
on:click=move |_| store.dispatch(Action::ToggleShuffle)
|
||
>"⇄"</button>
|
||
<button
|
||
class="ghost"
|
||
class:active=move || store.mods.get().repeat
|
||
title="repeat (x)"
|
||
on:click=move |_| store.dispatch(Action::ToggleRepeat)
|
||
>"↻"</button>
|
||
</div>
|
||
<div class="now-playing">
|
||
<span class="np-title">
|
||
{move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()}
|
||
</span>
|
||
<div class="progress">
|
||
<span class="time">
|
||
{move || format_seconds(store.position.get().position)}
|
||
</span>
|
||
<div class="gauge">
|
||
<div
|
||
class="gauge-fill"
|
||
style:width=move || {
|
||
let p = store.position.get();
|
||
if p.duration == 0 {
|
||
"0%".to_string()
|
||
} else {
|
||
format!(
|
||
"{:.1}%",
|
||
f64::from(p.position.min(p.duration)) * 100.0
|
||
/ f64::from(p.duration),
|
||
)
|
||
}
|
||
}
|
||
></div>
|
||
</div>
|
||
<span class="time">
|
||
{move || format_seconds(store.position.get().duration)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="volume">
|
||
<button
|
||
class="ghost"
|
||
class:active=move || store.mute.get()
|
||
title="mute (m)"
|
||
on:click=move |_| store.dispatch(Action::ToggleMute)
|
||
>
|
||
{move || if store.mute.get() { "🔇" } else { "🔊" }}
|
||
</button>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1.5"
|
||
step="0.05"
|
||
prop:value=move || store.volume.get().to_string()
|
||
title="volume (J/K)"
|
||
on:input=on_volume
|
||
/>
|
||
</div>
|
||
</footer>
|
||
}
|
||
}
|
||
|
||
#[component]
|
||
fn Dialogs(store: Store) -> impl IntoView {
|
||
move || {
|
||
store.dialog.get().map(|dialog| match dialog {
|
||
Dialog::Name { purpose, buffer } => {
|
||
view! { <NameDialog store=store purpose=purpose buffer=buffer /> }.into_any()
|
||
}
|
||
Dialog::ConfirmDelete { path, title } => {
|
||
view! { <ConfirmDialog store=store path=path title=title /> }.into_any()
|
||
}
|
||
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
|
||
Dialog::Help => view! { <HelpOverlay store=store /> }.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! {
|
||
<div class="overlay" on:click=move |_| store.dialog.set(None)>
|
||
<form class="dialog" on:click=|ev| ev.stop_propagation() on:submit=submit>
|
||
<label>{label}</label>
|
||
<input
|
||
type="text"
|
||
autofocus
|
||
prop:value=move || value.get()
|
||
on:input=move |ev| value.set(event_target_value(&ev))
|
||
on:keydown=move |ev| {
|
||
if ev.key() == "Escape" {
|
||
store.dialog.set(None);
|
||
}
|
||
}
|
||
/>
|
||
<div class="dialog-actions">
|
||
<button type="button" class="ghost" on:click=move |_| store.dialog.set(None)>
|
||
"cancel"
|
||
</button>
|
||
<button type="submit">"ok"</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
#[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! {
|
||
<div class="overlay" on:click=move |_| store.dialog.set(None)>
|
||
<div class="dialog danger-dialog" on:click=|ev| ev.stop_propagation()>
|
||
<p>
|
||
"Delete "<strong>{title}</strong>
|
||
" from disk (downloaded audio included)?"
|
||
</p>
|
||
<div class="dialog-actions">
|
||
<button class="ghost" on:click=move |_| store.dialog.set(None)>
|
||
"cancel (N)"
|
||
</button>
|
||
<button class="danger" on:click=confirm>"delete (y)"</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
#[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! {
|
||
<div class="overlay">
|
||
<form class="dialog" on:submit=submit>
|
||
<label>"This server requires credentials (architecture/roles-auth.md)"</label>
|
||
<input
|
||
type="text"
|
||
placeholder="role: owner | queue-owner | queue-appender"
|
||
autofocus
|
||
prop:value=move || user.get()
|
||
on:input=move |ev| user.set(event_target_value(&ev))
|
||
/>
|
||
<input
|
||
type="password"
|
||
placeholder="password"
|
||
prop:value=move || password.get()
|
||
on:input=move |ev| password.set(event_target_value(&ev))
|
||
/>
|
||
<div class="dialog-actions">
|
||
<button type="submit">"connect"</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
#[component]
|
||
fn HelpOverlay(store: Store) -> impl IntoView {
|
||
let groups = ["Global", "Library", "Queue", "Help"];
|
||
view! {
|
||
<div class="overlay" on:click=move |_| store.dialog.set(None)>
|
||
<div class="dialog help" on:click=|ev| ev.stop_propagation()>
|
||
<h2>"Key bindings"</h2>
|
||
<div class="help-columns">
|
||
{groups
|
||
.into_iter()
|
||
.map(|group| {
|
||
view! {
|
||
<section>
|
||
<h3>{group}</h3>
|
||
<table>
|
||
<tbody>
|
||
{keymap::HELP
|
||
.iter()
|
||
.filter(|entry| entry.scope == group)
|
||
.map(|entry| {
|
||
view! {
|
||
<tr>
|
||
<td class="key">{entry.key}</td>
|
||
<td>{entry.description}</td>
|
||
</tr>
|
||
}
|
||
})
|
||
.collect_view()}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
}
|
||
})
|
||
.collect_view()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|