init federation
This commit is contained in:
+11
-9
@@ -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
@@ -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
@@ -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
@@ -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>),
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+285
-74
@@ -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, ¤t_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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user