init federation

This commit is contained in:
Ultradesu
2026-07-16 20:29:51 +03:00
parent 98525718d0
commit b1f8f4cb01
33 changed files with 6417 additions and 4019 deletions
+11 -9
View File
@@ -39,10 +39,10 @@ pub enum Action {
NewPlaylist,
ToggleHelp,
ToggleViewMode,
OpenDevices,
OpenCommandLine,
OpenSearch,
Logout,
EditSelected,
DeleteSelected,
}
/// Help-window sections, in display order.
@@ -50,15 +50,17 @@ pub enum Action {
pub enum Category {
Playback,
Queue,
Library,
Navigation,
Search,
System,
}
impl Category {
pub const ALL: [Category; 5] = [
pub const ALL: [Category; 6] = [
Category::Playback,
Category::Queue,
Category::Library,
Category::Navigation,
Category::Search,
Category::System,
@@ -68,6 +70,7 @@ impl Category {
match self {
Category::Playback => "Playback",
Category::Queue => "Queue & playlists",
Category::Library => "Library",
Category::Navigation => "Navigation",
Category::Search => "Search & commands",
Category::System => "System",
@@ -111,9 +114,9 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::OpenDevices => Category::System,
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
Action::ToggleHelp | Action::Quit => Category::System,
}
}
@@ -121,7 +124,7 @@ impl Action {
pub fn command_hint(&self) -> Option<&'static str> {
match self {
Action::Quit => Some(":q"),
Action::Logout => Some(":logout"),
Action::EditSelected => Some(":import <path> adds files"),
Action::PlayPause => Some(":play"),
Action::NextTrack => Some(":next"),
Action::PrevTrack => Some(":prev"),
@@ -130,7 +133,6 @@ impl Action {
Action::ToggleShuffle => Some(":shuffle"),
Action::CycleRepeat => Some(":repeat [off|one|all]"),
Action::ClearQueue => Some(":clear"),
Action::OpenDevices => Some(":devices"),
Action::ToggleHelp => Some(":help"),
Action::OpenSearch => Some("/text"),
_ => None,
@@ -174,10 +176,10 @@ impl Action {
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::OpenDevices => "Connected devices".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
Action::Logout => "Sign out".into(),
Action::EditSelected => "Edit the selected item".into(),
Action::DeleteSelected => "Delete the selected item".into(),
}
}
}
+31 -21
View File
@@ -4,7 +4,6 @@ use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::client::ApiError;
use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
@@ -59,7 +58,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
match command {
// One-shot commands have no live effect.
Command::Quit
| Command::Logout
| Command::Import(_)
| Command::Volume(_)
| Command::Seek(_)
| Command::SeekTo(_)
@@ -70,7 +69,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
| Command::Prev
| Command::PlayPause
| Command::Help
| Command::Devices
| Command::Logs(_) => {}
Command::Search(query) => {
state.active_tab = Tab::Global;
@@ -96,17 +94,17 @@ fn set_view_cursor_zero(state: &mut AppState) {
/// Debounced, race-free search: every edit bumps the global sequence; the
/// spawned task only queries if it is still the latest after the debounce,
/// and the receiver drops responses that arrive out of date.
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
let query = state.search.query.clone();
if query.is_empty() {
state.search.loading = false;
state.search.results = None;
state.search.fed_tracks.clear();
state.search.fed_loading = false;
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
state.search.loading = true;
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
@@ -115,19 +113,32 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
if latest.load(Ordering::SeqCst) != seq {
return;
}
let event = match api.search(&query, SEARCH_LIMIT).await {
Ok(results) => AppEvent::SearchLoaded {
seq,
result: Ok(results),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::SearchLoaded {
seq,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
.await
.map_err(|err| err.to_string())
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
});
// The same query also runs against the federated network (when the
// node is up); its results render as a separate, marked section.
state.search.fed_tracks.clear();
state.search.fed_loading = false;
if runtime.federation.settings().enabled {
state.search.fed_loading = true;
let fed = Arc::clone(&runtime.federation);
let query = state.search.query.clone();
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
tokio::spawn(async move {
tokio::time::sleep(SEARCH_DEBOUNCE).await;
if latest.load(Ordering::SeqCst) != seq {
return;
}
let result = fed.search(&query).await.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
});
}
}
/// Enter: close the line. Live commands already took effect (their view
@@ -161,7 +172,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
match command {
Command::Search(_) => {}
Command::Quit => state.should_quit = true,
Command::Logout => super::perform_logout(state, runtime),
Command::Import(path) => super::spawn_import(state, runtime, &path),
Command::Volume(value) => {
state.player.volume = value;
super::perform_effect(state, runtime, Effect::SetVolume(value));
@@ -194,7 +205,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
Command::Prev => run_action(state, runtime, Action::PrevTrack),
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
Command::Help => state.help_visible = true,
Command::Devices => run_action(state, runtime, Action::OpenDevices),
Command::Logs(level) => {
if let Some(index) = level {
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
+17 -8
View File
@@ -22,8 +22,9 @@ pub enum Command {
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
/// double-press confirmation).
Quit,
/// `:logout` — sign out and return to the login screen.
Logout,
/// `:import <path>` — import an audio file or a directory into the
/// library.
Import(String),
/// `:volume 40` (also `:vol`) — set the volume precisely.
Volume(u8),
/// `:seek +30` / `:seek -10` — relative seek in seconds.
@@ -43,8 +44,6 @@ pub enum Command {
PlayPause,
/// `:help` — open the keybinding help.
Help,
/// `:devices` — open the connected-devices picker.
Devices,
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
/// optionally setting the severity filter.
Logs(Option<usize>),
@@ -74,7 +73,15 @@ pub fn parse(input: &str) -> Parsed {
let arg = parts.next();
match name {
"q" | "quit" => Parsed::Command(Command::Quit),
"logout" => Parsed::Command(Command::Logout),
"import" | "add" => {
let path = input.trim_start().split_once(char::is_whitespace);
match path.map(|(_, rest)| rest.trim()) {
Some(path) if !path.is_empty() => {
Parsed::Command(Command::Import(path.to_string()))
}
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
}
}
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
@@ -96,7 +103,6 @@ pub fn parse(input: &str) -> Parsed {
"prev" => Parsed::Command(Command::Prev),
"pause" | "play" => Parsed::Command(Command::PlayPause),
"help" => Parsed::Command(Command::Help),
"devices" | "device" => Parsed::Command(Command::Devices),
"logs" => match arg {
None => Parsed::Command(Command::Logs(None)),
Some(level) => match ["error", "warn", "info", "debug", "trace"]
@@ -152,7 +158,11 @@ mod tests {
fn parses_word_commands() {
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
assert_eq!(parse("quit"), Parsed::Command(Command::Quit));
assert_eq!(parse("logout"), Parsed::Command(Command::Logout));
assert_eq!(
parse("import ~/Music/My Album"),
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
);
assert!(matches!(parse("import"), Parsed::Invalid(_)));
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
@@ -162,7 +172,6 @@ mod tests {
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
);
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
assert_eq!(parse("devices"), Parsed::Command(Command::Devices));
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
}
+44 -21
View File
@@ -1,25 +1,24 @@
use std::sync::Arc;
use crate::api::auth::AuthSession;
use crate::api::models::{
ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TrackItem,
};
use crate::art::ArtImage;
use crate::library::models::{
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
TrackItem,
};
/// Events delivered to the main loop by background tasks (API fetches, the
/// playback engine, device sync). Tasks never touch AppState directly.
/// Events delivered to the main loop by background tasks (library queries,
/// the playback engine, imports). Tasks never touch AppState directly.
#[derive(Debug)]
pub enum AppEvent {
StatusMessage(String),
LoginSucceeded(Box<AuthSession>),
LoginFailed(String),
/// Loopback listener received the browser SSO callback.
SsoCallback(Result<String, String>),
/// Refresh token rejected — stored credentials were deleted.
SessionExpired,
/// A page of the Global artists list arrived (or failed).
/// A page of the artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>),
/// A full reload after a library change: replaces the loaded artist
/// list wholesale, so the grid never flashes empty.
ArtistsReloaded {
page: ArtistsPage,
limit: i64,
},
ArtistViewLoaded {
id: i64,
result: Result<ArtistDetail, String>,
@@ -33,7 +32,7 @@ pub enum AppEvent {
seq: u64,
result: Result<SearchResults, String>,
},
/// Artwork fetched and decoded for the shared art cache.
/// Artwork loaded and decoded for the shared art cache.
ArtLoaded {
key: String,
art: Option<Arc<ArtImage>>,
@@ -41,7 +40,7 @@ pub enum AppEvent {
Player(crate::player::PlayerEvent),
/// A command from the OS media keys.
Media(crate::media::MediaCommand),
/// Gapless prefetch could not open the stream; the normal track-switch
/// Gapless prefetch could not open the file; the normal track-switch
/// path takes over when the current track ends.
PrefetchFailed {
pos: usize,
@@ -57,11 +56,6 @@ pub enum AppEvent {
track_id: i64,
liked: bool,
},
/// Connected-devices poll result; carries device list, active id,
/// remote playback state and commands for this TUI.
DevicesPolled(Result<DevicePollResponse, String>),
/// Response from switching the active device.
DeviceActivated(Result<DevicePollResponse, String>),
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
@@ -77,4 +71,33 @@ pub enum AppEvent {
playlist_title: String,
result: Result<(), String>,
},
/// The library was mutated (import, edit, delete): cached views must be
/// dropped and reloaded lazily.
LibraryChanged {
message: Option<String>,
},
/// Progress of a running import, shown in the status bar.
ImportProgress {
done: usize,
total: usize,
current: String,
},
/// Fresh copies of the queued tracks after a library change. Tracks
/// missing from the result were deleted and leave the queue.
QueueTracksRefreshed {
tracks: Vec<TrackItem>,
},
/// A status snapshot for the Federation tab.
FederationStatus(crate::federation::FedStatus),
/// Tracks found on the federated network for the live search.
FedSearchLoaded {
seq: u64,
result: Result<Vec<crate::federation::FedTrack>, String>,
},
/// A federated track finished downloading and is ready to play.
FedPlayReady {
result: Result<crate::federation::FedPlayable, String>,
},
/// This peer's connection ticket, requested from the Federation tab.
FedTicket(Result<String, String>),
}
-221
View File
@@ -1,221 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::{auth, client};
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::sso;
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
state.should_quit = true;
return;
}
let form = &mut state.login;
if form.busy {
return;
}
match form.mode {
LoginMode::Form => handle_form_key(form, runtime, key),
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
}
}
/// Bracketed paste goes into whichever text field is focused.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let form = &mut state.login;
if form.busy {
return;
}
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
if let Some(field) = focused_text(form) {
field.push_str(&cleaned);
}
}
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
KeyCode::Backspace => {
if let Some(field) = focused_text(form) {
field.pop();
}
}
KeyCode::Enter => match form.focus {
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
LoginField::Password | LoginField::SignInButton => {
submit_password(form, runtime);
}
LoginField::SsoButton => start_sso(form, runtime),
},
KeyCode::Char(c) if is_typing(key) => {
if let Some(field) = focused_text(form) {
field.push(c);
}
}
_ => {}
}
}
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
// Ctrl-shortcuts first: plain letters belong to the paste field.
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
// Copy the full SSO URL — terminals can't copy a wrapped link
// in one piece, the clipboard can.
KeyCode::Char('l') => {
form.error = None;
match copy_to_clipboard(&form.sso_url) {
Ok(()) => form.error = Some("link copied to clipboard".to_string()),
Err(err) => {
tracing::warn!(%err, "clipboard copy failed");
form.error = Some(format!("copy failed: {err}"));
}
}
}
KeyCode::Char('o') => {
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to reopen browser");
form.error = Some("couldn't open a browser".to_string());
}
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc => {
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
form.mode = LoginMode::Form;
form.sso_paste.clear();
form.error = None;
}
KeyCode::Backspace => {
form.sso_paste.pop();
}
KeyCode::Enter => submit_sso_code(form, runtime),
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
_ => {}
}
}
fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> {
arboard::Clipboard::new()?.set_text(text.to_string())
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
}
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
if form.mode == LoginMode::SsoPending {
return Some(&mut form.sso_paste);
}
match form.focus {
LoginField::ServerUrl => Some(&mut form.server_url),
LoginField::Username => Some(&mut form.username),
LoginField::Password => Some(&mut form.password),
LoginField::SignInButton | LoginField::SsoButton => None,
}
}
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
let username = form.username.trim().to_string();
if username.is_empty() {
return form.error = Some("enter a username".to_string());
}
if form.password.is_empty() {
return form.error = Some("enter a password".to_string());
}
form.server_url = base_url.clone();
form.busy = true;
let password = form.password.clone();
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_password(&http, &base_url, &username, &password).await;
let _ = tx.send(login_event(result));
});
}
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
form.server_url = base_url.clone();
form.sso_paste.clear();
// Preferred flow: loopback listener, the browser redirect finishes the
// login hands-free. Fallback: furumi:// deep link + manual code paste.
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
match sso::start(runtime.event_tx.clone()) {
Ok(listener) => {
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
form.sso_url = client::sso_start_url(&base_url, &redirect);
form.sso_port = Some(listener.port);
runtime.sso = Some(listener);
}
Err(err) => {
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
form.sso_port = None;
}
}
form.mode = LoginMode::SsoPending;
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to open browser for SSO");
form.error = Some("couldn't open a browser — use the URL below".to_string());
}
}
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let code = match auth::extract_sso_code(&form.sso_paste) {
Ok(code) => code,
Err(err) => return form.error = Some(err.to_string()),
};
spawn_sso_exchange(form, runtime, code);
}
/// Used by both the manual paste path and the loopback callback event.
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
let base_url = form.server_url.clone();
form.busy = true;
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_sso_exchange(&http, &base_url, &code).await;
let _ = tx.send(login_event(result));
});
}
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
match result {
Ok(session) => {
tracing::info!(user = %session.user.name, server = %session.server_base_url, "signed in");
if let Err(err) = auth::save_session(&session) {
tracing::warn!(%err, "failed to persist credentials");
}
AppEvent::LoginSucceeded(Box::new(session))
}
Err(err) => {
tracing::warn!(%err, "login failed");
AppEvent::LoginFailed(err.to_string())
}
}
}
+514 -975
View File
File diff suppressed because it is too large Load Diff
+285 -74
View File
@@ -1,13 +1,18 @@
//! Modal dialog input: the add-to-playlist picker and new-playlist name
//! entry. The popup is taken out of the state, handled as an owned value
//! and put back unless the action closed it.
//! Modal dialog input: the add-to-playlist picker, new-playlist name entry,
//! metadata edit forms and delete confirmations. The popup is taken out of
//! the state, handled as an owned value and put back unless the action
//! closed it.
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::models::TrackItem;
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::state::{AppState, Popup, addable_playlists};
use crate::app::state::{
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Popup, addable_playlists,
};
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
let Some(popup) = state.popup.take() else {
@@ -22,7 +27,16 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
input,
busy,
} => handle_name_entry(state, runtime, for_track, input, busy, key),
Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key),
Popup::Edit {
target,
title,
fields,
focus,
error,
} => handle_edit(state, runtime, target, title, fields, focus, error, key),
Popup::ConfirmDelete { target, label } => {
handle_confirm_delete(state, runtime, target, label, key);
}
Popup::TrackInfo {
tracks,
cursor,
@@ -32,54 +46,269 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::LogDetail(entry)),
},
Popup::FedInput { field, input } => handle_fed_input(state, runtime, field, input, key),
Popup::FedText { title, text } => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::FedText { title, text }),
},
}
}
/// Pasted text goes into the name field when it is open.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup {
if !*busy {
input.extend(pasted.chars().filter(|c| !c.is_control()));
}
}
}
fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
let len = state.devices.devices.len();
/// One-line text entry on the Federation tab (network id / peer ticket).
fn handle_fed_input(
state: &mut AppState,
runtime: &Runtime,
field: FedInputField,
mut input: String,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::Devices {
cursor: cursor.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::Devices {
cursor: if len == 0 {
0
} else {
(cursor + 1).min(len - 1)
},
});
}
KeyCode::Esc => {}
KeyCode::Enter => {
if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) {
let target = device.id.clone();
state.devices.switching_to = Some(target.clone());
state.popup = Some(Popup::Devices { cursor });
spawn_select_device(runtime, target);
} else {
state.popup = Some(Popup::Devices { cursor: 0 });
let value = input.trim().to_string();
match field {
FedInputField::NetworkId => {
state.federation.settings.network_id = value;
// An empty id turns federation off rather than leaving a
// node bound to an unnamed network; a freshly set id
// enables it right away.
state.federation.settings.enabled =
!state.federation.settings.network_id.is_empty();
super::fed_apply_settings(state, runtime);
}
FedInputField::ConnectTicket => {
if value.is_empty() {
state.status_message = Some("ticket is empty".into());
} else {
super::fed_connect(runtime, value);
}
}
}
}
_ => {
state.popup = Some(Popup::Devices {
cursor: cursor.min(len.saturating_sub(1)),
})
KeyCode::Backspace => {
input.pop();
state.popup = Some(Popup::FedInput { field, input });
}
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
input.push(c);
state.popup = Some(Popup::FedInput { field, input });
}
_ => state.popup = Some(Popup::FedInput { field, input }),
}
}
/// Pasted text goes into the focused text field when one is open.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
match &mut state.popup {
Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned),
Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned),
Some(Popup::Edit { fields, focus, .. }) => {
if let Some(field) = fields.get_mut(*focus) {
field.value.push_str(&cleaned);
}
}
_ => {}
}
}
// ---------------------------------------------------------------------------
// Edit form
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
fn handle_edit(
state: &mut AppState,
runtime: &Runtime,
target: EditTarget,
title: String,
mut fields: Vec<EditField>,
mut focus: usize,
error: Option<String>,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc => return,
KeyCode::Enter => {
match save_edit(runtime, target, &fields) {
Ok(message) => {
state.status_message = Some(message);
return;
}
Err(message) => {
state.popup = Some(Popup::Edit {
target,
title,
fields,
focus,
error: Some(message),
});
return;
}
};
}
KeyCode::Tab | KeyCode::Down => focus = (focus + 1) % fields.len().max(1),
KeyCode::BackTab | KeyCode::Up => {
let len = fields.len().max(1);
focus = (focus + len - 1) % len;
}
KeyCode::Backspace => {
if let Some(field) = fields.get_mut(focus) {
field.value.pop();
}
}
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
if let Some(field) = fields.get_mut(focus) {
field.value.push(c);
}
}
_ => {}
}
state.popup = Some(Popup::Edit {
target,
title,
fields,
focus,
error,
});
}
/// Validate the form and write it to the library. Returns the status
/// message on success, the error text to show in the form otherwise.
fn save_edit(
runtime: &Runtime,
target: EditTarget,
fields: &[EditField],
) -> Result<String, String> {
let value = |label: &str| {
fields
.iter()
.find(|field| field.label == label)
.map(|field| field.value.trim().to_string())
.unwrap_or_default()
};
let number = |label: &str| -> Result<Option<i32>, String> {
let raw = value(label);
if raw.is_empty() {
return Ok(None);
}
raw.parse::<i32>()
.map(Some)
.map_err(|_| format!("{label} must be a number"))
};
let names = |label: &str| -> Vec<String> {
value(label)
.split(';')
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect()
};
let library = Arc::clone(&runtime.library);
let result = match target {
EditTarget::Track(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
let artists = names("Artists");
if artists.is_empty() {
return Err("at least one artist is required".to_string());
}
let edit = TrackEdit {
title,
artists,
featured_artists: names("Featured"),
track_number: number("Track #")?,
disc_number: number("Disc #")?,
};
library.update_track(id, &edit)
}
EditTarget::Release(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
let release_type = value("Type").to_lowercase();
let release_type = if release_type.is_empty() {
"album".to_string()
} else {
release_type
};
let edit = ReleaseEdit {
title,
release_type,
year: number("Year")?,
artists: Vec::new(),
};
library.update_release(id, &edit)
}
EditTarget::Artist(id) => {
let name = value("Name");
if name.is_empty() {
return Err("name is empty".to_string());
}
let image = value("Image path");
let image = (!image.is_empty()).then_some(image);
library.update_artist(id, &name, image.as_deref())
}
EditTarget::Playlist(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
library.update_playlist(id, &title, None)
}
};
match result {
Ok(()) => {
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some("saved".to_string()),
});
Ok("saved".to_string())
}
Err(err) => Err(format!("{err:#}")),
}
}
// ---------------------------------------------------------------------------
// Delete confirmation
// ---------------------------------------------------------------------------
fn handle_confirm_delete(
state: &mut AppState,
runtime: &Runtime,
target: DeleteTarget,
label: String,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
KeyCode::Enter | KeyCode::Char('y') => {
let library = Arc::clone(&runtime.library);
let result = match target {
DeleteTarget::Track(id) => library.delete_track(id),
DeleteTarget::Release(id) => library.delete_release(id),
DeleteTarget::Artist(id) => library.delete_artist(id),
DeleteTarget::Playlist(id) => library.delete_playlist(id),
};
match result {
Ok(()) => {
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some(format!("deleted {label}")),
});
}
Err(err) => state.status_message = Some(format!("delete failed: {err:#}")),
}
}
_ => state.popup = Some(Popup::ConfirmDelete { target, label }),
}
}
// ---------------------------------------------------------------------------
// Track info
// ---------------------------------------------------------------------------
fn handle_track_info(
state: &mut AppState,
tracks: Vec<TrackItem>,
@@ -132,6 +361,10 @@ fn handle_track_info(
}
}
// ---------------------------------------------------------------------------
// Add-to-playlist picker & new playlist
// ---------------------------------------------------------------------------
fn handle_picker(
state: &mut AppState,
runtime: &Runtime,
@@ -236,35 +469,13 @@ fn handle_name_entry(
}
}
fn spawn_select_device(runtime: &Runtime, target_device_id: String) {
let Some(api) = runtime.api.clone() else {
return;
};
let current_device_id = runtime.device_id.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api
.select_device(&target_device_id, &current_device_id)
.await
{
Ok(response) => AppEvent::DeviceActivated(Ok(response)),
Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::DeviceActivated(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(playlist_id, &[track.id])
.await
.map_err(|e| e.to_string());
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
@@ -274,12 +485,12 @@ fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String,
}
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api.create_playlist(&title).await.map_err(|e| e.to_string());
tokio::task::spawn_blocking(move || {
let result = library
.create_playlist(&title)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
});
}
-134
View File
@@ -1,134 +0,0 @@
use std::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc::UnboundedSender;
use crate::app::event::AppEvent;
/// Loopback callback listener for browser SSO (RFC 8252 native-app flow).
/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback`
/// with the exchange code; the listener delivers it as an AppEvent and exits.
pub struct SsoListener {
pub port: u16,
handle: tokio::task::JoinHandle<()>,
}
impl SsoListener {
pub fn abort(&self) {
self.handle.abort();
}
}
pub fn start(tx: UnboundedSender<AppEvent>) -> io::Result<SsoListener> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let handle = tokio::spawn(async move {
let result = match serve_one(listener).await {
Ok(result) => result,
Err(err) => Err(format!("callback listener failed: {err}")),
};
let _ = tx.send(AppEvent::SsoCallback(result));
});
Ok(SsoListener { port, handle })
}
async fn serve_one(listener: std::net::TcpListener) -> io::Result<Result<String, String>> {
let listener = tokio::net::TcpListener::from_std(listener)?;
loop {
let (mut stream, _) = listener.accept().await?;
let request_line = read_request_line(&mut stream).await?;
let Some(result) = parse_request_line(&request_line) else {
// Stray request (favicon, prefetch) — keep waiting for the code.
let _ = stream.write_all(&response(404, "Not Found")).await;
continue;
};
let page = match &result {
Ok(_) => "Sign-in complete. You can close this window and return to the terminal.",
Err(_) => "Sign-in failed. Return to the terminal to see the error.",
};
let _ = stream.write_all(&response(200, page)).await;
let _ = stream.shutdown().await;
return Ok(result);
}
}
async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result<String> {
let mut buf = vec![0u8; 8192];
let mut len = 0;
while len < buf.len() {
let n = stream.read(&mut buf[len..]).await?;
if n == 0 {
break;
}
len += n;
if buf[..len].windows(2).any(|w| w == b"\r\n") {
break;
}
}
let text = String::from_utf8_lossy(&buf[..len]);
Ok(text.lines().next().unwrap_or_default().to_string())
}
/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error).
/// Values are plain tokens (no percent-encoded characters expected).
fn parse_request_line(line: &str) -> Option<Result<String, String>> {
let path = line.split_whitespace().nth(1)?;
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut code = None;
let mut error = None;
for pair in query.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
match key {
"code" if !value.is_empty() => code = Some(value.to_string()),
"error" if !value.is_empty() => error = Some(value.to_string()),
_ => {}
}
}
if let Some(error) = error {
return Some(Err(format!("SSO failed: {error}")));
}
code.map(Ok)
}
fn response(status: u16, body: &str) -> Vec<u8> {
let reason = if status == 200 { "OK" } else { "Not Found" };
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>furumi</title></head>\
<body style=\"font-family:sans-serif;background:#101114;color:#f5f2ea;\
display:grid;place-items:center;min-height:100vh;margin:0\"><p>{body}</p></body></html>"
);
format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_code_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"),
Some(Ok("furu_mx_abc".to_string()))
);
}
#[test]
fn parses_error_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?error=provider_denied HTTP/1.1"),
Some(Err("SSO failed: provider_denied".to_string()))
);
}
#[test]
fn ignores_unrelated_requests() {
assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None);
assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None);
}
}
+120 -122
View File
@@ -1,12 +1,12 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::api::models::{
ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem, User,
};
use crate::art::ArtImage;
use crate::config::keymap::KeyContext;
use crate::library::models::{
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem,
};
/// Remote data that a view renders: spinner, content, or error.
#[derive(Debug)]
@@ -83,9 +83,12 @@ pub struct GlobalTab {
pub selected: usize,
pub view: ViewMode,
pub stack: Vec<GlobalView>,
/// Page size, fixed at the first request — the server's offset is
/// Page size, fixed at the first request — the offset is
/// `(page-1) * limit`, so it must not change between pages.
pub page_limit: Option<i64>,
/// A full atomic reload is in flight (after a library change); incoming
/// pages of the old pagination are dropped until it lands.
pub reloading: bool,
}
impl Default for GlobalTab {
@@ -101,6 +104,7 @@ impl Default for GlobalTab {
view: ViewMode::default(),
stack: Vec::new(),
page_limit: None,
reloading: false,
}
}
}
@@ -161,8 +165,8 @@ pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>>
rows
}
/// The virtual server-side Likes playlist id (`kind == "likes"`).
pub const LIKES_PLAYLIST_ID: i64 = -1;
/// The virtual Likes playlist id (`kind == "likes"`).
pub use crate::library::LIKES_PLAYLIST_ID;
#[derive(Debug, Clone, Copy)]
pub struct OpenedPlaylist {
@@ -282,42 +286,45 @@ impl Default for LogsTab {
}
}
#[derive(Debug, Default)]
pub struct DevicesState {
pub device_id: String,
pub active_device_id: Option<String>,
pub devices: Vec<DeviceDto>,
pub poll_error: Option<String>,
pub switching_to: Option<String>,
/// What an open edit form writes to when saved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditTarget {
Track(i64),
Release(i64),
Artist(i64),
Playlist(i64),
}
impl DevicesState {
pub fn is_playback_device(&self) -> bool {
self.active_device_id
.as_deref()
.is_none_or(|active| active == self.device_id)
}
/// What a confirmed delete removes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteTarget {
Track(i64),
Release(i64),
Artist(i64),
Playlist(i64),
}
pub fn remote_target_id(&self) -> Option<&str> {
self.active_device_id
.as_deref()
.filter(|active| *active != self.device_id)
}
/// One text field of an edit form.
#[derive(Debug, Clone)]
pub struct EditField {
pub label: &'static str,
pub value: String,
}
pub fn active_device_name(&self) -> Option<&str> {
let active = self.active_device_id.as_deref()?;
self.devices
.iter()
.find(|device| device.id == active)
.map(|device| device.name.as_str())
impl EditField {
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
Self {
label,
value: value.into(),
}
}
}
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the user's playlists (row 0 = "create new"); the track
/// is added on Enter.
/// Pick one of the playlists (row 0 = "create new"); the track is added
/// on Enter.
AddToPlaylist { track: TrackItem, cursor: usize },
/// Name input for a new playlist; when `for_track` is set, the track is
/// added to it right after creation.
@@ -326,8 +333,16 @@ pub enum Popup {
input: String,
busy: bool,
},
/// Connected devices list; Enter transfers active playback to the row.
Devices { cursor: usize },
/// Metadata edit form for a track, release, artist or playlist.
Edit {
target: EditTarget,
title: String,
fields: Vec<EditField>,
focus: usize,
error: Option<String>,
},
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
ConfirmDelete { target: DeleteTarget, label: String },
/// Track metadata viewer; left/right switch between selected tracks.
TrackInfo {
tracks: Vec<TrackItem>,
@@ -336,15 +351,67 @@ pub enum Popup {
},
/// Full, wrapped view of one log entry (Enter on the Logs tab).
LogDetail(crate::config::logging::LogEntry),
/// One-line text entry on the Federation tab (network id, peer ticket).
FedInput {
field: FedInputField,
input: String,
},
/// Wrapped read-only text (this peer's connection ticket).
FedText { title: String, text: String },
}
/// User's own playlists eligible as add-targets (the virtual Likes playlist
/// is managed through likes, not direct adds).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
NetworkId,
ConnectTicket,
}
impl FedInputField {
pub fn title(self) -> &'static str {
match self {
FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
}
}
}
/// Rows of the Federation tab, in display order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedRow {
Toggle,
NetworkId,
SaveOnListen,
SyncNow,
ShowTicket,
Connect,
}
impl FedRow {
pub const ALL: [FedRow; 6] = [
FedRow::Toggle,
FedRow::NetworkId,
FedRow::SaveOnListen,
FedRow::SyncNow,
FedRow::ShowTicket,
FedRow::Connect,
];
}
/// The Federation tab: settings mirror + the latest status snapshot.
#[derive(Debug, Default)]
pub struct FederationTab {
pub cursor: usize,
pub settings: crate::federation::FedSettings,
pub status: Option<crate::federation::FedStatus>,
}
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
/// through likes, not direct adds).
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
match &state.playlists.list {
Some(Loadable::Ready(list)) => list
.iter()
.filter(|p| p.is_own && p.kind != "likes")
.filter(|p| p.kind != "likes")
.map(|p| (p.id, p.title.clone()))
.collect(),
_ => Vec::new(),
@@ -367,85 +434,10 @@ pub struct SearchState {
pub query: String,
pub loading: bool,
pub results: Option<SearchResults>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
#[default]
Main,
Login,
}
/// SSO is the primary sign-in path, so it sits right under the server URL;
/// the password fields below are the rare fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginField {
#[default]
ServerUrl,
SsoButton,
Username,
Password,
SignInButton,
}
impl LoginField {
const ORDER: [LoginField; 5] = [
LoginField::ServerUrl,
LoginField::SsoButton,
LoginField::Username,
LoginField::Password,
LoginField::SignInButton,
];
pub fn next(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + 1) % Self::ORDER.len()]
}
pub fn prev(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginMode {
/// Server / username / password fields plus the SSO button.
#[default]
Form,
/// Browser SSO started; waiting for the pasted callback link or code.
SsoPending,
}
#[derive(Debug)]
pub struct LoginForm {
pub server_url: String,
pub username: String,
pub password: String,
pub sso_paste: String,
pub sso_url: String,
pub sso_port: Option<u16>,
pub focus: LoginField,
pub mode: LoginMode,
pub busy: bool,
pub error: Option<String>,
}
impl Default for LoginForm {
fn default() -> Self {
Self {
server_url: "https://music.hexor.cy".to_string(),
username: String::new(),
password: String::new(),
sso_paste: String::new(),
sso_url: String::new(),
sso_port: None,
focus: LoginField::default(),
mode: LoginMode::default(),
busy: false,
error: None,
}
}
/// Tracks found on the federated network (empty while federation is
/// off); rendered as a separate, marked section.
pub fed_tracks: Vec<crate::federation::FedTrack>,
pub fed_loading: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -454,17 +446,25 @@ pub enum Tab {
Global,
Playlists,
Queue,
Federation,
Logs,
}
impl Tab {
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs];
pub const ALL: [Tab; 5] = [
Tab::Global,
Tab::Playlists,
Tab::Queue,
Tab::Federation,
Tab::Logs,
];
pub fn title(self) -> &'static str {
match self {
Tab::Global => "Global",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Federation => "Federation",
Tab::Logs => "Logs",
}
}
@@ -490,6 +490,7 @@ impl Tab {
Tab::Global => KeyContext::Library,
Tab::Playlists => KeyContext::Playlists,
Tab::Queue => KeyContext::Queue,
Tab::Federation => KeyContext::Federation,
Tab::Logs => KeyContext::Logs,
}
}
@@ -567,7 +568,6 @@ impl Default for PlayerBar {
/// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)]
pub struct AppState {
pub screen: Screen,
pub active_tab: Tab,
pub should_quit: bool,
/// Double-press quit confirmation: set by the first Quit press, expires
@@ -577,8 +577,6 @@ pub struct AppState {
pub pending_keys: Option<String>,
pub status_message: Option<String>,
pub player: PlayerBar,
pub login: LoginForm,
pub user: Option<User>,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
@@ -588,8 +586,8 @@ pub struct AppState {
pub likes: std::collections::HashSet<i64>,
pub likes_loaded: bool,
pub logs: LogsTab,
pub devices: DevicesState,
pub queue_tab: QueueTab,
pub federation: FederationTab,
pub track_selection: TrackSelection,
/// Shift-J jump in flight: focus this (release, track) once the release
/// view finishes loading.
+370 -87
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, Instant};
use super::action::Action;
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
@@ -31,11 +31,24 @@ pub enum Effect {
ToggleLikes {
track_ids: Vec<i64>,
},
/// Remove tracks from a stored playlist in the library.
RemoveFromPlaylist {
playlist_id: i64,
track_ids: Vec<i64>,
},
RemoveQueueIndices {
indices: Vec<usize>,
restart_paused: Option<bool>,
stop: bool,
},
/// Persist the Federation-tab settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
FedSyncNow,
/// Fetch this peer's ticket and show it in a popup.
FedShowTicket,
/// Download (or resolve) a federated track and play it.
FedPlay(crate::federation::FedTrack),
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -195,18 +208,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
None
}
Action::OpenDevices => {
let cursor = state
.devices
.devices
.iter()
.position(|device| {
device.id == state.devices.active_device_id.clone().unwrap_or_default()
})
.unwrap_or(0);
state.popup = Some(super::state::Popup::Devices { cursor });
None
}
Action::Select => select_current(state),
Action::Back if state.track_selection.is_active() => {
state.track_selection.clear();
@@ -310,9 +311,243 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
}
// Needs the Runtime, so it is intercepted in app::handle_main_key
// before reaching update().
Action::Logout => None,
Action::EditSelected => {
open_edit_popup(state);
None
}
Action::DeleteSelected => delete_selected(state),
}
}
/// `e`: open the metadata edit form for whatever is under the cursor —
/// an artist tile, a release, a track or a playlist.
fn open_edit_popup(state: &mut AppState) {
use super::state::{EditField, EditTarget, Popup};
if state.active_tab == Tab::Playlists && state.playlists.opened.is_none() {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let Some(card) = card else {
return;
};
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be edited".into());
return;
}
state.popup = Some(Popup::Edit {
target: EditTarget::Playlist(card.id),
title: format!("Edit playlist — {}", card.title),
fields: vec![EditField::new("Title", card.title.clone())],
focus: 0,
error: None,
});
return;
}
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
return;
};
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
return;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
return;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::Edit {
target: EditTarget::Release(release.id),
title: format!("Edit release — {}", release.title),
fields: vec![
EditField::new("Title", release.title.clone()),
EditField::new("Type", release.release_type.clone()),
EditField::new(
"Year",
release.year.map(|y| y.to_string()).unwrap_or_default(),
),
],
focus: 0,
error: None,
});
return;
}
if let Some(track) = selected_track(state).or_else(|| state.player.current.clone()) {
state.popup = Some(track_edit_popup(&track));
return;
}
state.status_message = Some("nothing to edit here".into());
}
fn artist_edit_popup(
id: i64,
name: &str,
image_path: Option<String>,
) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
Popup::Edit {
target: EditTarget::Artist(id),
title: format!("Edit artist — {name}"),
fields: vec![
EditField::new("Name", name),
EditField::new("Image path", image_path.unwrap_or_default()),
],
focus: 0,
error: None,
}
}
fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
let join = |artists: &[crate::library::models::ArtistRef]| {
artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<_>>()
.join("; ")
};
Popup::Edit {
target: EditTarget::Track(track.id),
title: format!("Edit track — {}", track.title),
fields: vec![
EditField::new("Title", track.title.clone()),
EditField::new("Artists", join(&track.artists)),
EditField::new("Featured", join(&track.featured_artists)),
EditField::new(
"Track #",
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
),
EditField::new(
"Disc #",
track.disc_number.map(|n| n.to_string()).unwrap_or_default(),
),
],
focus: 0,
error: None,
}
}
/// shift-d: delete whatever is under the cursor. Library entities ask for
/// confirmation; playlist/queue rows are removed directly.
fn delete_selected(state: &mut AppState) -> Option<Effect> {
use super::state::{DeleteTarget, Popup};
if state.active_tab == Tab::Queue {
return remove_selected_from_queue(state);
}
if state.active_tab == Tab::Playlists {
match state.playlists.opened {
None => {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let card = card?;
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be deleted".into());
return None;
}
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Playlist(card.id),
label: format!("playlist \"{}\"", card.title),
});
return None;
}
Some(opened) => {
let tracks = selected_tracks(state);
if tracks.is_empty() {
state.status_message = Some("no track selected".into());
return None;
}
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
state.track_selection.clear();
if opened.id == super::state::LIKES_PLAYLIST_ID {
let liked: Vec<i64> = track_ids
.into_iter()
.filter(|id| state.likes.contains(id))
.collect();
state.status_message = Some(format!("removing {} like(s)", liked.len()));
return Some(Effect::ToggleLikes { track_ids: liked });
}
state.status_message =
Some(format!("removing {} track(s) from playlist", track_ids.len()));
return Some(Effect::RemoveFromPlaylist {
playlist_id: opened.id,
track_ids,
});
}
}
}
if state.active_tab != Tab::Global {
return None;
}
if state.global.stack.is_empty() {
let artist = state.global.artists.get(state.global.selected).cloned()?;
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
});
return None;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
});
return None;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Release(release.id),
label: format!("release \"{}\" with all its tracks", release.title),
});
return None;
}
if let Some(track) = selected_track(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Track(track.id),
label: format!("track \"{}\" (the audio file stays on disk)", track.title),
});
} else {
state.status_message = Some("nothing to delete here".into());
}
None
}
/// The artist under the cursor in the search view, if any.
fn selected_search_artist(state: &AppState) -> Option<crate::library::models::ArtistCard> {
if state.active_tab != Tab::Global {
return None;
}
let Some(GlobalView::Search { cursor }) = state.global.stack.last() else {
return None;
};
state.search.results.as_ref()?.artists.get(*cursor).cloned()
}
/// The release card under the cursor (artist view tiles/rows or search).
fn selected_release_card(state: &AppState) -> Option<crate::library::models::ReleaseCard> {
if state.active_tab != Tab::Global {
return None;
}
match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let position = cursor.checked_sub(detail.top_tracks.len())?;
let order = release_display_order(&detail.releases);
order
.get(position)
.map(|&index| detail.releases[index].clone())
}
_ => None,
},
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).cloned()
}
GlobalView::Release { .. } => None,
}
}
@@ -386,11 +621,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
}
}
TrackSelectionScope::Playlist(id) => {
if let Some(opened) = &mut state.playlists.opened {
if opened.id == *id {
if let Some(opened) = &mut state.playlists.opened
&& opened.id == *id {
opened.cursor = value;
}
}
}
TrackSelectionScope::Queue => {
state.queue_tab.cursor = value;
@@ -438,7 +672,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
state.queue_tab.cursor,
state.player.queue.len(),
)),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -487,7 +721,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
state.queue_tab.cursor,
&state.player.queue,
)),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -653,7 +887,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
.cloned()
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -769,11 +1003,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
for (offset, track) in tracks.into_iter().enumerate() {
player.queue.insert(insert_at + offset, track);
}
if let Some(prefetched) = &mut player.prefetched_pos {
if insert_at <= *prefetched {
if let Some(prefetched) = &mut player.prefetched_pos
&& insert_at <= *prefetched {
*prefetched += count;
}
}
if insert_at <= player.queue_pos && player.current.is_some() {
player.queue_pos += count;
}
@@ -885,7 +1118,7 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
}
let tail = player.queue.split_off(start);
let mut used = vec![false; order.len()];
let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail
let mut keyed: Vec<(usize, usize, crate::library::models::TrackItem)> = tail
.into_iter()
.enumerate()
.map(|(position, track)| {
@@ -954,6 +1187,14 @@ fn page_step(state: &AppState) -> isize {
}
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
if state.active_tab == Tab::Federation {
if dy != 0 {
let last = super::state::FedRow::ALL.len() as isize - 1;
state.federation.cursor =
(state.federation.cursor as isize + dy).clamp(0, last) as usize;
}
return;
}
if state.active_tab == Tab::Logs {
// The cursor anchors to an entry's seq, so freshly appended log
// lines (including ones caused by this very keypress) don't shift
@@ -1138,6 +1379,9 @@ fn current_view_len(state: &AppState) -> usize {
if state.active_tab == Tab::Queue {
return state.player.queue.len();
}
if state.active_tab == Tab::Federation {
return super::state::FedRow::ALL.len();
}
match state.global.stack.last() {
None => state.global.artists.len(),
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
@@ -1150,7 +1394,9 @@ fn current_view_len(state: &AppState) -> usize {
Some(Loadable::Ready(d)) => d.tracks.len(),
_ => 0,
},
Some(GlobalView::Search { .. }) => state.search.results.as_ref().map_or(0, |r| r.len()),
Some(GlobalView::Search { .. }) => {
state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len()
}
}
}
@@ -1173,15 +1419,19 @@ fn jump_selection(state: &mut AppState, first: bool) {
}
return;
}
if state.active_tab == Tab::Federation {
let last = super::state::FedRow::ALL.len() - 1;
state.federation.cursor = if first { 0 } else { last };
return;
}
if state.active_tab == Tab::Logs {
if first {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
if let Some(buffer) = crate::config::logging::buffer()
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
}
} else {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -1257,6 +1507,9 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}
return None;
}
if state.active_tab == Tab::Federation {
return federation_select(state);
}
// Queue: jump playback to the track under the cursor. Earlier tracks
// stay in the queue as "played"; picking one of them just moves the
// playing position back.
@@ -1274,9 +1527,10 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
enum Outcome {
Push(GlobalView),
Play {
tracks: Vec<crate::api::models::TrackItem>,
tracks: Vec<crate::library::models::TrackItem>,
start: usize,
},
PlayFed(crate::federation::FedTrack),
Nothing,
}
let outcome = match state.global.stack.last().copied() {
@@ -1347,10 +1601,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
start: cursor - artists - releases,
}
} else {
Outcome::Nothing
let fed_index = cursor - artists - releases - results.tracks.len();
match state.search.fed_tracks.get(fed_index) {
Some(fed) => Outcome::PlayFed(fed.clone()),
None => Outcome::Nothing,
}
}
}
None => Outcome::Nothing,
None => match state.search.fed_tracks.get(cursor) {
Some(fed) => Outcome::PlayFed(fed.clone()),
None => Outcome::Nothing,
},
},
};
match outcome {
@@ -1364,13 +1625,57 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
on_new_queue(state);
Some(Effect::PlayCurrent)
}
Outcome::PlayFed(fed) => {
state.status_message = Some(format!("federation: fetching \"{}\"", fed.title));
Some(Effect::FedPlay(fed))
}
Outcome::Nothing => None,
}
}
/// Enter on the Federation tab: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup};
match FedRow::ALL.get(state.federation.cursor)? {
FedRow::Toggle => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: String::new(),
});
return None;
}
settings.enabled = !settings.enabled;
Some(Effect::FedApplySettings)
}
FedRow::NetworkId => {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: state.federation.settings.network_id.clone(),
});
None
}
FedRow::SaveOnListen => {
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
Some(Effect::FedApplySettings)
}
FedRow::SyncNow => Some(Effect::FedSyncNow),
FedRow::ShowTicket => Some(Effect::FedShowTicket),
FedRow::Connect => {
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectTicket,
input: String::new(),
});
None
}
}
}
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
/// if shuffle is on, shuffle everything after the chosen track right away.
fn on_new_queue(state: &mut AppState) {
pub(super) fn on_new_queue(state: &mut AppState) {
let player = &mut state.player;
player.original_order = None;
if player.shuffle && !player.queue.is_empty() {
@@ -1409,19 +1714,17 @@ fn go_back(state: &mut AppState) {
Tab::Global => {
// Esc on a view opened by Shift-J from another tab goes back to
// that tab, not down the Global stack.
if let Some((origin, depth)) = state.jump_origin {
if state.global.stack.len() == depth + 1 {
if let Some((origin, depth)) = state.jump_origin
&& state.global.stack.len() == depth + 1 {
state.global.stack.pop();
state.jump_origin = None;
state.active_tab = origin;
return;
}
}
if let Some(popped) = state.global.stack.pop() {
if matches!(popped, GlobalView::Search { .. }) {
if let Some(popped) = state.global.stack.pop()
&& matches!(popped, GlobalView::Search { .. }) {
state.search = SearchState::default();
}
}
}
_ => {}
}
@@ -1450,6 +1753,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
state.global.stack.clear();
}
Tab::Playlists => state.playlists.opened = None,
Tab::Federation => state.federation.cursor = 0,
Tab::Logs => {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -1465,7 +1769,7 @@ fn not_yet(state: &mut AppState, what: &str) {
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::{ArtistCard, ArtistDetail, TrackItem};
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
fn with_artists(n: usize) -> AppState {
let mut state = AppState::default();
@@ -1473,7 +1777,7 @@ mod tests {
.map(|i| ArtistCard {
id: i as i64,
name: format!("artist {i}"),
image_url: None,
image_path: None,
release_count: 1,
track_count: 2,
})
@@ -1493,18 +1797,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
}
}
@@ -1619,14 +1919,14 @@ mod tests {
#[test]
fn artist_tiles_move_by_visual_rows_across_groups() {
use crate::api::models::{ArtistDetail, ReleaseCard};
use crate::library::models::{ArtistDetail, ReleaseCard};
let release = |id: i64, kind: &str| ReleaseCard {
id,
title: format!("r{id}"),
release_type: kind.to_string(),
year: None,
cover_url: None,
cover_path: None,
track_count: 1,
};
// columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3],
@@ -1634,7 +1934,7 @@ mod tests {
let detail = ArtistDetail {
id: 1,
name: "a".into(),
image_url: None,
image_path: None,
total_track_count: 0,
total_play_count: 0,
top_tracks: vec![],
@@ -1707,7 +2007,7 @@ mod tests {
#[test]
fn queue_advances_and_respects_repeat() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
use crate::app::state::RepeatMode;
let track = |id: i64| TrackItem {
@@ -1721,18 +2021,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/api/player/stream/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/api/player/stream/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState::default();
state.player.queue = vec![track(1), track(2)];
@@ -1776,7 +2072,7 @@ mod tests {
#[test]
fn queue_tab_select_and_clear() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
let track = |id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -1788,18 +2084,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState {
active_tab: Tab::Queue,
@@ -1870,7 +2162,7 @@ mod tests {
Loadable::Ready(ArtistDetail {
id: 9,
name: "artist".into(),
image_url: None,
image_path: None,
total_track_count: 3,
total_play_count: 0,
top_tracks: (1..=3).map(test_track).collect(),
@@ -1942,7 +2234,7 @@ mod tests {
#[test]
fn shuffle_reorders_tail_and_restores() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
let track = |id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -1954,18 +2246,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState::default();
state.player.queue = (1..=8).map(track).collect();
@@ -1991,7 +2279,7 @@ mod tests {
#[test]
fn shift_j_opens_release_from_queue() {
use crate::api::models::{ReleaseDetail, TrackItem};
use crate::library::models::{ReleaseDetail, TrackItem};
let track = |id: i64, release_id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -2003,18 +2291,14 @@ mod tests {
release_id,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState {
active_tab: Tab::Queue,
@@ -2048,10 +2332,9 @@ mod tests {
title: "r".into(),
release_type: "album".into(),
year: None,
cover_url: None,
cover_path: None,
artists: vec![],
tracks: vec![track(1, 7), track(2, 7)],
uploaders: vec![],
}),
);
state.active_tab = Tab::Queue;