Improved fiters. reworked Trak wrapper.
This commit is contained in:
@@ -113,6 +113,12 @@ pub enum AppEvent {
|
||||
name: String,
|
||||
result: Result<crate::federation::FedArtistCard, String>,
|
||||
},
|
||||
/// Federated enrichment for an already-open local artist view.
|
||||
ArtistFederationLoaded {
|
||||
id: i64,
|
||||
name: String,
|
||||
result: Result<crate::federation::FedArtistCard, String>,
|
||||
},
|
||||
/// A network-library source refreshed its cached top-artist slice.
|
||||
NetworkArtistCacheUpdated {
|
||||
source_id: String,
|
||||
|
||||
+103
-4
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 }),
|
||||
}
|
||||
|
||||
+510
-43
@@ -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<usize>)> {
|
||||
release_type_groups(
|
||||
releases,
|
||||
|release| &release.release_type,
|
||||
|release| release.year,
|
||||
|release| &release.title,
|
||||
)
|
||||
}
|
||||
|
||||
fn release_type_groups<T>(
|
||||
items: &[T],
|
||||
release_type: impl Fn(&T) -> &str,
|
||||
@@ -183,21 +170,6 @@ fn sort_release_indices<T>(
|
||||
});
|
||||
}
|
||||
|
||||
/// Flattened display order of releases (concatenated groups).
|
||||
pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
|
||||
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<Vec<usize>> {
|
||||
grouped_release_rows(release_groups(releases), columns)
|
||||
}
|
||||
|
||||
pub fn fed_release_groups(
|
||||
releases: &[crate::federation::FedRelease],
|
||||
) -> Vec<(&'static str, Vec<usize>)> {
|
||||
@@ -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<i32>,
|
||||
pub cover_path: Option<String>,
|
||||
pub local_index: Option<usize>,
|
||||
pub fed_index: Option<usize>,
|
||||
pub local_track_count: usize,
|
||||
pub total_track_count: usize,
|
||||
pub availability: Availability,
|
||||
}
|
||||
|
||||
pub fn artist_release_groups(releases: &[ArtistReleaseSlot]) -> Vec<(&'static str, Vec<usize>)> {
|
||||
release_type_groups(
|
||||
releases,
|
||||
|release| &release.release_type,
|
||||
|release| release.year,
|
||||
|release| &release.title,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn artist_release_display_order(releases: &[ArtistReleaseSlot]) -> Vec<usize> {
|
||||
artist_release_groups(releases)
|
||||
.into_iter()
|
||||
.flat_map(|(_, indices)| indices)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn artist_release_rows(releases: &[ArtistReleaseSlot], columns: usize) -> Vec<Vec<usize>> {
|
||||
grouped_release_rows(artist_release_groups(releases), columns)
|
||||
}
|
||||
|
||||
fn grouped_release_rows(
|
||||
groups: Vec<(&'static str, Vec<usize>)>,
|
||||
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<i32>) -> ReleaseCard {
|
||||
ReleaseCard {
|
||||
id,
|
||||
fn release(title: &str, release_type: &str, year: Option<i32>) -> 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<i64, Loadable<ArtistDetail>>,
|
||||
/// Federated card data that enriches a local artist page in-place.
|
||||
pub artist_fed_views: HashMap<i64, Loadable<crate::federation::FedArtistCard>>,
|
||||
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||
pub playlists: PlaylistsTab,
|
||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||
@@ -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<crate::federation::FedArtistCard>)>,
|
||||
/// 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<i64>,
|
||||
title_key: String,
|
||||
},
|
||||
FeaturedTrack(i64),
|
||||
}
|
||||
|
||||
pub fn artist_cursor_anchor(state: &AppState, id: i64) -> Option<ArtistCursorAnchor> {
|
||||
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<ArtistCursorAnchor>,
|
||||
) {
|
||||
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<ArtistReleaseSlot> {
|
||||
let mut slots: Vec<ArtistReleaseSlot> = 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<String, usize> = 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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
track
|
||||
.content_id
|
||||
|
||||
+228
-111
@@ -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<Effect> {
|
||||
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<crate::library::models::Rel
|
||||
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);
|
||||
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<TrackItem> {
|
||||
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<TrackItem> {
|
||||
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<TrackItem> {
|
||||
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<TrackItem> {
|
||||
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<i64> {
|
||||
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<Effect> {
|
||||
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<Effect> {
|
||||
}
|
||||
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<Effect> {
|
||||
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<Effect> {
|
||||
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<Effect> {
|
||||
};
|
||||
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<Effect> {
|
||||
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<crate::federation::
|
||||
};
|
||||
card.appears_on
|
||||
.iter()
|
||||
.filter(|appearance| crate::app::state::fed_appearance_visible(state, appearance))
|
||||
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
|
||||
.collect()
|
||||
}
|
||||
@@ -2234,8 +2339,9 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return Vec::new();
|
||||
};
|
||||
let release_count = fed_artist_visible_release_indices(state, card).len();
|
||||
cursor
|
||||
.checked_sub(card.releases.len())
|
||||
.checked_sub(release_count)
|
||||
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
|
||||
.into_iter()
|
||||
.collect()
|
||||
@@ -2329,6 +2435,11 @@ enum Outcome {
|
||||
start: usize,
|
||||
},
|
||||
OpenFedArtist(String),
|
||||
OpenFedRelease {
|
||||
name: String,
|
||||
card: crate::federation::FedArtistCard,
|
||||
index: usize,
|
||||
},
|
||||
DownloadFed(Vec<crate::federation::FedTrack>),
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
+135
-54
@@ -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<usize> = (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,
|
||||
|
||||
+27
-7
@@ -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<String> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user