From f80e10e6add36a50534b4d424b8f68696f85c32b Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 25 Jul 2026 23:57:03 +0300 Subject: [PATCH] Improved fiters. reworked Trak wrapper. --- src/app/event.rs | 6 + src/app/mod.rs | 107 ++++++++- src/app/popup.rs | 2 +- src/app/state.rs | 553 ++++++++++++++++++++++++++++++++++++++++++---- src/app/update.rs | 339 ++++++++++++++++++---------- src/ui/global.rs | 189 +++++++++++----- src/ui/mod.rs | 34 ++- 7 files changed, 1010 insertions(+), 220 deletions(-) diff --git a/src/app/event.rs b/src/app/event.rs index 4883e9c..3a51881 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -113,6 +113,12 @@ pub enum AppEvent { name: String, result: Result, }, + /// Federated enrichment for an already-open local artist view. + ArtistFederationLoaded { + id: i64, + name: String, + result: Result, + }, /// A network-library source refreshed its cached top-artist slice. NetworkArtistCacheUpdated { source_id: String, diff --git a/src/app/mod.rs b/src/app/mod.rs index cb9d742..e22bc2f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -95,6 +95,62 @@ fn refresh_local_content_ids(runtime: &Runtime) { }); } +fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = fed + .artist_card(&name) + .await + .map_err(|err| format!("{err:#}")); + let card = result.as_ref().ok().cloned(); + let _ = tx.send(AppEvent::ArtistFederationLoaded { + id, + name: name.clone(), + result, + }); + 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, + }); + } + }); +} + +fn maybe_enrich_open_artist(state: &mut AppState, runtime: &Runtime) { + if !state.federation.settings.enabled || state.active_tab != state::Tab::Global { + return; + } + let Some(state::GlobalView::Artist { id, .. }) = state.global.stack.last().copied() else { + return; + }; + if state.artist_fed_views.contains_key(&id) { + return; + } + let Some(state::Loadable::Ready(detail)) = state.artist_views.get(&id) else { + return; + }; + let name = detail.name.clone(); + state.artist_fed_views.insert(id, state::Loadable::Loading); + spawn_artist_federation_enrichment(runtime, id, name); +} + fn spawn_content_id_backfill(runtime: &Runtime) { let library = Arc::clone(&runtime.library); let federation = Arc::clone(&runtime.federation); @@ -796,6 +852,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { maybe_refresh_network_library(state, runtime); maybe_fetch_network_artist_images(state, runtime); + maybe_enrich_open_artist(state, runtime); // Liked ids load once per session — markers are shown everywhere. if !state.likes_loaded { @@ -916,6 +973,19 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { wanted.push((path.clone(), header.0, header.1)); } } + for card in state.artist_fed_views.values() { + if let state::Loadable::Ready(card) = card { + 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)); + } + } + } + } 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)); @@ -2981,6 +3051,18 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }; } } + AppEvent::ArtistFederationLoaded { id, name, result } => { + let cursor_anchor = state::artist_cursor_anchor(state, id); + let entry = match result { + Ok(card) => state::Loadable::Ready(card), + Err(message) => { + tracing::debug!(artist = id, %name, %message, "artist federation enrichment failed"); + state::Loadable::Failed(message) + } + }; + state.artist_fed_views.insert(id, entry); + state::restore_artist_cursor_anchor(state, id, cursor_anchor); + } AppEvent::NetworkArtistCacheUpdated { source_id, count } => { tracing::debug!(source = %source_id, count, "network artist cache updated"); if state.active_tab == state::Tab::Global && state.global.stack.is_empty() { @@ -2995,11 +3077,27 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent if let Some((current, state::Loadable::Ready(card))) = &mut state.fed_artist_view && *current == name { - match release { - None => card.image_path = Some(path), + match &release { + None => card.image_path = Some(path.clone()), Some(title) => { - if let Some(slot) = card.releases.iter_mut().find(|r| r.title == title) { - slot.cover_path = Some(path); + if let Some(slot) = card.releases.iter_mut().find(|r| r.title == *title) { + slot.cover_path = Some(path.clone()); + } + } + } + } + for data in state.artist_fed_views.values_mut() { + let state::Loadable::Ready(card) = data else { + continue; + }; + if music_dht::normalize_name(&card.name) != music_dht::normalize_name(&name) { + continue; + } + match &release { + None => card.image_path = Some(path.clone()), + Some(title) => { + if let Some(slot) = card.releases.iter_mut().find(|r| r.title == *title) { + slot.cover_path = Some(path.clone()); } } } @@ -3074,6 +3172,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } }; state.artist_views.insert(id, entry); + maybe_enrich_open_artist(state, runtime); } AppEvent::ReleaseViewLoaded { id, result } => { let entry = match result { diff --git a/src/app/popup.rs b/src/app/popup.rs index 3da5aad..02a9d01 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -267,7 +267,7 @@ fn handle_library_filters( super::save_app_settings(state); super::reset_artist_pagination(state); super::refresh_artists(state, runtime); - state.popup = Some(Popup::LibraryFilters { cursor }); + super::update::apply_library_filter_change(state); } _ => state.popup = Some(Popup::LibraryFilters { cursor }), } diff --git a/src/app/state.rs b/src/app/state.rs index 02f4499..09f3ec1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -5,7 +5,7 @@ use crate::app::input::LineEdit; use crate::art::ArtImage; use crate::config::keymap::KeyContext; use crate::library::models::{ - ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, + ArtistCard, ArtistDetail, Availability, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, TrackItem, }; @@ -122,19 +122,6 @@ impl Default for GlobalTab { } } -/// Releases of an artist in display order: grouped by type (albums, singles, -/// EPs, compilations, then anything else), newest first within each group. -/// Returns (group label, indices into the original slice). Cursor positions -/// use this flattened order, so update() and ui must both go through here. -pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec)> { - release_type_groups( - releases, - |release| &release.release_type, - |release| release.year, - |release| &release.title, - ) -} - fn release_type_groups( items: &[T], release_type: impl Fn(&T) -> &str, @@ -183,21 +170,6 @@ fn sort_release_indices( }); } -/// Flattened display order of releases (concatenated groups). -pub fn release_display_order(releases: &[ReleaseCard]) -> Vec { - release_groups(releases) - .into_iter() - .flat_map(|(_, indices)| indices) - .collect() -} - -/// Visual tile-grid rows of the releases section: each group starts its own -/// rows, chunked by the column count. Values are display-order positions. -/// Vertical cursor movement must follow these rows to match the rendering. -pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec> { - grouped_release_rows(release_groups(releases), columns) -} - pub fn fed_release_groups( releases: &[crate::federation::FedRelease], ) -> Vec<(&'static str, Vec)> { @@ -225,6 +197,39 @@ pub fn fed_release_rows( grouped_release_rows(fed_release_groups(releases), columns) } +#[derive(Debug, Clone)] +pub struct ArtistReleaseSlot { + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_path: Option, + pub local_index: Option, + pub fed_index: Option, + pub local_track_count: usize, + pub total_track_count: usize, + pub availability: Availability, +} + +pub fn artist_release_groups(releases: &[ArtistReleaseSlot]) -> Vec<(&'static str, Vec)> { + release_type_groups( + releases, + |release| &release.release_type, + |release| release.year, + |release| &release.title, + ) +} + +pub fn artist_release_display_order(releases: &[ArtistReleaseSlot]) -> Vec { + artist_release_groups(releases) + .into_iter() + .flat_map(|(_, indices)| indices) + .collect() +} + +pub fn artist_release_rows(releases: &[ArtistReleaseSlot], columns: usize) -> Vec> { + grouped_release_rows(artist_release_groups(releases), columns) +} + fn grouped_release_rows( groups: Vec<(&'static str, Vec)>, columns: usize, @@ -245,14 +250,16 @@ fn grouped_release_rows( mod tests { use super::*; - fn release(id: i64, title: &str, release_type: &str, year: Option) -> ReleaseCard { - ReleaseCard { - id, + fn release(title: &str, release_type: &str, year: Option) -> ArtistReleaseSlot { + ArtistReleaseSlot { title: title.to_string(), release_type: release_type.to_string(), year, cover_path: None, - track_count: 1, + local_index: None, + fed_index: None, + local_track_count: 1, + total_track_count: 1, availability: crate::library::models::Availability::Local, } } @@ -273,14 +280,14 @@ mod tests { #[test] fn release_display_order_is_newest_first_within_each_type() { let releases = vec![ - release(1, "Old Album", "album", Some(1991)), - release(2, "New Single", "single", Some(2024)), - release(3, "New Album", "album", Some(2020)), - release(4, "Undated Album", "album", None), - release(5, "Old Single", "single", Some(1999)), + release("Old Album", "album", Some(1991)), + release("New Single", "single", Some(2024)), + release("New Album", "album", Some(2020)), + release("Undated Album", "album", None), + release("Old Single", "single", Some(1999)), ]; - assert_eq!(release_display_order(&releases), vec![2, 0, 3, 1, 4]); + assert_eq!(artist_release_display_order(&releases), vec![2, 0, 3, 1, 4]); } #[test] @@ -295,6 +302,178 @@ mod tests { assert_eq!(fed_release_display_order(&releases), vec![2, 0, 3, 1, 4]); } + + #[test] + fn artist_merged_releases_marks_partially_local_federated_release() { + let local_id = "b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let remote_id = "b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let detail = ArtistDetail { + id: 7, + name: "Artist".to_string(), + image_path: None, + total_track_count: 1, + total_play_count: 0, + top_tracks: Vec::new(), + releases: vec![crate::library::models::ReleaseCard { + id: 11, + title: "Album".to_string(), + release_type: "album".to_string(), + year: Some(2024), + cover_path: None, + track_count: 1, + availability: Availability::Local, + }], + featured_tracks: Vec::new(), + }; + let mut state = AppState::default(); + state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My; + state.local_content_ids.insert(local_id.to_string()); + state.artist_fed_views.insert( + detail.id, + Loadable::Ready(crate::federation::FedArtistCard { + name: detail.name.clone(), + own_owner: None, + peers: 1, + owners: vec!["peer".to_string()], + image_path: None, + releases: vec![crate::federation::FedRelease { + title: "Album".to_string(), + release_type: "album".to_string(), + year: Some(2024), + tracks: vec![ + crate::federation::FedCardTrack { + title: "Local".to_string(), + content_id: Some(local_id.to_string()), + ..Default::default() + }, + crate::federation::FedCardTrack { + title: "Remote".to_string(), + content_id: Some(remote_id.to_string()), + ..Default::default() + }, + ], + ..Default::default() + }], + appears_on: Vec::new(), + }), + ); + + let merged = artist_merged_releases(&state, detail.id, &detail); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].local_track_count, 1); + assert_eq!(merged[0].total_track_count, 2); + assert_eq!(merged[0].availability, Availability::Mixed); + assert_eq!(merged[0].local_index, Some(0)); + assert_eq!(merged[0].fed_index, Some(0)); + } + + #[test] + fn artist_merged_releases_ignores_federation_in_local_filter() { + let detail = ArtistDetail { + id: 7, + name: "Artist".to_string(), + image_path: None, + total_track_count: 1, + total_play_count: 0, + top_tracks: Vec::new(), + releases: vec![crate::library::models::ReleaseCard { + id: 11, + title: "Album".to_string(), + release_type: "album".to_string(), + year: Some(2024), + cover_path: None, + track_count: 1, + availability: Availability::Local, + }], + featured_tracks: Vec::new(), + }; + let mut state = AppState::default(); + state.artist_fed_views.insert( + detail.id, + Loadable::Ready(crate::federation::FedArtistCard { + name: detail.name.clone(), + peers: 1, + releases: vec![crate::federation::FedRelease { + title: "Album".to_string(), + release_type: "album".to_string(), + tracks: vec![crate::federation::FedCardTrack { + title: "Remote".to_string(), + content_id: Some( + "b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + ), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }), + ); + + let merged = artist_merged_releases(&state, detail.id, &detail); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].total_track_count, 1); + assert_eq!(merged[0].availability, Availability::Local); + assert_eq!(merged[0].fed_index, None); + } + + #[test] + fn artist_cursor_anchor_restores_release_after_federation_enrichment() { + let detail = ArtistDetail { + id: 7, + name: "Artist".to_string(), + image_path: None, + total_track_count: 1, + total_play_count: 0, + top_tracks: Vec::new(), + releases: vec![crate::library::models::ReleaseCard { + id: 11, + title: "Old Local".to_string(), + release_type: "album".to_string(), + year: Some(2001), + cover_path: None, + track_count: 1, + availability: Availability::Local, + }], + featured_tracks: Vec::new(), + }; + let mut state = AppState::default(); + state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My; + let artist_id = detail.id; + let artist_name = detail.name.clone(); + state + .artist_views + .insert(artist_id, Loadable::Ready(detail)); + state.global.stack.push(GlobalView::Artist { + id: artist_id, + cursor: 0, + }); + + let anchor = artist_cursor_anchor(&state, artist_id); + state.artist_fed_views.insert( + artist_id, + Loadable::Ready(crate::federation::FedArtistCard { + name: artist_name, + peers: 1, + releases: vec![crate::federation::FedRelease { + title: "New Remote".to_string(), + release_type: "album".to_string(), + year: Some(2024), + owners: vec!["peer".to_string()], + ..Default::default() + }], + ..Default::default() + }), + ); + restore_artist_cursor_anchor(&mut state, artist_id, anchor); + + assert!(matches!( + state.global.stack.last(), + Some(GlobalView::Artist { cursor: 1, .. }) + )); + } } /// The virtual Likes playlist id (`kind == "likes"`). @@ -993,6 +1172,8 @@ pub struct AppState { pub visualizer: crate::visualizer::VisualizerState, pub global: GlobalTab, pub artist_views: HashMap>, + /// Federated card data that enriches a local artist page in-place. + pub artist_fed_views: HashMap>, pub release_views: HashMap>, pub playlists: PlaylistsTab, pub playlist_views: HashMap>, @@ -1011,10 +1192,6 @@ pub struct AppState { /// The one federated artist card being viewed (name + loading state); /// opening another card replaces it. pub fed_artist_view: Option<(String, Loadable)>, - /// The "search this artist in the federation" button of the open local - /// artist view has the focus (reached by pressing Up from the first - /// row, like the download button on a federated release). - pub artist_fed_button: bool, pub track_selection: TrackSelection, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. @@ -1105,6 +1282,296 @@ impl AppState { } } +pub fn artist_fed_card(state: &AppState, id: i64) -> Option<&crate::federation::FedArtistCard> { + match state.artist_fed_views.get(&id) { + Some(Loadable::Ready(card)) => Some(card), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtistCursorAnchor { + TopTrack(i64), + Release { + local_id: Option, + title_key: String, + }, + FeaturedTrack(i64), +} + +pub fn artist_cursor_anchor(state: &AppState, id: i64) -> Option { + let Some(GlobalView::Artist { + id: current_id, + cursor, + }) = state.global.stack.last() + else { + return None; + }; + if *current_id != id { + return None; + } + let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { + return None; + }; + if *cursor < detail.top_tracks.len() { + return detail + .top_tracks + .get(*cursor) + .map(|track| ArtistCursorAnchor::TopTrack(track.id)); + } + + let releases = artist_merged_releases(state, id, detail); + let release_order = artist_release_display_order(&releases); + let release_position = cursor.checked_sub(detail.top_tracks.len())?; + if let Some(&slot_index) = release_order.get(release_position) { + let slot = &releases[slot_index]; + let local_id = slot + .local_index + .and_then(|index| detail.releases.get(index)) + .map(|release| release.id); + return Some(ArtistCursorAnchor::Release { + local_id, + title_key: release_merge_key(&slot.title), + }); + } + + let featured_position = cursor.checked_sub(detail.top_tracks.len() + release_order.len())?; + detail + .featured_tracks + .get(featured_position) + .map(|track| ArtistCursorAnchor::FeaturedTrack(track.id)) +} + +pub fn restore_artist_cursor_anchor( + state: &mut AppState, + id: i64, + anchor: Option, +) { + let Some(anchor) = anchor else { + return; + }; + let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { + return; + }; + let releases = artist_merged_releases(state, id, detail); + let release_order = artist_release_display_order(&releases); + let next_cursor = match anchor { + ArtistCursorAnchor::TopTrack(track_id) => detail + .top_tracks + .iter() + .position(|track| track.id == track_id), + ArtistCursorAnchor::Release { + local_id, + title_key, + } => release_order + .iter() + .position(|&slot_index| { + let slot = &releases[slot_index]; + if let Some(wanted_id) = local_id { + let local_matches = slot + .local_index + .and_then(|index| detail.releases.get(index)) + .is_some_and(|release| release.id == wanted_id); + if local_matches { + return true; + } + } + release_merge_key(&slot.title) == title_key + }) + .map(|position| detail.top_tracks.len() + position), + ArtistCursorAnchor::FeaturedTrack(track_id) => detail + .featured_tracks + .iter() + .position(|track| track.id == track_id) + .map(|position| detail.top_tracks.len() + release_order.len() + position), + }; + let Some(next_cursor) = next_cursor else { + return; + }; + if let Some(GlobalView::Artist { + id: current_id, + cursor, + }) = state.global.stack.last_mut() + && *current_id == id + { + *cursor = next_cursor; + } +} + +pub fn artist_merged_releases( + state: &AppState, + id: i64, + detail: &ArtistDetail, +) -> Vec { + let mut slots: Vec = detail + .releases + .iter() + .enumerate() + .map(|(index, release)| ArtistReleaseSlot { + title: release.title.clone(), + release_type: release.release_type.clone(), + year: release.year, + cover_path: release.cover_path.clone(), + local_index: Some(index), + fed_index: None, + local_track_count: release.track_count.max(0) as usize, + total_track_count: release.track_count.max(0) as usize, + availability: release.availability, + }) + .collect(); + + if !state.global.filters.source_mode.includes_network() { + return slots; + } + + let mut by_key: HashMap = slots + .iter() + .enumerate() + .filter_map(|(slot_index, release)| { + let key = release_merge_key(&release.title); + (!key.is_empty()).then_some((key, slot_index)) + }) + .collect(); + + let Some(card) = artist_fed_card(state, id) else { + return slots; + }; + for (fed_index, release) in card.releases.iter().enumerate() { + let key = release_merge_key(&release.title); + let existing = by_key.get(&key).copied(); + let local_from_fed = release + .tracks + .iter() + .filter(|track| state.fed_card_track_local(track)) + .count(); + let fed_total = release.tracks.len(); + let local_count = existing + .and_then(|slot_index| slots.get(slot_index)) + .map(|slot| slot.local_track_count) + .unwrap_or(0) + .max(local_from_fed); + let availability = + fed_release_slot_availability(local_count, fed_total, existing.is_some()); + match existing { + Some(slot_index) => { + let slot = &mut slots[slot_index]; + slot.fed_index = Some(fed_index); + slot.release_type = prefer_text(&slot.release_type, &release.release_type); + slot.year = slot.year.or(release.year); + if slot.cover_path.is_none() { + slot.cover_path = release.cover_path.clone(); + } + slot.total_track_count = slot.total_track_count.max(fed_total); + slot.local_track_count = slot.local_track_count.max(local_from_fed); + slot.availability = availability; + } + None => { + by_key.insert(key, slots.len()); + slots.push(ArtistReleaseSlot { + title: release.title.clone(), + release_type: release.release_type.clone(), + year: release.year, + cover_path: release.cover_path.clone(), + local_index: None, + fed_index: Some(fed_index), + local_track_count: local_from_fed, + total_track_count: fed_total, + availability, + }); + } + } + } + slots +} + +pub fn fed_card_track_visible(state: &AppState, track: &crate::federation::FedCardTrack) -> bool { + state.global.filters.source_mode.includes_network() || state.fed_card_track_local(track) +} + +pub fn fed_release_visible_track_indices( + state: &AppState, + release: &crate::federation::FedRelease, +) -> Vec { + release + .tracks + .iter() + .enumerate() + .filter_map(|(index, track)| fed_card_track_visible(state, track).then_some(index)) + .collect() +} + +pub fn fed_release_visible(state: &AppState, release: &crate::federation::FedRelease) -> bool { + state.global.filters.source_mode.includes_network() + || release + .tracks + .iter() + .any(|track| state.fed_card_track_local(track)) +} + +pub fn fed_appearance_visible( + state: &AppState, + appearance: &crate::federation::FedAppearsOn, +) -> bool { + fed_card_track_visible(state, &appearance.track) +} + +pub fn fed_artist_visible_release_indices( + state: &AppState, + card: &crate::federation::FedArtistCard, +) -> Vec { + card.releases + .iter() + .enumerate() + .filter_map(|(index, release)| fed_release_visible(state, release).then_some(index)) + .collect() +} + +pub fn fed_artist_visible_appearance_indices( + state: &AppState, + card: &crate::federation::FedArtistCard, +) -> Vec { + card.appears_on + .iter() + .enumerate() + .filter_map(|(index, appearance)| { + fed_appearance_visible(state, appearance).then_some(index) + }) + .collect() +} + +fn release_merge_key(title: &str) -> String { + music_dht::normalize_name(title) +} + +fn prefer_text(left: &str, right: &str) -> String { + if left.trim().is_empty() { + right.to_string() + } else { + left.to_string() + } +} + +fn fed_release_slot_availability( + local: usize, + total: usize, + has_local_release: bool, +) -> Availability { + if total == 0 { + return if has_local_release { + Availability::Local + } else { + Availability::Remote + }; + } + if local == 0 { + Availability::Remote + } else if local >= total { + Availability::Local + } else { + Availability::Mixed + } +} + pub fn track_content_id(track: &TrackItem) -> Option { track .content_id diff --git a/src/app/update.rs b/src/app/update.rs index 7df3787..e59b875 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -5,8 +5,10 @@ use crate::library::models::TrackItem; use super::state::{ AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab, - TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows, - release_display_order, release_rows, settings_rows, track_content_id, track_key, + TrackSelectionScope, ViewMode, artist_merged_releases, artist_release_display_order, + artist_release_rows, fed_artist_visible_appearance_indices, fed_artist_visible_release_indices, + fed_release_display_order, fed_release_rows, fed_release_visible_track_indices, settings_rows, + track_content_id, track_key, }; pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500); @@ -236,7 +238,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } Action::OpenLibraryFilters => { - if state.active_tab == Tab::Global && state.global.stack.is_empty() { + if state.active_tab == Tab::Global { state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 }); } None @@ -729,10 +731,12 @@ fn selected_release_card(state: &AppState) -> Option 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); + let merged = artist_merged_releases(state, *id, detail); + let order = artist_release_display_order(&merged); order .get(position) - .map(|&index| detail.releases[index].clone()) + .and_then(|&index| merged[index].local_index) + .map(|index| detail.releases[index].clone()) } _ => None, }, @@ -805,7 +809,8 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val let Some(Loadable::Ready(detail)) = state.artist_views.get(id) else { return; }; - let flat = detail.top_tracks.len() + detail.releases.len() + value; + let releases = artist_merged_releases(state, *id, detail).len(); + let flat = detail.top_tracks.len() + releases + value; set_view_cursor(state, flat); } TrackSelectionScope::Release(id) => { @@ -838,22 +843,19 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else { return; }; - let release_count = card.releases.len(); + let release_count = fed_artist_visible_release_indices(state, card).len(); set_view_cursor(state, release_count + value); } } } fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> { - if state.artist_fed_button { - return None; - } match state.active_tab { Tab::Global => match state.global.stack.last()? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { Loadable::Ready(detail) => { let tracks = detail.top_tracks.len(); - let releases = detail.releases.len(); + let releases = artist_merged_releases(state, *id, detail).len(); if *cursor < tracks { Some((TrackSelectionScope::ArtistTop(*id), *cursor, tracks)) } else { @@ -884,7 +886,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, (relative < len).then_some((TrackSelectionScope::FedSearch, relative, len)) } GlobalView::FedRelease { index, cursor } => { - let len = fed_card_release(state, *index)?.tracks.len(); + let len = fed_release_tracks(state, *index).len(); let relative = cursor.checked_sub(1)?; (relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len)) } @@ -892,11 +894,13 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else { return None; }; - let relative = cursor.checked_sub(card.releases.len())?; - (relative < card.appears_on.len()).then_some(( + let release_count = fed_artist_visible_release_indices(state, card).len(); + let appearance_count = fed_artist_visible_appearance_indices(state, card).len(); + let relative = cursor.checked_sub(release_count)?; + (relative < appearance_count).then_some(( TrackSelectionScope::FedAppearsOn, relative, - card.appears_on.len(), + appearance_count, )) } }, @@ -920,7 +924,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, & GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { Loadable::Ready(detail) => { let tracks = detail.top_tracks.len(); - let releases = detail.releases.len(); + let releases = artist_merged_releases(state, *id, detail).len(); if *cursor < tracks { Some(( TrackSelectionScope::ArtistTop(*id), @@ -964,9 +968,6 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, & } pub fn selected_tracks(state: &AppState) -> Vec { - if state.artist_fed_button { - return Vec::new(); - } // Federated contexts produce queueable placeholders that behave like // regular tracks (queue, info, playback-on-demand). { @@ -1109,9 +1110,6 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval /// The track under the cursor in whatever view is showing tracks. pub fn selected_track(state: &AppState) -> Option { - if state.artist_fed_button { - return None; - } match state.active_tab { Tab::Global => match state.global.stack.last()? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { @@ -1120,8 +1118,9 @@ pub fn selected_track(state: &AppState) -> Option { if *cursor < tracks { detail.top_tracks.get(*cursor).cloned() } else { + let releases = artist_merged_releases(state, *id, detail).len(); cursor - .checked_sub(tracks + detail.releases.len()) + .checked_sub(tracks + releases) .and_then(|i| detail.featured_tracks.get(i).cloned()) } } @@ -1152,9 +1151,12 @@ pub fn selected_track(state: &AppState) -> Option { let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else { return None; }; - let index = cursor.checked_sub(card.releases.len())?; - card.appears_on + let release_count = fed_artist_visible_release_indices(state, card).len(); + let appearance_indices = fed_artist_visible_appearance_indices(state, card); + let index = cursor.checked_sub(release_count)?; + appearance_indices .get(index) + .and_then(|&appearance_index| card.appears_on.get(appearance_index)) .and_then(|appearance| { fed_track_from_appearance(appearance, card.own_owner.as_deref()) }) @@ -1195,8 +1197,12 @@ fn selected_release_id(state: &AppState) -> Option { 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(|&i| detail.releases[i].id) + let merged = artist_merged_releases(state, *id, detail); + let order = artist_release_display_order(&merged); + order + .get(position) + .and_then(|&i| merged[i].local_index) + .map(|i| detail.releases[i].id) } _ => None, }, @@ -1288,7 +1294,6 @@ pub(crate) fn open_artist_ref( ) -> Option { let origin = state.active_tab; state.active_tab = Tab::Global; - state.artist_fed_button = false; let effect = if artist.id >= 0 { match state.global.stack.last() { Some(GlobalView::Artist { id, .. }) if *id == artist.id => {} @@ -1511,8 +1516,9 @@ fn page_step(state: &AppState) -> isize { Some(GlobalView::Artist { id, cursor }) => { let in_release_tiles = match state.artist_views.get(id) { Some(Loadable::Ready(detail)) => { + let releases = artist_merged_releases(state, *id, detail).len(); *cursor >= detail.top_tracks.len() - && *cursor < detail.top_tracks.len() + detail.releases.len() + && *cursor < detail.top_tracks.len() + releases } _ => false, }; @@ -1527,7 +1533,9 @@ fn page_step(state: &AppState) -> isize { | Some(GlobalView::FedRelease { .. }) => lines, Some(GlobalView::FedArtist { cursor }) => { let in_release_tiles = match &state.fed_artist_view { - Some((_, Loadable::Ready(card))) => *cursor < card.releases.len(), + Some((_, Loadable::Ready(card))) => { + *cursor < fed_artist_visible_release_indices(state, card).len() + } _ => false, }; if in_release_tiles { tile_rows } else { lines } @@ -1622,22 +1630,12 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { state.track_selection.clear(); } Some(GlobalView::Artist { id, cursor }) => { - if state.artist_fed_button { - // Down leaves the federation-search button; other keys stay. - if dy > 0 { - state.artist_fed_button = false; - } - return; - } - if cursor == 0 && dy < 0 && state.federation.settings.enabled { - state.artist_fed_button = true; - return; - } let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { return; }; + let merged = artist_merged_releases(state, id, detail); let tracks = detail.top_tracks.len(); - let releases = detail.releases.len(); + let releases = merged.len(); let featured = detail.featured_tracks.len(); let total = tracks + releases + featured; if total == 0 { @@ -1654,7 +1652,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } else { // Release tiles: move by visual rows (groups break rows), // keeping the column, so Up lands on the tile above. - let rows = release_rows(&detail.releases, grid_columns()); + let rows = artist_release_rows(&merged, grid_columns()); let position = cursor - tracks; let (row, column) = rows .iter() @@ -1714,8 +1712,9 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else { return; }; - let releases = card.releases.len(); - let appears_on = card.appears_on.len(); + let release_indices = fed_artist_visible_release_indices(state, card); + let releases = release_indices.len(); + let appears_on = fed_artist_visible_appearance_indices(state, card).len(); let total = (releases + appears_on) as isize; if total == 0 { return; @@ -1724,7 +1723,11 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { let next = if !in_release_tiles { (cursor as isize + dy).clamp(0, total - 1) as usize } else { - let rows = fed_release_rows(&card.releases, grid_columns()); + let visible_releases: Vec<_> = release_indices + .iter() + .map(|&index| card.releases[index].clone()) + .collect(); + let rows = fed_release_rows(&visible_releases, grid_columns()); let (row, column) = rows .iter() .enumerate() @@ -1748,7 +1751,9 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { 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; + let tracks = fed_card_release(state, index) + .map_or(0, |r| fed_release_visible_track_indices(state, r).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); @@ -1760,7 +1765,10 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { /// Selectable rows of the open federated artist card: releases, then appearances. pub(crate) fn fed_card_len(state: &AppState) -> usize { match &state.fed_artist_view { - Some((_, Loadable::Ready(card))) => card.releases.len() + card.appears_on.len(), + Some((_, Loadable::Ready(card))) => { + fed_artist_visible_release_indices(state, card).len() + + fed_artist_visible_appearance_indices(state, card).len() + } _ => 0, } } @@ -1802,7 +1810,8 @@ fn current_view_len(state: &AppState) -> usize { None => state.global.artists.len(), Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { Some(Loadable::Ready(d)) => { - d.top_tracks.len() + d.releases.len() + d.featured_tracks.len() + let releases = artist_merged_releases(state, *id, d).len(); + d.top_tracks.len() + releases + d.featured_tracks.len() } _ => 0, }, @@ -1816,14 +1825,67 @@ 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) + Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index) + .map_or(0, |r| fed_release_visible_track_indices(state, r).len() + 1), + } +} + +pub(crate) fn apply_library_filter_change(state: &mut AppState) { + state.track_selection.clear(); + let len = current_view_len(state); + if state.active_tab == Tab::Global { + if state.global.stack.is_empty() { + state.global.selected = state.global.selected.min(len.saturating_sub(1)); + } else if let Some(view) = state.global.stack.last_mut() { + let cursor = match view { + GlobalView::Artist { cursor, .. } + | GlobalView::Release { cursor, .. } + | GlobalView::Search { cursor } + | GlobalView::FedArtist { cursor } + | GlobalView::FedRelease { cursor, .. } => cursor, + }; + *cursor = (*cursor).min(len.saturating_sub(1)); } } + + if state.global.filters.source_mode.includes_network() { + return; + } + let message = match state.global.stack.last() { + Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { + Some(Loadable::Ready(detail)) + if detail.top_tracks.is_empty() + && artist_merged_releases(state, *id, detail).is_empty() + && detail.featured_tracks.is_empty() => + { + Some(format!( + "local filter: \"{}\" is not available locally", + detail.name + )) + } + _ => None, + }, + Some(GlobalView::FedArtist { .. }) if fed_card_len(state) == 0 => { + let name = state + .fed_artist_view + .as_ref() + .map(|(name, _)| name.as_str()) + .unwrap_or("artist"); + Some(format!("local filter: \"{name}\" is not available locally")) + } + Some(GlobalView::FedRelease { index, .. }) + if fed_release_tracks(state, *index).is_empty() => + { + Some("local filter: release is not available locally".to_string()) + } + _ => None, + }; + if let Some(message) = message { + state.status_message = Some(message); + } } fn jump_selection(state: &mut AppState, first: bool) { - state.artist_fed_button = false; if state.track_selection.is_active() && let Some((scope, _, len)) = current_track_list_context(state) { @@ -1950,12 +2012,6 @@ fn select_current(state: &mut AppState) -> Option { } let outcome = match state.global.stack.last().copied() { None => match state.global.artists.get(state.global.selected) { - Some(artist) - if state.global.filters.source_mode.includes_network() - && artist.availability.is_remoteish() => - { - Outcome::OpenFedArtist(artist.name.clone()) - } Some(artist) if artist.id >= 0 => Outcome::Push(GlobalView::Artist { id: artist.id, cursor: 0, @@ -1963,41 +2019,59 @@ fn select_current(state: &mut AppState) -> Option { Some(artist) => Outcome::OpenFedArtist(artist.name.clone()), None => Outcome::Nothing, }, - Some(GlobalView::Artist { id, cursor: _ }) if state.artist_fed_button => { - match state.artist_views.get(&id) { - Some(Loadable::Ready(detail)) => { - state.artist_fed_button = false; - Outcome::OpenFedArtist(detail.name.clone()) - } - _ => Outcome::Nothing, - } - } Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) { Some(Loadable::Ready(detail)) => { let tracks = detail.top_tracks.len(); - let releases = detail.releases.len(); + let releases = artist_merged_releases(state, id, detail); + let releases_len = releases.len(); if cursor < tracks { Outcome::Play { tracks: detail.top_tracks.clone(), start: cursor, } - } else if cursor < tracks + releases { - let order = release_display_order(&detail.releases); + } else if cursor < tracks + releases_len { + let order = artist_release_display_order(&releases); match order.get(cursor - tracks) { - Some(&original) => Outcome::Push(GlobalView::Release { - id: detail.releases[original].id, - cursor: 0, - }), + Some(&original) => { + let slot = &releases[original]; + if slot.availability.is_remoteish() + && let Some(index) = slot.fed_index + && let Some(card) = + crate::app::state::artist_fed_card(state, id).cloned() + { + Outcome::OpenFedRelease { + name: detail.name.clone(), + card, + index, + } + } else if let Some(index) = slot.local_index { + Outcome::Push(GlobalView::Release { + id: detail.releases[index].id, + cursor: 0, + }) + } else if let Some(index) = slot.fed_index + && let Some(card) = + crate::app::state::artist_fed_card(state, id).cloned() + { + Outcome::OpenFedRelease { + name: detail.name.clone(), + card, + index, + } + } else { + Outcome::Nothing + } + } None => Outcome::Nothing, } } else if detail .featured_tracks - .get(cursor - tracks - releases) + .get(cursor - tracks - releases_len) .is_some() { Outcome::Play { tracks: detail.featured_tracks.clone(), - start: cursor - tracks - releases, + start: cursor - tracks - releases_len, } } else { Outcome::Nothing @@ -2038,40 +2112,53 @@ fn select_current(state: &mut AppState) -> Option { None => fed_outcome(state, cursor), }, Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view { - Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => { - let order = fed_release_display_order(&card.releases); - let Some(&release_index) = order.get(cursor) else { - return None; - }; - // Focus starts on the first track; Up from it reaches the - // download-release button (row 0). - let start = if card.releases[release_index].tracks.is_empty() { - 0 - } else { - 1 - }; - Outcome::Push(GlobalView::FedRelease { - index: release_index, - cursor: start, - }) - } Some((_, Loadable::Ready(card))) => { - let start = cursor - card.releases.len(); - if card.appears_on.get(start).is_none() { - Outcome::Nothing - } else { - let tracks: Vec<_> = card - .appears_on + let release_indices = fed_artist_visible_release_indices(state, card); + if cursor < release_indices.len() { + let visible_releases: Vec<_> = release_indices .iter() - .filter_map(|appearance| { - fed_track_from_appearance(appearance, card.own_owner.as_deref()) - }) - .map(|fed| crate::federation::pending_track(&fed)) + .map(|&index| card.releases[index].clone()) .collect(); - if tracks.is_empty() { + let order = fed_release_display_order(&visible_releases); + let Some(&visible_index) = order.get(cursor) else { + return None; + }; + let Some(&release_index) = release_indices.get(visible_index) else { + return None; + }; + // Focus starts on the first track; Up from it reaches the + // download-release button (row 0). + let start = + if fed_release_visible_track_indices(state, &card.releases[release_index]) + .is_empty() + { + 0 + } else { + 1 + }; + Outcome::Push(GlobalView::FedRelease { + index: release_index, + cursor: start, + }) + } else { + let appearance_indices = fed_artist_visible_appearance_indices(state, card); + let start = cursor - release_indices.len(); + if appearance_indices.get(start).is_none() { Outcome::Nothing } else { - Outcome::Play { tracks, start } + let tracks: Vec<_> = appearance_indices + .iter() + .filter_map(|&appearance_index| card.appears_on.get(appearance_index)) + .filter_map(|appearance| { + fed_track_from_appearance(appearance, card.own_owner.as_deref()) + }) + .map(|fed| crate::federation::pending_track(&fed)) + .collect(); + if tracks.is_empty() { + Outcome::Nothing + } else { + Outcome::Play { tracks, start } + } } } } @@ -2106,7 +2193,6 @@ fn select_current(state: &mut AppState) -> Option { }; match outcome { Outcome::Push(view) => { - state.artist_fed_button = false; state.global.stack.push(view); None } @@ -2122,6 +2208,23 @@ fn select_current(state: &mut AppState) -> Option { state.active_tab = Tab::Global; Some(Effect::FedOpenArtist(name)) } + Outcome::OpenFedRelease { name, card, index } => { + let start = + if card.releases.get(index).is_some_and(|release| { + fed_release_visible_track_indices(state, release).is_empty() + }) { + 0 + } else { + 1 + }; + state.fed_artist_view = Some((name, Loadable::Ready(card))); + state.global.stack.push(GlobalView::FedRelease { + index, + cursor: start, + }); + state.active_tab = Tab::Global; + None + } Outcome::DownloadFed(tracks) => { state.status_message = Some(format!( "federation: downloading {} track(s) to the library…", @@ -2144,7 +2247,7 @@ pub(crate) fn fed_card_release( } } -/// Every track of one card release as playable FedTracks. +/// Every visible track of one card release as playable FedTracks. pub(crate) fn fed_release_tracks( state: &AppState, index: usize, @@ -2158,6 +2261,7 @@ pub(crate) fn fed_release_tracks( release .tracks .iter() + .filter(|track| crate::app::state::fed_card_track_visible(state, track)) .filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref())) .collect() } @@ -2168,6 +2272,7 @@ pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec Vec), Nothing, } @@ -2622,7 +2733,6 @@ fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) { /// Esc/Backspace: pop the navigation stack; leaving a search view resets the /// search so the next `:/` starts clean. fn go_back(state: &mut AppState) { - state.artist_fed_button = false; state.track_selection.clear(); match state.active_tab { Tab::Playlists => { @@ -2653,7 +2763,6 @@ fn go_back(state: &mut AppState) { } fn switch_tab(state: &mut AppState, tab: Tab) { - state.artist_fed_button = false; state.active_tab = tab; state.help_visible = false; state.track_selection.clear(); @@ -2787,7 +2896,7 @@ mod tests { } #[test] - fn library_filters_popup_opens_only_on_root() { + fn library_filters_popup_opens_on_library_screens() { let mut state = AppState::default(); update(&mut state, Action::OpenLibraryFilters); assert!(matches!( @@ -2798,6 +2907,14 @@ mod tests { state.popup = None; state.global.stack.push(GlobalView::Search { cursor: 0 }); update(&mut state, Action::OpenLibraryFilters); + assert!(matches!( + state.popup, + Some(crate::app::state::Popup::LibraryFilters { .. }) + )); + + state.popup = None; + state.active_tab = Tab::Queue; + update(&mut state, Action::OpenLibraryFilters); assert!(state.popup.is_none()); } diff --git a/src/ui/global.rs b/src/ui/global.rs index 7e9b6e8..2aa8f9f 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -8,8 +8,10 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::{art, availability_marker, availability_prefix, theme}; use crate::app::state::{ ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState, - GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, fed_release_display_order, - fed_release_groups, release_groups, + GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, artist_merged_releases, + artist_release_display_order, artist_release_groups, fed_artist_visible_appearance_indices, + fed_artist_visible_release_indices, fed_release_display_order, fed_release_groups, + fed_release_visible_track_indices, }; use crate::art::cache_key; use crate::library::models::{ArtistCard, Availability, ReleaseCard, SearchResults}; @@ -497,12 +499,43 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, - header_art(state, detail.image_path.as_ref()), + header_art( + state, + detail.image_path.as_ref().or_else(|| { + crate::app::state::artist_fed_card(state, id) + .and_then(|card| card.image_path.as_ref()) + }), + ), ); - let mut about = format!("{} releases", detail.releases.len()); + let merged_releases = artist_merged_releases(state, id, detail); + let mut about = format!("{} releases", merged_releases.len()); if !detail.featured_tracks.is_empty() { about.push_str(&format!(" · appears on {}", detail.featured_tracks.len())); } + let federation_line = match state.artist_fed_views.get(&id) { + Some(Loadable::Loading) => Some(super::loading_line(state, "searching peers…")), + Some(Loadable::Ready(card)) => { + let tracks: usize = card + .releases + .iter() + .map(|release| release.tracks.len()) + .sum(); + Some(Line::styled( + format!( + "federation: {} releases · {} tracks · {} peers", + card.releases.len(), + tracks, + card.peers + ), + theme::dim(), + )) + } + Some(Loadable::Failed(_)) => Some(Line::styled("federation: unavailable", theme::dim())), + None if state.federation.settings.enabled => { + Some(Line::styled("federation: pending", theme::dim())) + } + None => None, + }; let mut info = vec![ Line::default(), Line::styled(detail.name.clone(), theme::header()), @@ -516,25 +549,15 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ), Line::styled(about, theme::dim()), ]; - if state.federation.settings.enabled { - // Reached with Up from the first row, like the download button on - // a federated release. - info.push(Line::default()); - info.push(Line::styled( - " ⌕ Search this artist in the federation ", - if state.artist_fed_button { - theme::tab_active() - } else { - theme::accent() - }, - )); + if let Some(line) = federation_line { + info.push(line); } frame.render_widget(Paragraph::new(info), info_area); // Scrollable content: top tracks, releases grouped by type, then the // tracks this artist is featured on. let tracks = detail.top_tracks.len(); - let releases_len = detail.releases.len(); + let releases_len = merged_releases.len(); let featured_len = detail.featured_tracks.len(); let mut items = Vec::new(); let mut cursor_item = None; @@ -559,7 +582,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: } let columns = usize::from((content_area.width / TILE_WIDTH).max(1)); let mut position = 0; - for (label, group) in release_groups(&detail.releases) { + for (label, group) in artist_release_groups(&merged_releases) { items.push(PlanItem::Header(format!("{label} ({})", group.len()))); match state.global.view { ViewMode::Tiles => { @@ -596,7 +619,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: items.push(PlanItem::Gap); } - let display_order = crate::app::state::release_display_order(&detail.releases); + let display_order = artist_release_display_order(&merged_releases); render_plan( frame, content_area, @@ -637,7 +660,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: } PlanItem::TileRow(row) => { for (column, position) in row.iter().enumerate() { - let release = &detail.releases[display_order[*position]]; + let release = &merged_releases[display_order[*position]]; let tile = Rect { x: rect.x + column as u16 * TILE_WIDTH, y: rect.y, @@ -653,14 +676,14 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: tile, tile_art(state, release.cover_path.as_ref()), &release.title, - &release_tile_meta(release), + &artist_release_tile_meta(release), cursor == tracks + position, Some(release.availability), ); } } PlanItem::TableRow(position) => { - let release = &detail.releases[display_order[*position]]; + let release = &merged_releases[display_order[*position]]; let year = release.year.map(|y| y.to_string()).unwrap_or_default(); draw_row( frame, @@ -669,7 +692,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: Span::raw(release.title.clone()), Span::styled(format!(" {year}"), theme::dim()), ]), - Some(format!("{} trk", release.track_count)), + Some(artist_release_count_meta(release)), cursor == tracks + position, ); } @@ -678,6 +701,25 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ); } +fn artist_release_tile_meta(release: &crate::app::state::ArtistReleaseSlot) -> String { + match release.year { + Some(year) => format!("{year} · {}", artist_release_count_meta(release)), + None => artist_release_count_meta(release), + } +} + +fn artist_release_count_meta(release: &crate::app::state::ArtistReleaseSlot) -> String { + match release.availability { + Availability::Mixed => format!( + "{}/{} local", + release.local_track_count.min(release.total_track_count), + release.total_track_count + ), + Availability::Remote => format!("{} network", release.total_track_count), + Availability::Local => format!("{} trk", release.total_track_count), + } +} + fn release_tile_meta(release: &ReleaseCard) -> String { match release.year { Some(year) => format!("{year} · {} trk", release.track_count), @@ -1060,17 +1102,30 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz }, header_art(state, card.image_path.as_ref()), ); - let release_tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum(); - let appears_on_len = card.appears_on.len(); + let release_indices = fed_artist_visible_release_indices(state, card); + let appearance_indices = fed_artist_visible_appearance_indices(state, card); + let visible_releases: Vec<_> = release_indices + .iter() + .map(|&index| card.releases[index].clone()) + .collect(); + let release_tracks_total: usize = visible_releases + .iter() + .map(|release| fed_release_visible_track_indices(state, release).len()) + .sum(); + let appears_on_len = appearance_indices.len(); let mut stats = format!( "{} releases · {} tracks", - card.releases.len(), + visible_releases.len(), release_tracks_total ); if appears_on_len > 0 { stats.push_str(&format!(" · appears on {appears_on_len}")); } - stats.push_str(&format!(" · from {} peers", card.peers)); + if state.global.filters.source_mode.includes_network() { + stats.push_str(&format!(" · from {} peers", card.peers)); + } else { + stats.push_str(" · local only"); + } let info = vec![ Line::default(), Line::styled(name.clone(), theme::header()), @@ -1080,24 +1135,22 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz ]; frame.render_widget(Paragraph::new(info), info_area); - if card.releases.is_empty() && card.appears_on.is_empty() { - return centered_line( - frame, - content_area, - Line::styled( - "the peers returned no releases or appearances", - theme::dim(), - ), - ); + if visible_releases.is_empty() && appearance_indices.is_empty() { + let message = if state.global.filters.source_mode.includes_network() { + "the peers returned no releases or appearances" + } else { + "artist is not available locally" + }; + return centered_line(frame, content_area, Line::styled(message, theme::dim())); } // Release tiles grouped by type, then featured appearances as tracks. let columns = usize::from((content_area.width / TILE_WIDTH).max(1)); - let releases_len = card.releases.len(); + let releases_len = visible_releases.len(); let mut items = Vec::new(); let mut cursor_item = None; let mut position = 0; - for (label, group) in fed_release_groups(&card.releases) { + for (label, group) in fed_release_groups(&visible_releases) { items.push(PlanItem::Header(format!("{label} ({})", group.len()))); for chunk in group.chunks(columns) { let row: Vec = (position..position + chunk.len()).collect(); @@ -1121,7 +1174,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz items.push(PlanItem::Gap); } - let display_order = fed_release_display_order(&card.releases); + let display_order = fed_release_display_order(&visible_releases); let appears_scope = crate::app::state::TrackSelectionScope::FedAppearsOn; render_plan( frame, @@ -1132,7 +1185,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz &mut |frame, rect, item| match item { PlanItem::TileRow(row) => { for (column, position) in row.iter().enumerate() { - let release = &card.releases[display_order[*position]]; + let release = &visible_releases[display_order[*position]]; let tile = Rect { x: rect.x + column as u16 * TILE_WIDTH, y: rect.y, @@ -1160,7 +1213,10 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz } PlanItem::Track { cursor_index } => { let index = cursor_index - releases_len; - let Some(appearance) = card.appears_on.get(index) else { + let Some(&appearance_index) = appearance_indices.get(index) else { + return; + }; + let Some(appearance) = card.appears_on.get(appearance_index) else { return; }; draw_fed_appearance_row( @@ -1307,47 +1363,72 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz if let Some(year) = release.year { meta.push_str(&format!(" · {year}")); } - meta.push_str(&format!( - " · {} tracks · from {} peers", - release.tracks.len(), - release.owners.len().max(1) - )); + let visible_track_indices = fed_release_visible_track_indices(state, release); + let track_count = visible_track_indices.len(); + if state.global.filters.source_mode.includes_network() { + meta.push_str(&format!( + " · {} tracks · from {} peers", + track_count, + release.owners.len().max(1) + )); + } else { + meta.push_str(&format!(" · {track_count} tracks · local only")); + } let button_style = if cursor == 0 { theme::tab_active() } else { theme::accent() }; + let action_line = if state.global.filters.source_mode.includes_network() { + Line::styled( + format!(" ⤓ Download the whole release ({track_count}) "), + button_style, + ) + } else { + Line::styled(" Local filter is active ", button_style) + }; let info = vec![ Line::default(), Line::styled(release.title.clone(), theme::header()), Line::styled(meta, theme::dim()), Line::default(), + action_line, Line::styled( - format!(" ⤓ Download the whole release ({}) ", release.tracks.len()), - button_style, - ), - Line::styled( - "shift+v: select · y: download · p: add to playlist", + if state.global.filters.source_mode.includes_network() { + "shift+v: select · y: download · p: add to playlist" + } else { + "shift+v: select · enter: play · p: add to playlist" + }, theme::dim(), ), ]; frame.render_widget(Paragraph::new(info), info_area); // Tracklist: rows 1..=n of the cursor space. + if visible_track_indices.is_empty() { + let message = if state.global.filters.source_mode.includes_network() { + "the peers returned no tracks" + } else { + "release is not available locally" + }; + return centered_line(frame, content_area, Line::styled(message, theme::dim())); + } 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 + .min(visible_track_indices.len().saturating_sub(visible)); + for (offset, (position, track_index)) in visible_track_indices .iter() .enumerate() .skip(first) .take(visible) .enumerate() { + let Some(track) = release.tracks.get(*track_index) else { + continue; + }; let rect = Rect { x: content_area.x, y: content_area.y + offset as u16, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1c4a51a..51fa4ff 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,7 +13,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Tabs}; use crate::app::input::LineEdit; -use crate::app::state::{AppState, Tab, TrackSelectionScope}; +use crate::app::state::{AppState, GlobalView, Loadable, Tab, TrackSelectionScope}; use crate::config::keymap::Keymap; use crate::library::models::Availability; @@ -446,12 +446,15 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { Span::styled(message.clone(), theme::accent()), ]), Some(message) => Line::styled(message.clone(), theme::accent()), - None => match &state.player.current { - // Idle line doubles as the current track's tech data display. - Some(track) if state.player.playing && !track.tech_label_full().is_empty() => { - Line::styled(track.tech_label_full(), theme::dim()) - } - _ => Line::styled("press ? for keybindings", theme::dim()), + None => match active_artist_peer_search(state) { + Some(message) => loading_line(state, message), + None => match &state.player.current { + // Idle line doubles as the current track's tech data display. + Some(track) if state.player.playing && !track.tech_label_full().is_empty() => { + Line::styled(track.tech_label_full(), theme::dim()) + } + _ => Line::styled("press ? for keybindings", theme::dim()), + }, }, }; frame.render_widget(Paragraph::new(message), message_row); @@ -464,6 +467,23 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { } } +fn active_artist_peer_search(state: &AppState) -> Option { + if state.active_tab != Tab::Global { + return None; + } + let Some(GlobalView::Artist { id, .. }) = state.global.stack.last() else { + return None; + }; + if !matches!(state.artist_fed_views.get(id), Some(Loadable::Loading)) { + return None; + } + let name = match state.artist_views.get(id) { + Some(Loadable::Ready(detail)) => detail.name.as_str(), + _ => "artist", + }; + Some(format!("searching peers for \"{name}\"…")) +} + fn draw_version(frame: &mut Frame, area: Rect) { let version = format!("v{}", env!("CARGO_PKG_VERSION")); frame.render_widget(