crabidy/cbd-web/src/app.rs

1182 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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>,
spectrum: RwSignal<Vec<f32>>,
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()),
spectrum: RwSignal::new(Vec::new()),
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();
}
StreamUpdate::Spectrum(frame) => self.spectrum.set(frame.bins),
}
}
/// 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">
<div class="spectrum" aria-hidden="true">
{move || {
store
.spectrum
.get()
.into_iter()
.map(|level| {
let pct = format!("{:.0}%", level.clamp(0.0, 1.0) * 100.0);
view! { <span class="bar" style:height=pct></span> }
})
.collect_view()
}}
</div>
<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>
}
}