Improved fiters. reworked Trak wrapper.
This commit is contained in:
@@ -113,6 +113,12 @@ pub enum AppEvent {
|
|||||||
name: String,
|
name: String,
|
||||||
result: Result<crate::federation::FedArtistCard, 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.
|
/// A network-library source refreshed its cached top-artist slice.
|
||||||
NetworkArtistCacheUpdated {
|
NetworkArtistCacheUpdated {
|
||||||
source_id: String,
|
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) {
|
fn spawn_content_id_backfill(runtime: &Runtime) {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let federation = Arc::clone(&runtime.federation);
|
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_refresh_network_library(state, runtime);
|
||||||
maybe_fetch_network_artist_images(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.
|
// Liked ids load once per session — markers are shown everywhere.
|
||||||
if !state.likes_loaded {
|
if !state.likes_loaded {
|
||||||
@@ -916,6 +973,19 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
wanted.push((path.clone(), header.0, header.1));
|
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((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
|
||||||
if let Some(path) = &card.image_path {
|
if let Some(path) = &card.image_path {
|
||||||
wanted.push((path.clone(), header.0, header.1));
|
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 } => {
|
AppEvent::NetworkArtistCacheUpdated { source_id, count } => {
|
||||||
tracing::debug!(source = %source_id, count, "network artist cache updated");
|
tracing::debug!(source = %source_id, count, "network artist cache updated");
|
||||||
if state.active_tab == state::Tab::Global && state.global.stack.is_empty() {
|
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
|
if let Some((current, state::Loadable::Ready(card))) = &mut state.fed_artist_view
|
||||||
&& *current == name
|
&& *current == name
|
||||||
{
|
{
|
||||||
match release {
|
match &release {
|
||||||
None => card.image_path = Some(path),
|
None => card.image_path = Some(path.clone()),
|
||||||
Some(title) => {
|
Some(title) => {
|
||||||
if let Some(slot) = card.releases.iter_mut().find(|r| r.title == title) {
|
if let Some(slot) = card.releases.iter_mut().find(|r| r.title == *title) {
|
||||||
slot.cover_path = Some(path);
|
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);
|
state.artist_views.insert(id, entry);
|
||||||
|
maybe_enrich_open_artist(state, runtime);
|
||||||
}
|
}
|
||||||
AppEvent::ReleaseViewLoaded { id, result } => {
|
AppEvent::ReleaseViewLoaded { id, result } => {
|
||||||
let entry = match result {
|
let entry = match result {
|
||||||
|
|||||||
+1
-1
@@ -267,7 +267,7 @@ fn handle_library_filters(
|
|||||||
super::save_app_settings(state);
|
super::save_app_settings(state);
|
||||||
super::reset_artist_pagination(state);
|
super::reset_artist_pagination(state);
|
||||||
super::refresh_artists(state, runtime);
|
super::refresh_artists(state, runtime);
|
||||||
state.popup = Some(Popup::LibraryFilters { cursor });
|
super::update::apply_library_filter_change(state);
|
||||||
}
|
}
|
||||||
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
|
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
|
||||||
}
|
}
|
||||||
|
|||||||
+510
-43
@@ -5,7 +5,7 @@ use crate::app::input::LineEdit;
|
|||||||
use crate::art::ArtImage;
|
use crate::art::ArtImage;
|
||||||
use crate::config::keymap::KeyContext;
|
use crate::config::keymap::KeyContext;
|
||||||
use crate::library::models::{
|
use crate::library::models::{
|
||||||
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
ArtistCard, ArtistDetail, Availability, PlaylistCard, PlaylistDetail, ReleaseDetail,
|
||||||
SearchResults, TrackItem,
|
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>(
|
fn release_type_groups<T>(
|
||||||
items: &[T],
|
items: &[T],
|
||||||
release_type: impl Fn(&T) -> &str,
|
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(
|
pub fn fed_release_groups(
|
||||||
releases: &[crate::federation::FedRelease],
|
releases: &[crate::federation::FedRelease],
|
||||||
) -> Vec<(&'static str, Vec<usize>)> {
|
) -> Vec<(&'static str, Vec<usize>)> {
|
||||||
@@ -225,6 +197,39 @@ pub fn fed_release_rows(
|
|||||||
grouped_release_rows(fed_release_groups(releases), columns)
|
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(
|
fn grouped_release_rows(
|
||||||
groups: Vec<(&'static str, Vec<usize>)>,
|
groups: Vec<(&'static str, Vec<usize>)>,
|
||||||
columns: usize,
|
columns: usize,
|
||||||
@@ -245,14 +250,16 @@ fn grouped_release_rows(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn release(id: i64, title: &str, release_type: &str, year: Option<i32>) -> ReleaseCard {
|
fn release(title: &str, release_type: &str, year: Option<i32>) -> ArtistReleaseSlot {
|
||||||
ReleaseCard {
|
ArtistReleaseSlot {
|
||||||
id,
|
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
release_type: release_type.to_string(),
|
release_type: release_type.to_string(),
|
||||||
year,
|
year,
|
||||||
cover_path: None,
|
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,
|
availability: crate::library::models::Availability::Local,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,14 +280,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn release_display_order_is_newest_first_within_each_type() {
|
fn release_display_order_is_newest_first_within_each_type() {
|
||||||
let releases = vec![
|
let releases = vec![
|
||||||
release(1, "Old Album", "album", Some(1991)),
|
release("Old Album", "album", Some(1991)),
|
||||||
release(2, "New Single", "single", Some(2024)),
|
release("New Single", "single", Some(2024)),
|
||||||
release(3, "New Album", "album", Some(2020)),
|
release("New Album", "album", Some(2020)),
|
||||||
release(4, "Undated Album", "album", None),
|
release("Undated Album", "album", None),
|
||||||
release(5, "Old Single", "single", Some(1999)),
|
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]
|
#[test]
|
||||||
@@ -295,6 +302,178 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(fed_release_display_order(&releases), vec![2, 0, 3, 1, 4]);
|
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"`).
|
/// The virtual Likes playlist id (`kind == "likes"`).
|
||||||
@@ -993,6 +1172,8 @@ pub struct AppState {
|
|||||||
pub visualizer: crate::visualizer::VisualizerState,
|
pub visualizer: crate::visualizer::VisualizerState,
|
||||||
pub global: GlobalTab,
|
pub global: GlobalTab,
|
||||||
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
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 release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||||
pub playlists: PlaylistsTab,
|
pub playlists: PlaylistsTab,
|
||||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||||
@@ -1011,10 +1192,6 @@ pub struct AppState {
|
|||||||
/// The one federated artist card being viewed (name + loading state);
|
/// The one federated artist card being viewed (name + loading state);
|
||||||
/// opening another card replaces it.
|
/// opening another card replaces it.
|
||||||
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
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,
|
pub track_selection: TrackSelection,
|
||||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||||
/// view finishes loading.
|
/// 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> {
|
pub fn track_content_id(track: &TrackItem) -> Option<String> {
|
||||||
track
|
track
|
||||||
.content_id
|
.content_id
|
||||||
|
|||||||
+228
-111
@@ -5,8 +5,10 @@ use crate::library::models::TrackItem;
|
|||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||||
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
|
TrackSelectionScope, ViewMode, artist_merged_releases, artist_release_display_order,
|
||||||
release_display_order, release_rows, settings_rows, track_content_id, track_key,
|
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);
|
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
|
None
|
||||||
}
|
}
|
||||||
Action::OpenLibraryFilters => {
|
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 });
|
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
|
||||||
}
|
}
|
||||||
None
|
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)? {
|
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||||
Loadable::Ready(detail) => {
|
Loadable::Ready(detail) => {
|
||||||
let position = cursor.checked_sub(detail.top_tracks.len())?;
|
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
|
order
|
||||||
.get(position)
|
.get(position)
|
||||||
.map(|&index| detail.releases[index].clone())
|
.and_then(|&index| merged[index].local_index)
|
||||||
|
.map(|index| detail.releases[index].clone())
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => 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 {
|
let Some(Loadable::Ready(detail)) = state.artist_views.get(id) else {
|
||||||
return;
|
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);
|
set_view_cursor(state, flat);
|
||||||
}
|
}
|
||||||
TrackSelectionScope::Release(id) => {
|
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 {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
return;
|
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);
|
set_view_cursor(state, release_count + value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> {
|
fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> {
|
||||||
if state.artist_fed_button {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match state.active_tab {
|
match state.active_tab {
|
||||||
Tab::Global => match state.global.stack.last()? {
|
Tab::Global => match state.global.stack.last()? {
|
||||||
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||||
Loadable::Ready(detail) => {
|
Loadable::Ready(detail) => {
|
||||||
let tracks = detail.top_tracks.len();
|
let tracks = detail.top_tracks.len();
|
||||||
let releases = detail.releases.len();
|
let releases = artist_merged_releases(state, *id, detail).len();
|
||||||
if *cursor < tracks {
|
if *cursor < tracks {
|
||||||
Some((TrackSelectionScope::ArtistTop(*id), *cursor, tracks))
|
Some((TrackSelectionScope::ArtistTop(*id), *cursor, tracks))
|
||||||
} else {
|
} else {
|
||||||
@@ -884,7 +886,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
(relative < len).then_some((TrackSelectionScope::FedSearch, relative, len))
|
(relative < len).then_some((TrackSelectionScope::FedSearch, relative, len))
|
||||||
}
|
}
|
||||||
GlobalView::FedRelease { index, cursor } => {
|
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)?;
|
let relative = cursor.checked_sub(1)?;
|
||||||
(relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
|
(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 {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let relative = cursor.checked_sub(card.releases.len())?;
|
let release_count = fed_artist_visible_release_indices(state, card).len();
|
||||||
(relative < card.appears_on.len()).then_some((
|
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,
|
TrackSelectionScope::FedAppearsOn,
|
||||||
relative,
|
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)? {
|
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||||
Loadable::Ready(detail) => {
|
Loadable::Ready(detail) => {
|
||||||
let tracks = detail.top_tracks.len();
|
let tracks = detail.top_tracks.len();
|
||||||
let releases = detail.releases.len();
|
let releases = artist_merged_releases(state, *id, detail).len();
|
||||||
if *cursor < tracks {
|
if *cursor < tracks {
|
||||||
Some((
|
Some((
|
||||||
TrackSelectionScope::ArtistTop(*id),
|
TrackSelectionScope::ArtistTop(*id),
|
||||||
@@ -964,9 +968,6 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
||||||
if state.artist_fed_button {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
// Federated contexts produce queueable placeholders that behave like
|
// Federated contexts produce queueable placeholders that behave like
|
||||||
// regular tracks (queue, info, playback-on-demand).
|
// 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.
|
/// The track under the cursor in whatever view is showing tracks.
|
||||||
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||||
if state.artist_fed_button {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match state.active_tab {
|
match state.active_tab {
|
||||||
Tab::Global => match state.global.stack.last()? {
|
Tab::Global => match state.global.stack.last()? {
|
||||||
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
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 {
|
if *cursor < tracks {
|
||||||
detail.top_tracks.get(*cursor).cloned()
|
detail.top_tracks.get(*cursor).cloned()
|
||||||
} else {
|
} else {
|
||||||
|
let releases = artist_merged_releases(state, *id, detail).len();
|
||||||
cursor
|
cursor
|
||||||
.checked_sub(tracks + detail.releases.len())
|
.checked_sub(tracks + releases)
|
||||||
.and_then(|i| detail.featured_tracks.get(i).cloned())
|
.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 {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let index = cursor.checked_sub(card.releases.len())?;
|
let release_count = fed_artist_visible_release_indices(state, card).len();
|
||||||
card.appears_on
|
let appearance_indices = fed_artist_visible_appearance_indices(state, card);
|
||||||
|
let index = cursor.checked_sub(release_count)?;
|
||||||
|
appearance_indices
|
||||||
.get(index)
|
.get(index)
|
||||||
|
.and_then(|&appearance_index| card.appears_on.get(appearance_index))
|
||||||
.and_then(|appearance| {
|
.and_then(|appearance| {
|
||||||
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
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)? {
|
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||||
Loadable::Ready(detail) => {
|
Loadable::Ready(detail) => {
|
||||||
let position = cursor.checked_sub(detail.top_tracks.len())?;
|
let position = cursor.checked_sub(detail.top_tracks.len())?;
|
||||||
let order = release_display_order(&detail.releases);
|
let merged = artist_merged_releases(state, *id, detail);
|
||||||
order.get(position).map(|&i| detail.releases[i].id)
|
let order = artist_release_display_order(&merged);
|
||||||
|
order
|
||||||
|
.get(position)
|
||||||
|
.and_then(|&i| merged[i].local_index)
|
||||||
|
.map(|i| detail.releases[i].id)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
@@ -1288,7 +1294,6 @@ pub(crate) fn open_artist_ref(
|
|||||||
) -> Option<Effect> {
|
) -> Option<Effect> {
|
||||||
let origin = state.active_tab;
|
let origin = state.active_tab;
|
||||||
state.active_tab = Tab::Global;
|
state.active_tab = Tab::Global;
|
||||||
state.artist_fed_button = false;
|
|
||||||
let effect = if artist.id >= 0 {
|
let effect = if artist.id >= 0 {
|
||||||
match state.global.stack.last() {
|
match state.global.stack.last() {
|
||||||
Some(GlobalView::Artist { id, .. }) if *id == artist.id => {}
|
Some(GlobalView::Artist { id, .. }) if *id == artist.id => {}
|
||||||
@@ -1511,8 +1516,9 @@ fn page_step(state: &AppState) -> isize {
|
|||||||
Some(GlobalView::Artist { id, cursor }) => {
|
Some(GlobalView::Artist { id, cursor }) => {
|
||||||
let in_release_tiles = match state.artist_views.get(id) {
|
let in_release_tiles = match state.artist_views.get(id) {
|
||||||
Some(Loadable::Ready(detail)) => {
|
Some(Loadable::Ready(detail)) => {
|
||||||
|
let releases = artist_merged_releases(state, *id, detail).len();
|
||||||
*cursor >= detail.top_tracks.len()
|
*cursor >= detail.top_tracks.len()
|
||||||
&& *cursor < detail.top_tracks.len() + detail.releases.len()
|
&& *cursor < detail.top_tracks.len() + releases
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -1527,7 +1533,9 @@ fn page_step(state: &AppState) -> isize {
|
|||||||
| Some(GlobalView::FedRelease { .. }) => lines,
|
| Some(GlobalView::FedRelease { .. }) => lines,
|
||||||
Some(GlobalView::FedArtist { cursor }) => {
|
Some(GlobalView::FedArtist { cursor }) => {
|
||||||
let in_release_tiles = match &state.fed_artist_view {
|
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,
|
_ => false,
|
||||||
};
|
};
|
||||||
if in_release_tiles { tile_rows } else { lines }
|
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();
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
Some(GlobalView::Artist { id, cursor }) => {
|
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 {
|
let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let merged = artist_merged_releases(state, id, detail);
|
||||||
let tracks = detail.top_tracks.len();
|
let tracks = detail.top_tracks.len();
|
||||||
let releases = detail.releases.len();
|
let releases = merged.len();
|
||||||
let featured = detail.featured_tracks.len();
|
let featured = detail.featured_tracks.len();
|
||||||
let total = tracks + releases + featured;
|
let total = tracks + releases + featured;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
@@ -1654,7 +1652,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
} else {
|
} else {
|
||||||
// Release tiles: move by visual rows (groups break rows),
|
// Release tiles: move by visual rows (groups break rows),
|
||||||
// keeping the column, so Up lands on the tile above.
|
// 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 position = cursor - tracks;
|
||||||
let (row, column) = rows
|
let (row, column) = rows
|
||||||
.iter()
|
.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 {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let releases = card.releases.len();
|
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||||
let appears_on = card.appears_on.len();
|
let releases = release_indices.len();
|
||||||
|
let appears_on = fed_artist_visible_appearance_indices(state, card).len();
|
||||||
let total = (releases + appears_on) as isize;
|
let total = (releases + appears_on) as isize;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -1724,7 +1723,11 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
let next = if !in_release_tiles {
|
let next = if !in_release_tiles {
|
||||||
(cursor as isize + dy).clamp(0, total - 1) as usize
|
(cursor as isize + dy).clamp(0, total - 1) as usize
|
||||||
} else {
|
} 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
|
let (row, column) = rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -1748,7 +1751,9 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
Some(GlobalView::FedRelease { index, cursor }) => {
|
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.
|
// Row 0 is the download button, 1..=tracks are the tracks.
|
||||||
let next = (cursor as isize + dy).clamp(0, tracks);
|
let next = (cursor as isize + dy).clamp(0, tracks);
|
||||||
set_view_cursor(state, next as usize);
|
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.
|
/// Selectable rows of the open federated artist card: releases, then appearances.
|
||||||
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
||||||
match &state.fed_artist_view {
|
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,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1802,7 +1810,8 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
None => state.global.artists.len(),
|
None => state.global.artists.len(),
|
||||||
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
||||||
Some(Loadable::Ready(d)) => {
|
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,
|
_ => 0,
|
||||||
},
|
},
|
||||||
@@ -1816,14 +1825,67 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
+ state.search.fed_tracks.len()
|
+ state.search.fed_tracks.len()
|
||||||
}
|
}
|
||||||
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
||||||
Some(GlobalView::FedRelease { index, .. }) => {
|
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
||||||
fed_card_release(state, *index).map_or(0, |r| r.tracks.len() + 1)
|
.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) {
|
fn jump_selection(state: &mut AppState, first: bool) {
|
||||||
state.artist_fed_button = false;
|
|
||||||
if state.track_selection.is_active()
|
if state.track_selection.is_active()
|
||||||
&& let Some((scope, _, len)) = current_track_list_context(state)
|
&& 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() {
|
let outcome = match state.global.stack.last().copied() {
|
||||||
None => match state.global.artists.get(state.global.selected) {
|
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 {
|
Some(artist) if artist.id >= 0 => Outcome::Push(GlobalView::Artist {
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -1963,41 +2019,59 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
Some(artist) => Outcome::OpenFedArtist(artist.name.clone()),
|
Some(artist) => Outcome::OpenFedArtist(artist.name.clone()),
|
||||||
None => Outcome::Nothing,
|
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(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) {
|
||||||
Some(Loadable::Ready(detail)) => {
|
Some(Loadable::Ready(detail)) => {
|
||||||
let tracks = detail.top_tracks.len();
|
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 {
|
if cursor < tracks {
|
||||||
Outcome::Play {
|
Outcome::Play {
|
||||||
tracks: detail.top_tracks.clone(),
|
tracks: detail.top_tracks.clone(),
|
||||||
start: cursor,
|
start: cursor,
|
||||||
}
|
}
|
||||||
} else if cursor < tracks + releases {
|
} else if cursor < tracks + releases_len {
|
||||||
let order = release_display_order(&detail.releases);
|
let order = artist_release_display_order(&releases);
|
||||||
match order.get(cursor - tracks) {
|
match order.get(cursor - tracks) {
|
||||||
Some(&original) => Outcome::Push(GlobalView::Release {
|
Some(&original) => {
|
||||||
id: detail.releases[original].id,
|
let slot = &releases[original];
|
||||||
cursor: 0,
|
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,
|
None => Outcome::Nothing,
|
||||||
}
|
}
|
||||||
} else if detail
|
} else if detail
|
||||||
.featured_tracks
|
.featured_tracks
|
||||||
.get(cursor - tracks - releases)
|
.get(cursor - tracks - releases_len)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
Outcome::Play {
|
Outcome::Play {
|
||||||
tracks: detail.featured_tracks.clone(),
|
tracks: detail.featured_tracks.clone(),
|
||||||
start: cursor - tracks - releases,
|
start: cursor - tracks - releases_len,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Outcome::Nothing
|
Outcome::Nothing
|
||||||
@@ -2038,40 +2112,53 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
None => fed_outcome(state, cursor),
|
None => fed_outcome(state, cursor),
|
||||||
},
|
},
|
||||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
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))) => {
|
Some((_, Loadable::Ready(card))) => {
|
||||||
let start = cursor - card.releases.len();
|
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||||
if card.appears_on.get(start).is_none() {
|
if cursor < release_indices.len() {
|
||||||
Outcome::Nothing
|
let visible_releases: Vec<_> = release_indices
|
||||||
} else {
|
|
||||||
let tracks: Vec<_> = card
|
|
||||||
.appears_on
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|appearance| {
|
.map(|&index| card.releases[index].clone())
|
||||||
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
|
||||||
})
|
|
||||||
.map(|fed| crate::federation::pending_track(&fed))
|
|
||||||
.collect();
|
.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
|
Outcome::Nothing
|
||||||
} else {
|
} 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 {
|
match outcome {
|
||||||
Outcome::Push(view) => {
|
Outcome::Push(view) => {
|
||||||
state.artist_fed_button = false;
|
|
||||||
state.global.stack.push(view);
|
state.global.stack.push(view);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -2122,6 +2208,23 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
state.active_tab = Tab::Global;
|
state.active_tab = Tab::Global;
|
||||||
Some(Effect::FedOpenArtist(name))
|
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) => {
|
Outcome::DownloadFed(tracks) => {
|
||||||
state.status_message = Some(format!(
|
state.status_message = Some(format!(
|
||||||
"federation: downloading {} track(s) to the library…",
|
"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(
|
pub(crate) fn fed_release_tracks(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -2158,6 +2261,7 @@ pub(crate) fn fed_release_tracks(
|
|||||||
release
|
release
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.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()))
|
.filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -2168,6 +2272,7 @@ pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::
|
|||||||
};
|
};
|
||||||
card.appears_on
|
card.appears_on
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|appearance| crate::app::state::fed_appearance_visible(state, appearance))
|
||||||
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
|
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
|
||||||
.collect()
|
.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 {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
let release_count = fed_artist_visible_release_indices(state, card).len();
|
||||||
cursor
|
cursor
|
||||||
.checked_sub(card.releases.len())
|
.checked_sub(release_count)
|
||||||
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
|
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -2329,6 +2435,11 @@ enum Outcome {
|
|||||||
start: usize,
|
start: usize,
|
||||||
},
|
},
|
||||||
OpenFedArtist(String),
|
OpenFedArtist(String),
|
||||||
|
OpenFedRelease {
|
||||||
|
name: String,
|
||||||
|
card: crate::federation::FedArtistCard,
|
||||||
|
index: usize,
|
||||||
|
},
|
||||||
DownloadFed(Vec<crate::federation::FedTrack>),
|
DownloadFed(Vec<crate::federation::FedTrack>),
|
||||||
Nothing,
|
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
|
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
|
||||||
/// search so the next `:/` starts clean.
|
/// search so the next `:/` starts clean.
|
||||||
fn go_back(state: &mut AppState) {
|
fn go_back(state: &mut AppState) {
|
||||||
state.artist_fed_button = false;
|
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
match state.active_tab {
|
match state.active_tab {
|
||||||
Tab::Playlists => {
|
Tab::Playlists => {
|
||||||
@@ -2653,7 +2763,6 @@ fn go_back(state: &mut AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn switch_tab(state: &mut AppState, tab: Tab) {
|
fn switch_tab(state: &mut AppState, tab: Tab) {
|
||||||
state.artist_fed_button = false;
|
|
||||||
state.active_tab = tab;
|
state.active_tab = tab;
|
||||||
state.help_visible = false;
|
state.help_visible = false;
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
@@ -2787,7 +2896,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn library_filters_popup_opens_only_on_root() {
|
fn library_filters_popup_opens_on_library_screens() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
update(&mut state, Action::OpenLibraryFilters);
|
update(&mut state, Action::OpenLibraryFilters);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -2798,6 +2907,14 @@ mod tests {
|
|||||||
state.popup = None;
|
state.popup = None;
|
||||||
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||||
update(&mut state, Action::OpenLibraryFilters);
|
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());
|
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 super::{art, availability_marker, availability_prefix, theme};
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
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,
|
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, artist_merged_releases,
|
||||||
fed_release_groups, release_groups,
|
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::art::cache_key;
|
||||||
use crate::library::models::{ArtistCard, Availability, ReleaseCard, SearchResults};
|
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),
|
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||||
..art_area
|
..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() {
|
if !detail.featured_tracks.is_empty() {
|
||||||
about.push_str(&format!(" · appears on {}", detail.featured_tracks.len()));
|
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![
|
let mut info = vec![
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(detail.name.clone(), theme::header()),
|
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()),
|
Line::styled(about, theme::dim()),
|
||||||
];
|
];
|
||||||
if state.federation.settings.enabled {
|
if let Some(line) = federation_line {
|
||||||
// Reached with Up from the first row, like the download button on
|
info.push(line);
|
||||||
// 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()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(info), info_area);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
// Scrollable content: top tracks, releases grouped by type, then the
|
// Scrollable content: top tracks, releases grouped by type, then the
|
||||||
// tracks this artist is featured on.
|
// tracks this artist is featured on.
|
||||||
let tracks = detail.top_tracks.len();
|
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 featured_len = detail.featured_tracks.len();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut cursor_item = None;
|
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 columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
||||||
let mut position = 0;
|
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())));
|
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
|
||||||
match state.global.view {
|
match state.global.view {
|
||||||
ViewMode::Tiles => {
|
ViewMode::Tiles => {
|
||||||
@@ -596,7 +619,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
items.push(PlanItem::Gap);
|
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(
|
render_plan(
|
||||||
frame,
|
frame,
|
||||||
content_area,
|
content_area,
|
||||||
@@ -637,7 +660,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
}
|
}
|
||||||
PlanItem::TileRow(row) => {
|
PlanItem::TileRow(row) => {
|
||||||
for (column, position) in row.iter().enumerate() {
|
for (column, position) in row.iter().enumerate() {
|
||||||
let release = &detail.releases[display_order[*position]];
|
let release = &merged_releases[display_order[*position]];
|
||||||
let tile = Rect {
|
let tile = Rect {
|
||||||
x: rect.x + column as u16 * TILE_WIDTH,
|
x: rect.x + column as u16 * TILE_WIDTH,
|
||||||
y: rect.y,
|
y: rect.y,
|
||||||
@@ -653,14 +676,14 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
tile,
|
tile,
|
||||||
tile_art(state, release.cover_path.as_ref()),
|
tile_art(state, release.cover_path.as_ref()),
|
||||||
&release.title,
|
&release.title,
|
||||||
&release_tile_meta(release),
|
&artist_release_tile_meta(release),
|
||||||
cursor == tracks + position,
|
cursor == tracks + position,
|
||||||
Some(release.availability),
|
Some(release.availability),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlanItem::TableRow(position) => {
|
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();
|
let year = release.year.map(|y| y.to_string()).unwrap_or_default();
|
||||||
draw_row(
|
draw_row(
|
||||||
frame,
|
frame,
|
||||||
@@ -669,7 +692,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
Span::raw(release.title.clone()),
|
Span::raw(release.title.clone()),
|
||||||
Span::styled(format!(" {year}"), theme::dim()),
|
Span::styled(format!(" {year}"), theme::dim()),
|
||||||
]),
|
]),
|
||||||
Some(format!("{} trk", release.track_count)),
|
Some(artist_release_count_meta(release)),
|
||||||
cursor == tracks + position,
|
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 {
|
fn release_tile_meta(release: &ReleaseCard) -> String {
|
||||||
match release.year {
|
match release.year {
|
||||||
Some(year) => format!("{year} · {} trk", release.track_count),
|
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()),
|
header_art(state, card.image_path.as_ref()),
|
||||||
);
|
);
|
||||||
let release_tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
|
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||||
let appears_on_len = card.appears_on.len();
|
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!(
|
let mut stats = format!(
|
||||||
"{} releases · {} tracks",
|
"{} releases · {} tracks",
|
||||||
card.releases.len(),
|
visible_releases.len(),
|
||||||
release_tracks_total
|
release_tracks_total
|
||||||
);
|
);
|
||||||
if appears_on_len > 0 {
|
if appears_on_len > 0 {
|
||||||
stats.push_str(&format!(" · appears on {appears_on_len}"));
|
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![
|
let info = vec![
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(name.clone(), theme::header()),
|
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);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
if card.releases.is_empty() && card.appears_on.is_empty() {
|
if visible_releases.is_empty() && appearance_indices.is_empty() {
|
||||||
return centered_line(
|
let message = if state.global.filters.source_mode.includes_network() {
|
||||||
frame,
|
"the peers returned no releases or appearances"
|
||||||
content_area,
|
} else {
|
||||||
Line::styled(
|
"artist is not available locally"
|
||||||
"the peers returned no releases or appearances",
|
};
|
||||||
theme::dim(),
|
return centered_line(frame, content_area, Line::styled(message, theme::dim()));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release tiles grouped by type, then featured appearances as tracks.
|
// Release tiles grouped by type, then featured appearances as tracks.
|
||||||
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
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 items = Vec::new();
|
||||||
let mut cursor_item = None;
|
let mut cursor_item = None;
|
||||||
let mut position = 0;
|
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())));
|
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
|
||||||
for chunk in group.chunks(columns) {
|
for chunk in group.chunks(columns) {
|
||||||
let row: Vec<usize> = (position..position + chunk.len()).collect();
|
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);
|
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;
|
let appears_scope = crate::app::state::TrackSelectionScope::FedAppearsOn;
|
||||||
render_plan(
|
render_plan(
|
||||||
frame,
|
frame,
|
||||||
@@ -1132,7 +1185,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
|||||||
&mut |frame, rect, item| match item {
|
&mut |frame, rect, item| match item {
|
||||||
PlanItem::TileRow(row) => {
|
PlanItem::TileRow(row) => {
|
||||||
for (column, position) in row.iter().enumerate() {
|
for (column, position) in row.iter().enumerate() {
|
||||||
let release = &card.releases[display_order[*position]];
|
let release = &visible_releases[display_order[*position]];
|
||||||
let tile = Rect {
|
let tile = Rect {
|
||||||
x: rect.x + column as u16 * TILE_WIDTH,
|
x: rect.x + column as u16 * TILE_WIDTH,
|
||||||
y: rect.y,
|
y: rect.y,
|
||||||
@@ -1160,7 +1213,10 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
|||||||
}
|
}
|
||||||
PlanItem::Track { cursor_index } => {
|
PlanItem::Track { cursor_index } => {
|
||||||
let index = cursor_index - releases_len;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
draw_fed_appearance_row(
|
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 {
|
if let Some(year) = release.year {
|
||||||
meta.push_str(&format!(" · {year}"));
|
meta.push_str(&format!(" · {year}"));
|
||||||
}
|
}
|
||||||
meta.push_str(&format!(
|
let visible_track_indices = fed_release_visible_track_indices(state, release);
|
||||||
" · {} tracks · from {} peers",
|
let track_count = visible_track_indices.len();
|
||||||
release.tracks.len(),
|
if state.global.filters.source_mode.includes_network() {
|
||||||
release.owners.len().max(1)
|
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 {
|
let button_style = if cursor == 0 {
|
||||||
theme::tab_active()
|
theme::tab_active()
|
||||||
} else {
|
} else {
|
||||||
theme::accent()
|
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![
|
let info = vec![
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(release.title.clone(), theme::header()),
|
Line::styled(release.title.clone(), theme::header()),
|
||||||
Line::styled(meta, theme::dim()),
|
Line::styled(meta, theme::dim()),
|
||||||
Line::default(),
|
Line::default(),
|
||||||
|
action_line,
|
||||||
Line::styled(
|
Line::styled(
|
||||||
format!(" ⤓ Download the whole release ({}) ", release.tracks.len()),
|
if state.global.filters.source_mode.includes_network() {
|
||||||
button_style,
|
"shift+v: select · y: download · p: add to playlist"
|
||||||
),
|
} else {
|
||||||
Line::styled(
|
"shift+v: select · enter: play · p: add to playlist"
|
||||||
"shift+v: select · y: download · p: add to playlist",
|
},
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
frame.render_widget(Paragraph::new(info), info_area);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
// Tracklist: rows 1..=n of the cursor space.
|
// 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 scope = crate::app::state::TrackSelectionScope::FedRelease(index);
|
||||||
let visible = usize::from(content_area.height.max(1));
|
let visible = usize::from(content_area.height.max(1));
|
||||||
let cursor_track = cursor.saturating_sub(1);
|
let cursor_track = cursor.saturating_sub(1);
|
||||||
let first = cursor_track
|
let first = cursor_track
|
||||||
.saturating_sub(visible / 2)
|
.saturating_sub(visible / 2)
|
||||||
.min(release.tracks.len().saturating_sub(visible));
|
.min(visible_track_indices.len().saturating_sub(visible));
|
||||||
for (offset, (position, track)) in release
|
for (offset, (position, track_index)) in visible_track_indices
|
||||||
.tracks
|
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.skip(first)
|
.skip(first)
|
||||||
.take(visible)
|
.take(visible)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
|
let Some(track) = release.tracks.get(*track_index) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let rect = Rect {
|
let rect = Rect {
|
||||||
x: content_area.x,
|
x: content_area.x,
|
||||||
y: content_area.y + offset as u16,
|
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 ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||||
|
|
||||||
use crate::app::input::LineEdit;
|
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::config::keymap::Keymap;
|
||||||
use crate::library::models::Availability;
|
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()),
|
Span::styled(message.clone(), theme::accent()),
|
||||||
]),
|
]),
|
||||||
Some(message) => Line::styled(message.clone(), theme::accent()),
|
Some(message) => Line::styled(message.clone(), theme::accent()),
|
||||||
None => match &state.player.current {
|
None => match active_artist_peer_search(state) {
|
||||||
// Idle line doubles as the current track's tech data display.
|
Some(message) => loading_line(state, message),
|
||||||
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
|
None => match &state.player.current {
|
||||||
Line::styled(track.tech_label_full(), theme::dim())
|
// 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("press ? for keybindings", theme::dim()),
|
Line::styled(track.tech_label_full(), theme::dim())
|
||||||
|
}
|
||||||
|
_ => Line::styled("press ? for keybindings", theme::dim()),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(message), message_row);
|
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) {
|
fn draw_version(frame: &mut Frame, area: Rect) {
|
||||||
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
|
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
|
|||||||
Reference in New Issue
Block a user