From ff97053156f6e1a166e1867fe68d13772271d33c Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 17 Jul 2026 01:43:09 +0300 Subject: [PATCH] Improved fed artist page --- Cargo.lock | 54 ++----- src/app/action.rs | 4 + src/app/event.rs | 11 +- src/app/mod.rs | 139 +++++++++++++++--- src/app/popup.rs | 85 ++++++----- src/app/state.rs | 47 +++++- src/app/update.rs | 241 +++++++++++++++++++++++++----- src/config/default_keymap.toml | 4 + src/federation/catalog.rs | 200 +++++++++++++++++++++++-- src/federation/mod.rs | 71 ++++++++- src/library/mod.rs | 18 +++ src/ui/global.rs | 259 +++++++++++++++++++++++++-------- src/ui/popup.rs | 4 +- 13 files changed, 929 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5aa1e6..ec8fb39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -1216,7 +1216,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1373,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1484,7 +1484,7 @@ dependencies = [ [[package]] name = "federation-net" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#555222d6d7527a1da8188814b0f308ee1562cb5d" +source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215" dependencies = [ "blake3", "data-encoding", @@ -3039,7 +3039,7 @@ dependencies = [ [[package]] name = "music-dht" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#555222d6d7527a1da8188814b0f308ee1562cb5d" +source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215" dependencies = [ "blake3", "data-encoding", @@ -3352,7 +3352,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3427,7 +3427,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -4076,16 +4076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit", ] [[package]] @@ -4510,7 +4501,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4568,7 +4559,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4660,7 +4651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5289,7 +5280,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5590,18 +5581,6 @@ dependencies = [ "winnow 0.5.40", ] -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -6209,7 +6188,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -6585,9 +6564,6 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" @@ -6818,7 +6794,7 @@ version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", "regex", @@ -6972,7 +6948,7 @@ version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", "syn 1.0.109", diff --git a/src/app/action.rs b/src/app/action.rs index a24a51f..54a153a 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -32,6 +32,8 @@ pub enum Action { OpenTrackInfo, QueueAddNext, QueueAddLast, + /// Download the selected federated track(s) into the local library. + DownloadSelected, RemoveFromQueue, ClearQueue, GoToRelease, @@ -92,6 +94,7 @@ impl Action { | Action::CycleRepeat => Category::Playback, Action::QueueAddNext | Action::QueueAddLast + | Action::DownloadSelected | Action::RemoveFromQueue | Action::ClearQueue | Action::AddToPlaylist @@ -169,6 +172,7 @@ impl Action { Action::OpenTrackInfo => "Track info".into(), Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddLast => "Queue: add to end".into(), + Action::DownloadSelected => "Federation: download to library".into(), Action::RemoveFromQueue => "Queue: remove selected".into(), Action::ClearQueue => "Queue: clear".into(), Action::GoToRelease => "Open the track's release".into(), diff --git a/src/app/event.rs b/src/app/event.rs index bad1cca..07ca277 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -63,8 +63,8 @@ pub enum AppEvent { }, PlaylistCreated { result: Result, - /// Add this track to the new playlist right away (Shift-P flow). - add_track: Option, + /// Add this target to the new playlist right away (Shift-P flow). + add_target: Option, }, PlaylistTracksAdded { playlist_id: i64, @@ -100,6 +100,13 @@ pub enum AppEvent { name: String, result: Result, }, + /// A streamed image for the open card arrived (artist image when + /// `release` is None, a release cover otherwise). + FedCardArt { + name: String, + release: Option, + path: String, + }, /// A federated track finished downloading and is ready to play. FedPlayReady { result: Result, diff --git a/src/app/mod.rs b/src/app/mod.rs index 6cc9b18..9b1ac83 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -245,7 +245,9 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { }); } } - state::GlobalView::Search { .. } | state::GlobalView::FedArtist { .. } => {} + state::GlobalView::Search { .. } + | state::GlobalView::FedArtist { .. } + | state::GlobalView::FedRelease { .. } => {} } } @@ -287,6 +289,17 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { wanted.push((path.clone(), header.0, header.1)); } } + if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view { + if let Some(path) = &card.image_path { + wanted.push((path.clone(), header.0, header.1)); + } + for release in &card.releases { + if let Some(path) = &release.cover_path { + wanted.push((path.clone(), tile.0, tile.1)); + wanted.push((path.clone(), header.0, header.1)); + } + } + } for (path, width, height) in wanted { let key = crate::art::cache_key(&path, width, height); if state.art.contains_key(&key) { @@ -434,9 +447,37 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { .artist_card(&name) .await .map_err(|err| format!("{err:#}")); - let _ = tx.send(AppEvent::FedArtistLoaded { name, result }); + let card = result.as_ref().ok().cloned(); + let _ = tx.send(AppEvent::FedArtistLoaded { + name: name.clone(), + result, + }); + // Stream the artwork in after the card is on screen: the + // artist image first, then every release cover. + let Some(card) = card else { return }; + if let Some(path) = fed.card_image(&card.owners, &name, None).await { + let _ = tx.send(AppEvent::FedCardArt { + name: name.clone(), + release: None, + path, + }); + } + for release in &card.releases { + let Some(path) = fed + .card_image(&release.owners, &name, Some(&release.title)) + .await + else { + continue; + }; + let _ = tx.send(AppEvent::FedCardArt { + name: name.clone(), + release: Some(release.title.clone()), + path, + }); + } }); } + Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None), Effect::FedPlay(fed_track) => { let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); @@ -626,6 +667,64 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) { }); } +/// Downloads federated tracks into the library one by one (with progress in +/// the status bar) and optionally links them to a playlist afterwards. +pub(crate) fn fed_download_spawn( + runtime: &Runtime, + tracks: Vec, + playlist: Option<(i64, String)>, +) { + if tracks.is_empty() { + return; + } + let fed = Arc::clone(&runtime.federation); + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let total = tracks.len(); + let mut imported_ids = Vec::new(); + let mut failed = 0usize; + for (index, track) in tracks.iter().enumerate() { + let _ = tx.send(AppEvent::StatusMessage(format!( + "federation: скачивание {}/{total}: {}", + index + 1, + track.title + ))); + match fed.download_to_library(track).await { + Ok(imported) => imported_ids.push(imported.id), + Err(err) => { + failed += 1; + tracing::warn!(title = %track.title, "federated download failed: {err:#}"); + } + } + } + let mut message = format!("federation: скачано {} из {total}", imported_ids.len()); + if failed > 0 { + message.push_str(&format!(" ({failed} с ошибкой)")); + } + if let Some((playlist_id, playlist_title)) = playlist + && !imported_ids.is_empty() + { + let library = Arc::clone(&library); + let tx_add = tx.clone(); + let title = playlist_title.clone(); + tokio::task::spawn_blocking(move || { + let result = library + .add_tracks_to_playlist(playlist_id, &imported_ids) + .map_err(|err| format!("{err:#}")); + let _ = tx_add.send(AppEvent::PlaylistTracksAdded { + playlist_id, + playlist_title: title, + result, + }); + }); + } + let _ = tx.send(AppEvent::LibraryChanged { + message: Some(message), + }); + }); +} + /// Request a fresh status snapshot for the Federation tab. fn fed_spawn_status(runtime: &Runtime) { let fed = Arc::clone(&runtime.federation); @@ -945,6 +1044,24 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }; } } + AppEvent::FedCardArt { + name, + release, + path, + } => { + if let Some((current, state::Loadable::Ready(card))) = &mut state.fed_artist_view + && *current == name + { + match release { + None => card.image_path = Some(path), + Some(title) => { + if let Some(slot) = card.releases.iter_mut().find(|r| r.title == title) { + slot.cover_path = Some(path); + } + } + } + } + } AppEvent::FedTicket(result) => match result { Ok(ticket) => { state.popup = Some(state::Popup::FedText { @@ -1144,27 +1261,15 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent format!("{count} tracks queued") }); } - AppEvent::PlaylistCreated { result, add_track } => match result { + AppEvent::PlaylistCreated { result, add_target } => match result { Ok(playlist) => { tracing::info!(title = %playlist.title, "playlist created"); state.status_message = Some(format!("playlist \"{}\" created", playlist.title)); state.popup = None; // The list is stale; refetch when next needed. state.playlists.list = None; - if let Some(track) = add_track { - let library = Arc::clone(&runtime.library); - let tx = runtime.event_tx.clone(); - let (id, title) = (playlist.id, playlist.title.clone()); - tokio::task::spawn_blocking(move || { - let result = library - .add_tracks_to_playlist(id, &[track.id]) - .map_err(|e| format!("{e:#}")); - let _ = tx.send(AppEvent::PlaylistTracksAdded { - playlist_id: id, - playlist_title: title, - result, - }); - }); + if let Some(target) = add_target { + popup::spawn_add_target(runtime, playlist.id, playlist.title.clone(), target); } } Err(message) => { diff --git a/src/app/popup.rs b/src/app/popup.rs index d8a3db0..3167c4c 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -19,14 +19,14 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { return; }; match popup { - Popup::AddToPlaylist { track, cursor } => { - handle_picker(state, runtime, track, cursor, key); + Popup::AddToPlaylist { target, cursor } => { + handle_picker(state, runtime, target, cursor, key); } Popup::NewPlaylist { - for_track, + for_target, input, busy, - } => handle_name_entry(state, runtime, for_track, input, busy, key), + } => handle_name_entry(state, runtime, for_target, input, busy, key), Popup::Edit { target, title, @@ -359,7 +359,7 @@ fn handle_track_info( fn handle_picker( state: &mut AppState, runtime: &Runtime, - track: TrackItem, + target: crate::app::state::PlaylistAddTarget, cursor: usize, key: KeyEvent, ) { @@ -368,42 +368,42 @@ fn handle_picker( KeyCode::Esc => {} KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::AddToPlaylist { - track, + target, cursor: cursor.saturating_sub(1), }); } KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::AddToPlaylist { - track, + target, cursor: (cursor + 1).min(options.len()), }); } KeyCode::Enter => { if cursor == 0 { state.popup = Some(Popup::NewPlaylist { - for_track: Some(track), + for_target: Some(target), input: crate::app::input::LineEdit::default(), busy: false, }); } else if let Some((id, title)) = options.get(cursor - 1).cloned() { - spawn_add_track(runtime, id, title, track); + spawn_add_target(runtime, id, title, target); } } - _ => state.popup = Some(Popup::AddToPlaylist { track, cursor }), + _ => state.popup = Some(Popup::AddToPlaylist { target, cursor }), } } fn handle_name_entry( state: &mut AppState, runtime: &Runtime, - for_track: Option, + for_target: Option, mut input: crate::app::input::LineEdit, busy: bool, key: KeyEvent, ) { if busy { state.popup = Some(Popup::NewPlaylist { - for_track, + for_target, input, busy, }); @@ -412,8 +412,8 @@ fn handle_name_entry( match key.code { KeyCode::Esc => { // Reached from the picker → step back to it; otherwise close. - if let Some(track) = for_track { - state.popup = Some(Popup::AddToPlaylist { track, cursor: 0 }); + if let Some(target) = for_target { + state.popup = Some(Popup::AddToPlaylist { target, cursor: 0 }); } } KeyCode::Enter => { @@ -421,15 +421,15 @@ fn handle_name_entry( if title.is_empty() { state.status_message = Some("playlist name is empty".into()); state.popup = Some(Popup::NewPlaylist { - for_track, + for_target, input, busy: false, }); return; } - spawn_create_playlist(runtime, title, for_track.clone()); + spawn_create_playlist(runtime, title, for_target.clone()); state.popup = Some(Popup::NewPlaylist { - for_track, + for_target, input, busy: true, }); @@ -437,7 +437,7 @@ fn handle_name_entry( _ => { input.handle_key(key); state.popup = Some(Popup::NewPlaylist { - for_track, + for_target, input, busy: false, }); @@ -445,28 +445,47 @@ fn handle_name_entry( } } -fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) { - let library = Arc::clone(&runtime.library); - let tx = runtime.event_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = library - .add_tracks_to_playlist(playlist_id, &[track.id]) - .map_err(|err| format!("{err:#}")); - let _ = tx.send(AppEvent::PlaylistTracksAdded { - playlist_id, - playlist_title, - result, - }); - }); +/// Adds a target to a playlist: local tracks directly; federated ones are +/// downloaded into the library first, then linked. +pub(crate) fn spawn_add_target( + runtime: &Runtime, + playlist_id: i64, + playlist_title: String, + target: crate::app::state::PlaylistAddTarget, +) { + match target { + crate::app::state::PlaylistAddTarget::Local(tracks) => { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + let ids: Vec = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect(); + tokio::task::spawn_blocking(move || { + let result = library + .add_tracks_to_playlist(playlist_id, &ids) + .map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::PlaylistTracksAdded { + playlist_id, + playlist_title, + result, + }); + }); + } + crate::app::state::PlaylistAddTarget::Fed(tracks) => { + super::fed_download_spawn(runtime, tracks, Some((playlist_id, playlist_title))); + } + } } -fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option) { +fn spawn_create_playlist( + runtime: &Runtime, + title: String, + add_target: Option, +) { let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { let result = library .create_playlist(&title) .map_err(|err| format!("{err:#}")); - let _ = tx.send(AppEvent::PlaylistCreated { result, add_track }); + let _ = tx.send(AppEvent::PlaylistCreated { result, add_target }); }); } diff --git a/src/app/state.rs b/src/app/state.rs index af2f6db..dd76069 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -74,6 +74,12 @@ pub enum GlobalView { FedArtist { cursor: usize, }, + /// One release of the open federated card: row 0 is the + /// download-release button, rows 1..=n are its tracks. + FedRelease { + index: usize, + cursor: usize, + }, } /// The Global tab: the whole server library of artists. @@ -202,6 +208,10 @@ pub enum TrackSelectionScope { Release(i64), Playlist(i64), Queue, + /// The federated section of the search results (its tracks). + FedSearch, + /// The tracklist of the open federated release view. + FedRelease(usize), } /// Vim-like Shift-V selection for line-oriented track lists. The selected @@ -325,16 +335,41 @@ impl EditField { } } +/// What an add-to-playlist flow adds: local library tracks directly, or +/// federated tracks that are downloaded into the library first. +#[derive(Debug, Clone)] +pub enum PlaylistAddTarget { + Local(Vec), + Fed(Vec), +} + +impl PlaylistAddTarget { + /// Short description for popup titles. + pub fn label(&self) -> String { + match self { + PlaylistAddTarget::Local(tracks) if tracks.len() == 1 => tracks[0].title.clone(), + PlaylistAddTarget::Fed(tracks) if tracks.len() == 1 => { + format!("{} (federation)", tracks[0].title) + } + PlaylistAddTarget::Local(tracks) => format!("{} tracks", tracks.len()), + PlaylistAddTarget::Fed(tracks) => format!("{} tracks (federation)", tracks.len()), + } + } +} + /// Modal dialog over the main screen. #[derive(Debug)] pub enum Popup { - /// 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. + /// Pick one of the playlists (row 0 = "create new"); the target is + /// added on Enter (federated tracks are downloaded first). + AddToPlaylist { + target: PlaylistAddTarget, + cursor: usize, + }, + /// Name input for a new playlist; when `for_target` is set, it is + /// added to the playlist right after creation. NewPlaylist { - for_track: Option, + for_target: Option, input: LineEdit, busy: bool, }, diff --git a/src/app/update.rs b/src/app/update.rs index 4f6bb21..3b17b92 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -51,6 +51,10 @@ pub enum Effect { FedPlay(crate::federation::FedTrack), /// Assemble the federated artist card (fan-out to the owning peers). FedOpenArtist(String), + /// Download federated tracks into the local library, one by one. + FedDownload { + tracks: Vec, + }, } pub fn update(state: &mut AppState, action: Action) -> Option { @@ -278,18 +282,46 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } Action::AddToPlaylist => { - let track = selected_track(state).or_else(|| state.player.current.clone()); - match track { - Some(track) => { - state.popup = Some(super::state::Popup::AddToPlaylist { track, cursor: 0 }); + let fed = selected_fed_tracks(state); + let target = if !fed.is_empty() { + Some(super::state::PlaylistAddTarget::Fed(fed)) + } else { + let local = selected_tracks(state); + if !local.is_empty() { + Some(super::state::PlaylistAddTarget::Local(local)) + } else { + selected_track(state) + .or_else(|| state.player.current.clone()) + .map(|track| super::state::PlaylistAddTarget::Local(vec![track])) + } + }; + match target { + Some(target) => { + state.popup = Some(super::state::Popup::AddToPlaylist { target, cursor: 0 }); + state.track_selection.clear(); } None => state.status_message = Some("no track selected".into()), } None } + Action::DownloadSelected => { + let tracks = selected_fed_tracks(state); + if tracks.is_empty() { + state.status_message = + Some("select federated tracks first (works in the federation results)".into()); + None + } else { + state.track_selection.clear(); + state.status_message = Some(format!( + "federation: скачивание {} трек(ов) в библиотеку…", + tracks.len() + )); + Some(Effect::FedDownload { tracks }) + } + } Action::NewPlaylist => { state.popup = Some(super::state::Popup::NewPlaylist { - for_track: None, + for_target: None, input: crate::app::input::LineEdit::default(), busy: false, }); @@ -550,7 +582,7 @@ fn selected_release_card(state: &AppState) -> Option None, + GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None, } } @@ -632,6 +664,14 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val TrackSelectionScope::Queue => { state.queue_tab.cursor = value; } + TrackSelectionScope::FedSearch => { + let base = state.search.results.as_ref().map_or(0, |r| r.len()) + + state.search.fed_artists.len(); + set_view_cursor(state, base + value); + } + TrackSelectionScope::FedRelease(_) => { + set_view_cursor(state, value + 1); + } } } @@ -663,6 +703,23 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, )), _ => None, }, + GlobalView::Search { cursor } => { + // Only the federated tracks section is selectable here. + let base = state.search.results.as_ref().map_or(0, |r| r.len()) + + state.search.fed_artists.len(); + let len = state.search.fed_tracks.len(); + let relative = cursor.checked_sub(base)?; + (relative < len).then_some((TrackSelectionScope::FedSearch, relative, len)) + } + GlobalView::FedRelease { index, cursor } => { + let len = fed_card_release(state, *index)?.tracks.len(); + let relative = cursor.checked_sub(1)?; + (relative < len).then_some(( + TrackSelectionScope::FedRelease(*index), + relative, + len, + )) + } _ => None, }, Tab::Playlists => { @@ -882,7 +939,7 @@ pub fn selected_track(state: &AppState) -> Option { let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?; results.tracks.get(offset).cloned() } - GlobalView::FedArtist { .. } => None, + GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None, }, Tab::Playlists => { let opened = state.playlists.opened.as_ref()?; @@ -922,7 +979,7 @@ fn selected_release_id(state: &AppState) -> Option { let offset = cursor.checked_sub(results.artists.len())?; results.releases.get(offset).map(|r| r.id) } - GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None, + GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None, } } @@ -1188,7 +1245,8 @@ fn page_step(state: &AppState) -> isize { } Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) - | Some(GlobalView::FedArtist { .. }) => lines, + | Some(GlobalView::FedArtist { .. }) + | Some(GlobalView::FedRelease { .. }) => lines, } } @@ -1358,23 +1416,31 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { state.track_selection.clear(); } Some(GlobalView::FedArtist { cursor }) => { + // A flat tile grid of releases. let total = fed_card_len(state) as isize; if total == 0 { return; } - let next = (cursor as isize + dy).clamp(0, total - 1); + let columns = grid_columns() as isize; + let step = if dx != 0 { dx } else { dy * columns }; + let next = (cursor as isize + step).clamp(0, total - 1); set_view_cursor(state, next as usize); state.track_selection.clear(); } + Some(GlobalView::FedRelease { index, cursor }) => { + let tracks = fed_card_release(state, index).map_or(0, |r| r.tracks.len()) as isize; + // Row 0 is the download button, 1..=tracks are the tracks. + let next = (cursor as isize + dy).clamp(0, tracks); + set_view_cursor(state, next as usize); + refresh_track_selection_cursor(state); + } } } -/// Selectable rows of the open federated artist card (its tracks). +/// Selectable tiles of the open federated artist card (its releases). pub(crate) fn fed_card_len(state: &AppState) -> usize { match &state.fed_artist_view { - Some((_, Loadable::Ready(card))) => { - card.releases.iter().map(|r| r.tracks.len()).sum() - } + Some((_, Loadable::Ready(card))) => card.releases.len(), _ => 0, } } @@ -1385,7 +1451,8 @@ fn set_view_cursor(state: &mut AppState, value: usize) { GlobalView::Artist { cursor, .. } | GlobalView::Release { cursor, .. } | GlobalView::Search { cursor } - | GlobalView::FedArtist { cursor } => *cursor = value, + | GlobalView::FedArtist { cursor } + | GlobalView::FedRelease { cursor, .. } => *cursor = value, } } } @@ -1429,6 +1496,9 @@ fn current_view_len(state: &AppState) -> usize { + state.search.fed_tracks.len() } Some(GlobalView::FedArtist { .. }) => fed_card_len(state), + Some(GlobalView::FedRelease { index, .. }) => { + fed_card_release(state, *index).map_or(0, |r| r.tracks.len() + 1) + } } } @@ -1629,27 +1699,47 @@ fn select_current(state: &mut AppState) -> Option { } None => fed_outcome(state, cursor), }, - Some(GlobalView::FedArtist { cursor }) => { - match &state.fed_artist_view { - Some((name, Loadable::Ready(card))) => { - match card - .releases - .iter() - .flat_map(|release| release.tracks.iter().map(move |t| (release, t))) - .nth(cursor) - { - Some((release, track)) => { - match fed_track_from_card(name, release, track) { - Some(fed) => Outcome::PlayFed(fed), - None => Outcome::Nothing, - } - } - None => Outcome::Nothing, - } - } - _ => Outcome::Nothing, + Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view { + Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => { + // Focus starts on the first track; Up from it reaches the + // download-release button (row 0). + let start = if card.releases[cursor].tracks.is_empty() { + 0 + } else { + 1 + }; + Outcome::Push(GlobalView::FedRelease { + index: cursor, + cursor: start, + }) } - } + _ => Outcome::Nothing, + }, + Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) { + Some(_) if cursor == 0 => { + // The download-whole-release button. + match fed_release_tracks(state, index) { + tracks if tracks.is_empty() => Outcome::Nothing, + tracks => Outcome::DownloadFed(tracks), + } + } + Some(release) => { + let name = state + .fed_artist_view + .as_ref() + .map(|(name, _)| name.clone()) + .unwrap_or_default(); + match release + .tracks + .get(cursor - 1) + .and_then(|track| fed_track_from_card(&name, release, track)) + { + Some(fed) => Outcome::PlayFed(fed), + None => Outcome::Nothing, + } + } + None => Outcome::Nothing, + }, }; match outcome { Outcome::Push(view) => { @@ -1672,10 +1762,90 @@ fn select_current(state: &mut AppState) -> Option { state.active_tab = Tab::Global; Some(Effect::FedOpenArtist(name)) } + Outcome::DownloadFed(tracks) => { + state.status_message = Some(format!( + "federation: скачивание {} трек(ов) в библиотеку…", + tracks.len() + )); + Some(Effect::FedDownload { tracks }) + } Outcome::Nothing => None, } } +/// The open card's release by index. +pub(crate) fn fed_card_release( + state: &AppState, + index: usize, +) -> Option<&crate::federation::FedRelease> { + match &state.fed_artist_view { + Some((_, Loadable::Ready(card))) => card.releases.get(index), + _ => None, + } +} + +/// Every track of one card release as playable FedTracks. +pub(crate) fn fed_release_tracks( + state: &AppState, + index: usize, +) -> Vec { + let Some((name, Loadable::Ready(card))) = &state.fed_artist_view else { + return Vec::new(); + }; + let Some(release) = card.releases.get(index) else { + return Vec::new(); + }; + release + .tracks + .iter() + .filter_map(|track| fed_track_from_card(name, release, track)) + .collect() +} + +/// Federated tracks covered by the active visual selection, or the single +/// one under the cursor in a federated context. +pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec { + // An active Shift-V range in a federated scope. + if let Some(scope) = state.track_selection.scope.clone() { + match scope { + TrackSelectionScope::FedSearch => { + let len = state.search.fed_tracks.len(); + if let Some(indices) = state.track_selection.indices(&scope, len) { + return indices + .into_iter() + .filter_map(|i| state.search.fed_tracks.get(i).cloned()) + .collect(); + } + } + TrackSelectionScope::FedRelease(index) => { + let all = fed_release_tracks(state, index); + if let Some(indices) = state.track_selection.indices(&scope, all.len()) { + return indices.into_iter().filter_map(|i| all.get(i).cloned()).collect(); + } + } + _ => {} + } + } + // No selection: the federated track under the cursor. + match state.global.stack.last() { + Some(GlobalView::Search { cursor }) => { + let base = state.search.results.as_ref().map_or(0, |r| r.len()) + + state.search.fed_artists.len(); + cursor + .checked_sub(base) + .and_then(|i| state.search.fed_tracks.get(i).cloned()) + .into_iter() + .collect() + } + Some(GlobalView::FedRelease { index, cursor }) => cursor + .checked_sub(1) + .and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i)) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + /// What Enter resolved to in the current view. enum Outcome { Push(GlobalView), @@ -1685,6 +1855,7 @@ enum Outcome { }, PlayFed(crate::federation::FedTrack), OpenFedArtist(String), + DownloadFed(Vec), Nothing, } diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 9d12384..b48ea23 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -51,6 +51,10 @@ command = { GoToTab = 3 } key_sequence = "5" command = { GoToTab = 4 } +[[keymaps]] +key_sequence = "y" +command = "DownloadSelected" + [[keymaps]] key_sequence = "a" command = "QueueAddNext" diff --git a/src/federation/catalog.rs b/src/federation/catalog.rs index 6087863..5bfd060 100644 --- a/src/federation/catalog.rs +++ b/src/federation/catalog.rs @@ -27,8 +27,30 @@ const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; struct CatalogRequest { /// Artist display name; matched case-insensitively by the owner. artist: String, + /// What is being asked for: `None`/"catalog" — the JSON catalog; + /// "artist_image" — the artist's image; "release_cover" — the cover of + /// `release`. Image responses are a JSON header line + raw bytes. + #[serde(default)] + want: Option, + #[serde(default)] + release: Option, } +/// Header line preceding raw image bytes (artist image / release cover). +#[derive(Debug, Default, Serialize, Deserialize)] +struct ImageHeader { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + mime_type: String, + #[serde(default)] + size: u64, +} + +/// Images above this size are skipped rather than transferred. +const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; + #[derive(Debug, Default, Serialize, Deserialize)] struct CatalogResponse { ok: bool, @@ -94,22 +116,106 @@ pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc, ow async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointId) -> Result<()> { let request: CatalogRequest = serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?; - tracing::info!(peer = %stream.peer_id, artist = %request.artist, "peer requested a catalog"); + tracing::info!( + peer = %stream.peer_id, + artist = %request.artist, + want = request.want.as_deref().unwrap_or("catalog"), + "peer requested a catalog" + ); - let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist)) - .await? - .unwrap_or_else(|err| CatalogResponse { - ok: false, - error: Some(format!("catalog lookup failed: {err:#}")), - artist: None, - }); - let payload = serde_json::to_vec(&response)?; - stream.send.write_all(&payload).await?; + match request.want.as_deref() { + None | Some("catalog") => { + let response = + tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist)) + .await? + .unwrap_or_else(|err| CatalogResponse { + ok: false, + error: Some(format!("catalog lookup failed: {err:#}")), + artist: None, + }); + let payload = serde_json::to_vec(&response)?; + stream.send.write_all(&payload).await?; + } + Some(want @ ("artist_image" | "release_cover")) => { + let want_cover = want == "release_cover"; + let release = request.release.clone().unwrap_or_default(); + let artist = request.artist.clone(); + let path = tokio::task::spawn_blocking(move || -> Result> { + if want_cover { + library.release_cover_by_names(&artist, &release) + } else { + let Some(artist_id) = library.artist_id_by_name(&artist)? else { + return Ok(None); + }; + library.artist_image(artist_id) + } + }) + .await??; + serve_image(&mut stream, path.as_deref()).await?; + } + Some(other) => { + let response = CatalogResponse { + ok: false, + error: Some(format!("unknown request kind '{other}'")), + artist: None, + }; + stream.send.write_all(&serde_json::to_vec(&response)?).await?; + } + } stream.send.finish()?; let _ = stream.send.stopped().await; Ok(()) } +/// Streams one image file: header line, then the raw bytes. +async fn serve_image(stream: &mut ByteStream, path: Option<&str>) -> Result<()> { + let loaded = match path { + Some(path) => match tokio::fs::read(path).await { + Ok(bytes) if !bytes.is_empty() && bytes.len() as u64 <= MAX_IMAGE_BYTES => { + Some((bytes, image_mime_by_path(path))) + } + _ => None, + }, + None => None, + }; + let header = match &loaded { + Some((bytes, mime)) => ImageHeader { + ok: true, + error: None, + mime_type: (*mime).to_string(), + size: bytes.len() as u64, + }, + None => ImageHeader { + ok: false, + error: Some("no image".to_string()), + ..ImageHeader::default() + }, + }; + let mut line = serde_json::to_vec(&header)?; + line.push(b'\n'); + stream.send.write_all(&line).await?; + if let Some((bytes, _)) = &loaded { + stream.send.write_all(bytes).await?; + } + Ok(()) +} + +fn image_mime_by_path(path: &str) -> &'static str { + match std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "bmp" => "image/bmp", + _ => "image/jpeg", + } +} + /// Builds this instance's library slice for `artist`. fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result { let Some(artist_id) = library.artist_id_by_name(artist)? else { @@ -173,6 +279,8 @@ pub async fn fetch_catalog( .map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?; let mut line = serde_json::to_vec(&CatalogRequest { artist: artist.to_string(), + want: None, + release: None, })?; line.push(b'\n'); stream.send.write_all(&line).await?; @@ -200,6 +308,59 @@ pub async fn fetch_catalog( response.artist.context("empty catalog response") } +/// Fetches an image (artist image or a release cover) from a peer over the +/// catalog protocol. `release: None` asks for the artist image. Returns the +/// raw bytes and a file extension, or None when the peer has no image. +pub async fn fetch_image( + service: &MusicDhtService, + owner: EndpointId, + artist: &str, + release: Option<&str>, +) -> Result, &'static str)>> { + let mut stream = service + .open_stream(owner, CATALOG_ALPN) + .await + .map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?; + let mut line = serde_json::to_vec(&CatalogRequest { + artist: artist.to_string(), + want: Some(if release.is_some() { + "release_cover".to_string() + } else { + "artist_image".to_string() + }), + release: release.map(str::to_string), + })?; + line.push(b'\n'); + stream.send.write_all(&line).await?; + stream.send.finish()?; + + let header: ImageHeader = + serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?) + .context("malformed image header")?; + if !header.ok || header.size == 0 { + return Ok(None); + } + anyhow::ensure!( + header.size <= MAX_IMAGE_BYTES, + "image of {} bytes exceeds the {MAX_IMAGE_BYTES} byte limit", + header.size + ); + let mut bytes = vec![0u8; header.size as usize]; + stream + .recv + .read_exact(&mut bytes) + .await + .context("stream ended inside the image")?; + let extension = match header.mime_type.as_str() { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + "image/bmp" => "bmp", + _ => "jpg", + }; + Ok(Some((bytes, extension))) +} + /// AsyncRead adapter over the receive half of a byte stream. struct StreamReader<'a>(&'a mut ByteStream); @@ -224,6 +385,10 @@ pub struct FedArtistCard { pub name: String, /// Peers whose catalogs contributed to the card. pub peers: usize, + /// Every contributing peer (hex ids) — where images are fetched from. + pub owners: Vec, + /// Local cache path of the artist image, streamed from a peer. + pub image_path: Option, pub releases: Vec, } @@ -232,6 +397,10 @@ pub struct FedRelease { pub title: String, pub release_type: String, pub year: Option, + /// Peers holding this release (hex ids). + pub owners: Vec, + /// Local cache path of the cover, streamed from a peer. + pub cover_path: Option, pub tracks: Vec, } @@ -254,7 +423,11 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed let mut releases: Vec = Vec::new(); let mut release_index: HashMap = HashMap::new(); + let mut card_owners: Vec = Vec::new(); for (owner_hex, catalog) in catalogs { + if !card_owners.contains(&owner_hex) { + card_owners.push(owner_hex.clone()); + } for release in catalog.releases { let release_key = music_dht::normalize_name(&release.title); let slot = *release_index.entry(release_key).or_insert_with(|| { @@ -262,11 +435,16 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed title: release.title.clone(), release_type: release.release_type.clone(), year: None, + owners: Vec::new(), + cover_path: None, tracks: Vec::new(), }); releases.len() - 1 }); let merged = &mut releases[slot]; + if !merged.owners.contains(&owner_hex) { + merged.owners.push(owner_hex.clone()); + } if merged.year.is_none() { merged.year = release.year; } @@ -320,6 +498,8 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed FedArtistCard { name: name.to_string(), peers, + owners: card_owners, + image_path: None, releases, } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 4bcd102..7ecd2fe 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -580,10 +580,80 @@ impl Federation { Ok(peer.to_string()) } + /// Directory for streamed (never library-imported) card artwork. + fn art_cache_dir(&self) -> PathBuf { + self.cache_dir.join("art") + } + + /// Returns a cached-or-streamed image for the card: the artist image + /// (`release: None`) or a release cover. Peers are tried in order until + /// one answers with an image; the result lands in the art cache and its + /// local path is returned. + pub async fn card_image( + &self, + owners: &[String], + artist: &str, + release: Option<&str>, + ) -> Option { + let dir = self.art_cache_dir(); + let stem = match release { + Some(release) => format!( + "cover-{}-{}", + sanitize_file_stem(artist), + sanitize_file_stem(release) + ), + None => format!("artist-{}", sanitize_file_stem(artist)), + }; + // Reuse a previously streamed copy of any known image type. + for extension in ["jpg", "png", "webp", "gif", "bmp"] { + let path = dir.join(format!("{stem}.{extension}")); + if path.is_file() { + return Some(path.to_string_lossy().into_owned()); + } + } + let service = self.service().await.ok()?; + tokio::fs::create_dir_all(&dir).await.ok()?; + for owner in owners { + let Ok(owner) = EndpointId::from_str(owner) else { + continue; + }; + let fetched = tokio::time::timeout( + Duration::from_secs(5), + catalog::fetch_image(&service, owner, artist, release), + ) + .await; + match fetched { + Ok(Ok(Some((bytes, extension)))) => { + let path = dir.join(format!("{stem}.{extension}")); + if tokio::fs::write(&path, &bytes).await.is_ok() { + return Some(path.to_string_lossy().into_owned()); + } + } + Ok(Ok(None)) => continue, + Ok(Err(err)) => tracing::debug!(peer = %owner, "image fetch failed: {err:#}"), + Err(_) => tracing::debug!(peer = %owner, "image fetch timed out"), + } + } + None + } + + /// Downloads a federated track straight into the local library + /// (regardless of the save-on-listen setting) and returns the imported + /// track. Own/already-local tracks resolve without downloading. + pub async fn download_to_library(self: &Arc, fed: &FedTrack) -> Result { + let playable = self.fetch_playable(fed, true).await?; + Ok(playable.track) + } + /// Prepares a federated track for playback: local tracks resolve /// straight to the library; remote tracks are downloaded — into the /// library when save-on-listen is enabled, into the cache otherwise. pub async fn prepare_playback(self: &Arc, fed: &FedTrack) -> Result { + let save = self.settings().save_on_listen; + self.fetch_playable(fed, save).await + } + + async fn fetch_playable(self: &Arc, fed: &FedTrack, save: bool) -> Result { let service = self.service().await?; let item_id = audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?; @@ -608,7 +678,6 @@ impl Federation { let owner = EndpointId::from_str(&fed.owner) .map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?; - let save = self.settings().save_on_listen; let dir = if save { &self.media_dir } else { &self.cache_dir }; tokio::fs::create_dir_all(dir).await?; diff --git a/src/library/mod.rs b/src/library/mod.rs index 4e3d40f..d56a5d0 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -731,6 +731,24 @@ impl Library { .optional()?) } + /// Cover path of an artist's release, matched by names (for the peer + /// catalog image protocol). + pub fn release_cover_by_names(&self, artist: &str, release: &str) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT r.cover_path FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + JOIN artists a ON a.id = ra.artist_id + WHERE a.name = ?1 COLLATE NOCASE AND r.title = ?2 COLLATE NOCASE + LIMIT 1", + params![artist, release], + |row| row.get(0), + ) + .optional()? + .flatten()) + } + /// Image of one artist, for the federation metadata exchange. pub fn artist_image(&self, artist_id: i64) -> Result> { let conn = self.lock(); diff --git a/src/ui/global.rs b/src/ui/global.rs index 0175a2b..925167f 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -24,6 +24,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor), Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor), Some(GlobalView::FedArtist { cursor }) => draw_fed_artist(frame, area, state, *cursor), + Some(GlobalView::FedRelease { index, cursor }) => { + draw_fed_release(frame, area, state, *index, *cursor) + } } } @@ -852,6 +855,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } } + // Absolute cursor indices covered by an active Shift-V range in the + // federated tracks section. + let fed_scope = crate::app::state::TrackSelectionScope::FedSearch; + let mut fed_selected: std::collections::HashSet = Default::default(); + if state.track_selection.is_active_for(&fed_scope) { + let base = results.len() + state.search.fed_artists.len(); + if let Some(indices) = state + .track_selection + .indices(&fed_scope, state.search.fed_tracks.len()) + { + fed_selected.extend(indices.into_iter().map(|i| base + i)); + } + } + let cursor_row = rows .iter() .position(|(_, _, c)| *c == Some(cursor)) @@ -869,6 +886,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { width: inner.width, height: 1, }; + if let Some(row_index) = row_cursor + && fed_selected.contains(&row_index) + && row_index != cursor + { + frame.buffer_mut().set_style(rect, theme::selection()); + } draw_row(frame, rect, line, right, row_cursor == Some(cursor)); } } @@ -881,7 +904,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz let Some((name, data)) = &state.fed_artist_view else { return centered_line(frame, area, Line::styled("no card is open", theme::dim())); }; - let inner = bordered(frame, area, format!(" {name} — federation ")); + let inner = bordered(frame, area, format!(" Federation ▸ {name} ")); let card = match data { Loadable::Loading => { return centered_line( @@ -896,82 +919,192 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz Loadable::Ready(card) => card, }; - // All rows are one line tall: (line, right column, cursor index). - let mut rows: Vec<(Line, Option, Option)> = Vec::new(); - rows.push(( + // Header: artist image (streamed from a peer) left, stats right. + let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); + let [header_area, content_area] = + Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); + let [art_area, _, info_area] = Layout::horizontal([ + Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), + Constraint::Length(2), + Constraint::Min(0), + ]) + .areas(header_area); + draw_art( + frame, + Rect { + height: ART_HEADER_HEIGHT.min(art_area.height), + ..art_area + }, + header_art(state, card.image_path.as_ref()), + ); + let tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum(); + let info = vec![ + Line::default(), + Line::styled(name.clone(), theme::header()), + Line::default(), Line::styled( format!( "{} релизов · {} треков · с {} пиров", card.releases.len(), - card.releases.iter().map(|r| r.tracks.len()).sum::(), + tracks_total, card.peers ), theme::dim(), ), - None, - None, - )); - let mut index = 0; - for release in &card.releases { - rows.push((Line::default(), None, None)); - let mut header = release.title.clone(); - if let Some(year) = release.year { - header.push_str(&format!(" ({year})")); - } - rows.push(( - Line::from(vec![ - Span::styled(header, theme::header()), - Span::styled(format!(" {}", release.release_type), theme::dim()), - ]), - None, - None, - )); - for track in &release.tracks { - let number = track - .track_number - .map(|n| format!("{n:>2}. ")) - .unwrap_or_else(|| " ".to_string()); - let duration = track - .duration_seconds - .map(|d| { - let total = d.round() as i64; - format!("{}:{:02}", total / 60, total % 60) - }) - .unwrap_or_default(); - let sources = if track.sources.len() > 1 { - format!("{} · {} пиров", duration, track.sources.len()) - } else { - duration - }; - rows.push(( - Line::from(vec![ - Span::styled("⇅ ", theme::accent()), - Span::raw(format!("{number}{}", track.title)), - ]), - Some(sources), - Some(index), - )); - index += 1; - } + Line::styled("enter: открыть релиз · esc: назад", theme::dim()), + ]; + frame.render_widget(Paragraph::new(info), info_area); + + if card.releases.is_empty() { + return centered_line( + frame, + content_area, + Line::styled("пиры не отдали ни одного релиза", theme::dim()), + ); } - let cursor_row = rows + // Release tiles: a flat grid ordered by year, scrolled to the cursor. + let columns = usize::from((content_area.width / TILE_WIDTH).max(1)); + let visible_rows = usize::from((content_area.height / TILE_HEIGHT).max(1)); + let cursor_row = cursor / columns; + let total_rows = card.releases.len().div_ceil(columns); + let first_row = cursor_row + .saturating_sub(visible_rows / 2) + .min(total_rows.saturating_sub(visible_rows)); + for (offset, row) in (first_row..total_rows).take(visible_rows).enumerate() { + for column in 0..columns { + let index = row * columns + column; + let Some(release) = card.releases.get(index) else { + break; + }; + let tile = Rect { + x: content_area.x + (column as u16) * TILE_WIDTH, + y: content_area.y + (offset as u16) * TILE_HEIGHT, + width: TILE_WIDTH, + height: TILE_HEIGHT.min(content_area.height.saturating_sub((offset as u16) * TILE_HEIGHT)), + }; + if tile.height < 3 { + continue; + } + let mut meta = release.release_type.clone(); + if let Some(year) = release.year { + meta = format!("{meta} · {year}"); + } + draw_tile( + frame, + tile, + tile_art(state, release.cover_path.as_ref()), + &release.title, + &meta, + index == cursor, + ); + } + } +} + +fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usize, cursor: usize) { + let Some((name, Loadable::Ready(card))) = &state.fed_artist_view else { + return centered_line(frame, area, Line::styled("no card is open", theme::dim())); + }; + let Some(release) = card.releases.get(index) else { + return centered_line(frame, area, Line::styled("release is gone", theme::dim())); + }; + let inner = bordered( + frame, + area, + format!(" Federation ▸ {name} ▸ {} ", release.title), + ); + + // Header: cover left; title, meta and the download button right. + let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); + let [header_area, content_area] = + Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); + let [art_area, _, info_area] = Layout::horizontal([ + Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), + Constraint::Length(2), + Constraint::Min(0), + ]) + .areas(header_area); + draw_art( + frame, + Rect { + height: ART_HEADER_HEIGHT.min(art_area.height), + ..art_area + }, + header_art(state, release.cover_path.as_ref()), + ); + let mut meta = release.release_type.clone(); + if let Some(year) = release.year { + meta.push_str(&format!(" · {year}")); + } + meta.push_str(&format!( + " · {} треков · с {} пиров", + release.tracks.len(), + release.owners.len().max(1) + )); + let button_style = if cursor == 0 { + theme::tab_active() + } else { + theme::accent() + }; + let info = vec![ + Line::default(), + Line::styled(release.title.clone(), theme::header()), + Line::styled(meta, theme::dim()), + Line::default(), + Line::styled( + format!(" ⤓ Скачать релиз целиком ({}) ", release.tracks.len()), + button_style, + ), + Line::styled("shift+v: выделение · y: скачать · p: в плейлист", theme::dim()), + ]; + frame.render_widget(Paragraph::new(info), info_area); + + // Tracklist: rows 1..=n of the cursor space. + let scope = crate::app::state::TrackSelectionScope::FedRelease(index); + let visible = usize::from(content_area.height.max(1)); + let cursor_track = cursor.saturating_sub(1); + let first = cursor_track + .saturating_sub(visible / 2) + .min(release.tracks.len().saturating_sub(visible)); + for (offset, (position, track)) in release + .tracks .iter() - .position(|(_, _, c)| *c == Some(cursor)) - .unwrap_or(0); - let visible = usize::from(inner.height.max(1)); - let first = cursor_row - .saturating_sub(visible / 2) - .min(rows.len().saturating_sub(visible)); - for (offset, (line, right, row_cursor)) in - rows.into_iter().enumerate().skip(first).take(visible) + .enumerate() + .skip(first) + .take(visible) + .enumerate() { let rect = Rect { - x: inner.x, - y: inner.y + (offset - first) as u16, - width: inner.width, + x: content_area.x, + y: content_area.y + offset as u16, + width: content_area.width, height: 1, + }; + let number = track + .track_number + .map(|n| format!("{n:>2}. ")) + .unwrap_or_else(|| " ".to_string()); + let duration = track + .duration_seconds + .map(|d| { + let total = d.round() as i64; + format!("{}:{:02}", total / 60, total % 60) + }) + .unwrap_or_default(); + let right = if track.sources.len() > 1 { + format!("{duration} · {} пиров", track.sources.len()) + } else { + duration }; - draw_row(frame, rect, line, right, row_cursor == Some(cursor)); + let in_selection = state.track_selection.contains(&scope, position); + let line = Line::from(vec![ + Span::styled("⇅ ", theme::accent()), + Span::raw(format!("{number}{}", track.title)), + ]); + if in_selection && cursor != position + 1 { + frame.buffer_mut().set_style(rect, theme::selection()); + } + draw_row(frame, rect, line, Some(right), cursor == position + 1); } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 0c2c528..05e244d 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -9,8 +9,8 @@ use crate::library::models::{ArtistRef, TrackItem}; pub fn draw(frame: &mut Frame, state: &AppState) { match state.popup.as_ref() { - Some(Popup::AddToPlaylist { track, cursor }) => { - draw_picker(frame, state, &track.title, *cursor) + Some(Popup::AddToPlaylist { target, cursor }) => { + draw_picker(frame, state, &target.label(), *cursor) } Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy), Some(Popup::Edit {