Improved fiters. reworked Trak wrapper.

This commit is contained in:
Ultradesu
2026-07-25 23:57:03 +03:00
parent c45f2e700b
commit f80e10e6ad
7 changed files with 1010 additions and 220 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(