Fixed release fed search regression
This commit is contained in:
1 parent
b07bcd4ddd
commit
9155bb9e00
6 files changed
+377
-33
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
description = "A federated P2P player for personal music libraries"
|
description = "A federated P2P player for personal music libraries"
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ pub enum AppEvent {
|
|||||||
id: i64,
|
id: i64,
|
||||||
result: Result<ReleaseDetail, String>,
|
result: Result<ReleaseDetail, String>,
|
||||||
},
|
},
|
||||||
|
/// Federated tracks that complete an already-open local release view.
|
||||||
|
ReleaseFederationLoaded {
|
||||||
|
id: i64,
|
||||||
|
result: Result<Option<crate::federation::FedRelease>, String>,
|
||||||
|
},
|
||||||
/// Live search results; `seq` drops responses that are already stale.
|
/// Live search results; `seq` drops responses that are already stale.
|
||||||
SearchLoaded {
|
SearchLoaded {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
|
|||||||
+165
@@ -189,6 +189,126 @@ fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_release_federation_results(
|
||||||
|
title: &str,
|
||||||
|
releases: impl IntoIterator<Item = crate::federation::FedRelease>,
|
||||||
|
) -> Option<crate::federation::FedRelease> {
|
||||||
|
let key = music_dht::normalize_name(title);
|
||||||
|
let mut merged = None;
|
||||||
|
for release in releases {
|
||||||
|
if music_dht::normalize_name(&release.title) != key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = merged.get_or_insert_with(|| crate::federation::FedRelease {
|
||||||
|
title: release.title.clone(),
|
||||||
|
release_type: release.release_type.clone(),
|
||||||
|
year: release.year,
|
||||||
|
owners: Vec::new(),
|
||||||
|
cover_path: release.cover_path.clone(),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
});
|
||||||
|
if target.release_type.is_empty() {
|
||||||
|
target.release_type = release.release_type.clone();
|
||||||
|
}
|
||||||
|
if target.year.is_none() {
|
||||||
|
target.year = release.year;
|
||||||
|
}
|
||||||
|
if target.cover_path.is_none() {
|
||||||
|
target.cover_path = release.cover_path.clone();
|
||||||
|
}
|
||||||
|
for owner in release.owners {
|
||||||
|
if !target.owners.contains(&owner) {
|
||||||
|
target.owners.push(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for track in release.tracks {
|
||||||
|
let existing = target.tracks.iter_mut().find(|existing| {
|
||||||
|
music_dht::normalize_name(&existing.title)
|
||||||
|
== music_dht::normalize_name(&track.title)
|
||||||
|
&& (existing.track_number == track.track_number
|
||||||
|
|| existing.track_number.is_none()
|
||||||
|
|| track.track_number.is_none())
|
||||||
|
});
|
||||||
|
match existing {
|
||||||
|
Some(existing) => {
|
||||||
|
if existing.artists.is_empty() {
|
||||||
|
existing.artists = track.artists;
|
||||||
|
}
|
||||||
|
if existing.featured_artists.is_empty() {
|
||||||
|
existing.featured_artists = track.featured_artists;
|
||||||
|
}
|
||||||
|
if existing.track_number.is_none() {
|
||||||
|
existing.track_number = track.track_number;
|
||||||
|
}
|
||||||
|
if existing.disc_number.is_none() {
|
||||||
|
existing.disc_number = track.disc_number;
|
||||||
|
}
|
||||||
|
if existing.duration_seconds.is_none() {
|
||||||
|
existing.duration_seconds = track.duration_seconds;
|
||||||
|
}
|
||||||
|
if existing.content_id.is_none() {
|
||||||
|
existing.content_id = track.content_id;
|
||||||
|
}
|
||||||
|
for source in track.sources {
|
||||||
|
if !existing.sources.contains(&source) {
|
||||||
|
existing.sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => target.tracks.push(track),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_release_federation_enrichment(
|
||||||
|
runtime: &Runtime,
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
artists: Vec<String>,
|
||||||
|
) {
|
||||||
|
let fed = Arc::clone(&runtime.federation);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let requests = artists.into_iter().map(|artist| {
|
||||||
|
let fed = Arc::clone(&fed);
|
||||||
|
async move { fed.artist_card(&artist).await }
|
||||||
|
});
|
||||||
|
let mut cards = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for result in futures_util::future::join_all(requests).await {
|
||||||
|
match result {
|
||||||
|
Ok(card) => {
|
||||||
|
let own_owner = card.own_owner.clone();
|
||||||
|
for mut release in card.releases {
|
||||||
|
// The local catalog is included in artist cards. This
|
||||||
|
// view only needs the missing network tracks; local
|
||||||
|
// rows are already supplied by the release query.
|
||||||
|
if let Some(own_owner) = own_owner.as_deref() {
|
||||||
|
release.owners.retain(|owner| owner != own_owner);
|
||||||
|
for track in &mut release.tracks {
|
||||||
|
track.sources.retain(|(owner, _)| owner != own_owner);
|
||||||
|
}
|
||||||
|
release.tracks.retain(|track| !track.sources.is_empty());
|
||||||
|
}
|
||||||
|
cards.push(release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => errors.push(format!("{error:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = if let Some(release) = merge_release_federation_results(&title, cards) {
|
||||||
|
Ok(Some(release))
|
||||||
|
} else if !errors.is_empty() {
|
||||||
|
Err(errors.join("; "))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
};
|
||||||
|
let _ = tx.send(AppEvent::ReleaseFederationLoaded { id, result });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn maybe_enrich_open_artist(state: &mut AppState, runtime: &Runtime) {
|
fn maybe_enrich_open_artist(state: &mut AppState, runtime: &Runtime) {
|
||||||
if !state.federation.settings.enabled || state.active_tab != state::Tab::Global {
|
if !state.federation.settings.enabled || state.active_tab != state::Tab::Global {
|
||||||
return;
|
return;
|
||||||
@@ -207,6 +327,38 @@ fn maybe_enrich_open_artist(state: &mut AppState, runtime: &Runtime) {
|
|||||||
spawn_artist_federation_enrichment(runtime, id, name);
|
spawn_artist_federation_enrichment(runtime, id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn maybe_enrich_open_release(state: &mut AppState, runtime: &Runtime) {
|
||||||
|
if !state.federation.settings.enabled
|
||||||
|
|| !state.global.filters.source_mode.includes_network()
|
||||||
|
|| state.active_tab != state::Tab::Global
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(state::GlobalView::Release { id, .. }) = state.global.stack.last().copied() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if state.release_fed_views.contains_key(&id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
for artist in &detail.artists {
|
||||||
|
if !artists.iter().any(|name: &String| {
|
||||||
|
music_dht::normalize_name(name) == music_dht::normalize_name(&artist.name)
|
||||||
|
}) {
|
||||||
|
artists.push(artist.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if artists.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = detail.title.clone();
|
||||||
|
state.release_fed_views.insert(id, state::Loadable::Loading);
|
||||||
|
spawn_release_federation_enrichment(runtime, id, title, artists);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -1011,6 +1163,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);
|
maybe_enrich_open_artist(state, runtime);
|
||||||
|
maybe_enrich_open_release(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 {
|
||||||
@@ -3591,6 +3744,17 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
state.artist_fed_views.insert(id, entry);
|
state.artist_fed_views.insert(id, entry);
|
||||||
state::restore_artist_cursor_anchor(state, id, cursor_anchor);
|
state::restore_artist_cursor_anchor(state, id, cursor_anchor);
|
||||||
}
|
}
|
||||||
|
AppEvent::ReleaseFederationLoaded { id, result } => {
|
||||||
|
let entry = match result {
|
||||||
|
Ok(Some(release)) => state::Loadable::Ready(release),
|
||||||
|
Ok(None) => state::Loadable::Ready(crate::federation::FedRelease::default()),
|
||||||
|
Err(message) => {
|
||||||
|
tracing::debug!(release = id, %message, "release federation enrichment failed");
|
||||||
|
state::Loadable::Failed(message)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state.release_fed_views.insert(id, entry);
|
||||||
|
}
|
||||||
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() {
|
||||||
@@ -3714,6 +3878,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
state.release_views.insert(id, entry);
|
state.release_views.insert(id, entry);
|
||||||
|
maybe_enrich_open_release(state, runtime);
|
||||||
// A Shift-J jump was waiting for this release: focus its track.
|
// A Shift-J jump was waiting for this release: focus its track.
|
||||||
if let Some((release_id, track_id)) = state.pending_release_focus
|
if let Some((release_id, track_id)) = state.pending_release_focus
|
||||||
&& release_id == id
|
&& release_id == id
|
||||||
|
|||||||
@@ -303,6 +303,79 @@ 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 release_tracks_adds_remote_rows_without_duplicating_local_content() {
|
||||||
|
let local_content =
|
||||||
|
"b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
|
||||||
|
let detail = ReleaseDetail {
|
||||||
|
id: 7,
|
||||||
|
title: "Album".to_string(),
|
||||||
|
release_type: "album".to_string(),
|
||||||
|
year: Some(2024),
|
||||||
|
cover_path: None,
|
||||||
|
artists: vec![crate::library::models::ArtistRef {
|
||||||
|
id: 3,
|
||||||
|
name: "Artist".to_string(),
|
||||||
|
}],
|
||||||
|
tracks: vec![TrackItem {
|
||||||
|
id: 1,
|
||||||
|
title: "Local".to_string(),
|
||||||
|
track_number: Some(1),
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: 120.0,
|
||||||
|
artists: Vec::new(),
|
||||||
|
featured_artists: Vec::new(),
|
||||||
|
release_id: 7,
|
||||||
|
release_title: "Album".to_string(),
|
||||||
|
release_year: Some(2024),
|
||||||
|
file_path: "/music/local.mp3".to_string(),
|
||||||
|
content_id: Some(local_content.clone()),
|
||||||
|
cover_path: None,
|
||||||
|
audio_format: Some("mp3".to_string()),
|
||||||
|
audio_bitrate: Some(320),
|
||||||
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
|
file_size_bytes: None,
|
||||||
|
play_count: 0,
|
||||||
|
fed: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut state = AppState::default();
|
||||||
|
state.release_fed_views.insert(
|
||||||
|
detail.id,
|
||||||
|
Loadable::Ready(crate::federation::FedRelease {
|
||||||
|
title: "Album".to_string(),
|
||||||
|
tracks: vec![
|
||||||
|
crate::federation::FedCardTrack {
|
||||||
|
title: "Local".to_string(),
|
||||||
|
track_number: Some(1),
|
||||||
|
content_id: Some(local_content),
|
||||||
|
sources: vec![("peer".to_string(), "local-item".to_string())],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
crate::federation::FedCardTrack {
|
||||||
|
title: "Remote".to_string(),
|
||||||
|
track_number: Some(3),
|
||||||
|
sources: vec![("peer".to_string(), "remote-item".to_string())],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tracks = release_tracks(&state, detail.id, &detail);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.title.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Local", "Remote"]
|
||||||
|
);
|
||||||
|
assert!(tracks[1].is_fed_pending());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn artist_merged_releases_marks_partially_local_federated_release() {
|
fn artist_merged_releases_marks_partially_local_federated_release() {
|
||||||
let local_id = "b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
let local_id = "b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
@@ -1604,6 +1677,8 @@ pub struct AppState {
|
|||||||
/// Federated card data that enriches a local artist page in-place.
|
/// Federated card data that enriches a local artist page in-place.
|
||||||
pub artist_fed_views: HashMap<i64, Loadable<crate::federation::FedArtistCard>>,
|
pub artist_fed_views: HashMap<i64, Loadable<crate::federation::FedArtistCard>>,
|
||||||
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||||
|
/// Federated tracks that enrich an already-open local release view.
|
||||||
|
pub release_fed_views: HashMap<i64, Loadable<crate::federation::FedRelease>>,
|
||||||
pub playlists: PlaylistsTab,
|
pub playlists: PlaylistsTab,
|
||||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||||
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
|
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
|
||||||
@@ -1721,6 +1796,75 @@ pub fn artist_fed_card(state: &AppState, id: i64) -> Option<&crate::federation::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tracklist rendered and played by an opened release. When federation
|
||||||
|
/// has supplied a matching release, local rows remain authoritative and only
|
||||||
|
/// tracks that are not already present locally are appended.
|
||||||
|
pub fn release_tracks(state: &AppState, id: i64, detail: &ReleaseDetail) -> Vec<TrackItem> {
|
||||||
|
let mut tracks = detail.tracks.clone();
|
||||||
|
if !state.global.filters.source_mode.includes_network() {
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
let Some(Loadable::Ready(fed_release)) = state.release_fed_views.get(&id) else {
|
||||||
|
return tracks;
|
||||||
|
};
|
||||||
|
for fed in &fed_release.tracks {
|
||||||
|
let Some((owner, item_id)) = fed.sources.first().cloned() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let fed_content_id = fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
let already_local = tracks.iter().any(|local| {
|
||||||
|
let same_content = fed_content_id.is_some()
|
||||||
|
&& track_content_id(local).as_deref() == fed_content_id.as_deref();
|
||||||
|
let same_position = local.track_number == fed.track_number
|
||||||
|
|| local.track_number.is_none()
|
||||||
|
|| fed.track_number.is_none();
|
||||||
|
same_content
|
||||||
|
|| (music_dht::normalize_name(&local.title)
|
||||||
|
== music_dht::normalize_name(&fed.title)
|
||||||
|
&& same_position)
|
||||||
|
});
|
||||||
|
if already_local {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let names = if fed.artists.is_empty() {
|
||||||
|
detail
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
fed.artists.clone()
|
||||||
|
};
|
||||||
|
let featured_artists = fed.featured_artists.clone();
|
||||||
|
tracks.push(crate::federation::pending_track(
|
||||||
|
&crate::federation::FedTrack {
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
own: false,
|
||||||
|
title: fed.title.clone(),
|
||||||
|
artist_names: names,
|
||||||
|
featured_artist_names: featured_artists,
|
||||||
|
year: fed_release.year.or(detail.year),
|
||||||
|
duration_seconds: fed.duration_seconds.map(|seconds| seconds.round() as i64),
|
||||||
|
content_id: fed.content_id.clone(),
|
||||||
|
release_title: Some(detail.title.clone()),
|
||||||
|
track_number: fed.track_number,
|
||||||
|
disc_number: fed.disc_number,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracks.sort_by_key(|track| {
|
||||||
|
(
|
||||||
|
track.disc_number.unwrap_or(1),
|
||||||
|
track.track_number.unwrap_or(i32::MAX),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ArtistCursorAnchor {
|
pub enum ArtistCursorAnchor {
|
||||||
TopTrack(i64),
|
TopTrack(i64),
|
||||||
|
|||||||
+36
-24
@@ -965,11 +965,10 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
|
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
|
||||||
Loadable::Ready(detail) => Some((
|
Loadable::Ready(detail) => {
|
||||||
TrackSelectionScope::Release(*id),
|
let tracks = crate::app::state::release_tracks(state, *id, detail);
|
||||||
*cursor,
|
Some((TrackSelectionScope::Release(*id), *cursor, tracks.len()))
|
||||||
detail.tracks.len(),
|
}
|
||||||
)),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
@@ -1021,7 +1020,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, Vec<&TrackItem>)> {
|
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, Vec<TrackItem>)> {
|
||||||
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)? {
|
||||||
@@ -1032,14 +1031,14 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
|||||||
Some((
|
Some((
|
||||||
TrackSelectionScope::ArtistTop(*id),
|
TrackSelectionScope::ArtistTop(*id),
|
||||||
*cursor,
|
*cursor,
|
||||||
detail.top_tracks.iter().collect(),
|
detail.top_tracks.clone(),
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
let featured = cursor.checked_sub(tracks + releases)?;
|
let featured = cursor.checked_sub(tracks + releases)?;
|
||||||
(featured < detail.featured_tracks.len()).then_some((
|
(featured < detail.featured_tracks.len()).then_some((
|
||||||
TrackSelectionScope::ArtistFeatured(*id),
|
TrackSelectionScope::ArtistFeatured(*id),
|
||||||
featured,
|
featured,
|
||||||
detail.featured_tracks.iter().collect(),
|
detail.featured_tracks.clone(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1048,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
|||||||
Loadable::Ready(detail) => Some((
|
Loadable::Ready(detail) => Some((
|
||||||
TrackSelectionScope::Release(*id),
|
TrackSelectionScope::Release(*id),
|
||||||
*cursor,
|
*cursor,
|
||||||
detail.tracks.iter().collect(),
|
crate::app::state::release_tracks(state, *id, detail),
|
||||||
)),
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
@@ -1060,13 +1059,16 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
|||||||
Some((
|
Some((
|
||||||
TrackSelectionScope::Playlist(opened.id),
|
TrackSelectionScope::Playlist(opened.id),
|
||||||
opened.cursor,
|
opened.cursor,
|
||||||
playlist_tracks(state, opened.id)?,
|
playlist_tracks(state, opened.id)?
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Tab::Queue => Some((
|
Tab::Queue => Some((
|
||||||
TrackSelectionScope::Queue,
|
TrackSelectionScope::Queue,
|
||||||
state.queue_tab.cursor,
|
state.queue_tab.cursor,
|
||||||
state.player.queue.iter().collect(),
|
state.player.queue.clone(),
|
||||||
)),
|
)),
|
||||||
Tab::Federation | Tab::Logs => None,
|
Tab::Federation | Tab::Logs => None,
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1106,7 @@ pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
|||||||
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
|
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
|
||||||
indices
|
indices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
|
.filter_map(|index| tracks.get(index).cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1317,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
|
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
|
||||||
Loadable::Ready(detail) => detail.tracks.get(*cursor).cloned(),
|
Loadable::Ready(detail) => crate::app::state::release_tracks(state, *id, detail)
|
||||||
|
.get(*cursor)
|
||||||
|
.cloned(),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
@@ -1456,11 +1460,12 @@ fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
|
|||||||
}
|
}
|
||||||
let release_id = track.release_id;
|
let release_id = track.release_id;
|
||||||
let cursor = match state.release_views.get(&release_id) {
|
let cursor = match state.release_views.get(&release_id) {
|
||||||
Some(Loadable::Ready(detail)) => detail
|
Some(Loadable::Ready(detail)) => {
|
||||||
.tracks
|
crate::app::state::release_tracks(state, release_id, detail)
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.id == track.id)
|
.position(|t| t.id == track.id)
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
state.pending_release_focus = Some((release_id, track.id));
|
state.pending_release_focus = Some((release_id, track.id));
|
||||||
0
|
0
|
||||||
@@ -1914,7 +1919,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else {
|
let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let total = detail.tracks.len() as isize;
|
let total = crate::app::state::release_tracks(state, id, detail).len() as isize;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2045,7 +2050,7 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) {
|
Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) {
|
||||||
Some(Loadable::Ready(d)) => d.tracks.len(),
|
Some(Loadable::Ready(d)) => crate::app::state::release_tracks(state, *id, d).len(),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { .. }) => {
|
Some(GlobalView::Search { .. }) => {
|
||||||
@@ -2339,10 +2344,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
_ => Outcome::Nothing,
|
_ => Outcome::Nothing,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Release { id, cursor }) => match state.release_views.get(&id) {
|
Some(GlobalView::Release { id, cursor }) => match state.release_views.get(&id) {
|
||||||
Some(Loadable::Ready(detail)) if !detail.tracks.is_empty() => Outcome::Play {
|
Some(Loadable::Ready(detail)) => {
|
||||||
tracks: detail.tracks.clone(),
|
let tracks = crate::app::state::release_tracks(state, id, detail);
|
||||||
start: cursor.min(detail.tracks.len() - 1),
|
if tracks.is_empty() {
|
||||||
},
|
Outcome::Nothing
|
||||||
|
} else {
|
||||||
|
Outcome::Play {
|
||||||
|
start: cursor.min(tracks.len() - 1),
|
||||||
|
tracks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => Outcome::Nothing,
|
_ => Outcome::Nothing,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { cursor }) => {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
|
|||||||
+26
-8
@@ -837,29 +837,47 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
|
|
||||||
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
|
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
|
||||||
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
|
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
|
||||||
let info = vec![
|
let tracks = crate::app::state::release_tracks(state, id, detail);
|
||||||
|
let federation_line = match state.release_fed_views.get(&id) {
|
||||||
|
Some(Loadable::Loading) => Some(super::loading_line(state, "searching peers…")),
|
||||||
|
Some(Loadable::Ready(release)) if !release.tracks.is_empty() => Some(Line::styled(
|
||||||
|
format!("federation: +{} tracks", release.tracks.len()),
|
||||||
|
theme::dim(),
|
||||||
|
)),
|
||||||
|
Some(Loadable::Ready(_)) => Some(Line::styled(
|
||||||
|
"federation: no additional tracks",
|
||||||
|
theme::dim(),
|
||||||
|
)),
|
||||||
|
Some(Loadable::Failed(_)) => Some(Line::styled("federation: unavailable", theme::dim())),
|
||||||
|
_ if state.federation.settings.enabled
|
||||||
|
&& state.global.filters.source_mode.includes_network() =>
|
||||||
|
{
|
||||||
|
Some(Line::styled("federation: pending", theme::dim()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut info = vec![
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(detail.title.clone(), theme::header_for(state)),
|
Line::styled(detail.title.clone(), theme::header_for(state)),
|
||||||
Line::raw(artists.join(", ")),
|
Line::raw(artists.join(", ")),
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(
|
Line::styled(
|
||||||
format!(
|
format!("{}{year} · {} tracks", detail.release_type, tracks.len()),
|
||||||
"{}{year} · {} tracks",
|
|
||||||
detail.release_type,
|
|
||||||
detail.tracks.len()
|
|
||||||
),
|
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
if let Some(line) = federation_line {
|
||||||
|
info.push(line);
|
||||||
|
}
|
||||||
frame.render_widget(Paragraph::new(info), info_area);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
// Track list with centered scrolling.
|
// Track list with centered scrolling.
|
||||||
let visible = usize::from(tracks_area.height.max(1));
|
let visible = usize::from(tracks_area.height.max(1));
|
||||||
let total = detail.tracks.len();
|
let total = tracks.len();
|
||||||
let first = cursor
|
let first = cursor
|
||||||
.saturating_sub(visible / 2)
|
.saturating_sub(visible / 2)
|
||||||
.min(total.saturating_sub(visible));
|
.min(total.saturating_sub(visible));
|
||||||
for (offset, track) in detail.tracks.iter().enumerate().skip(first).take(visible) {
|
for (offset, track) in tracks.iter().enumerate().skip(first).take(visible) {
|
||||||
let rect = Rect {
|
let rect = Rect {
|
||||||
x: tracks_area.x,
|
x: tracks_area.x,
|
||||||
y: tracks_area.y + (offset - first) as u16,
|
y: tracks_area.y + (offset - first) as u16,
|
||||||
|
|||||||
Reference in new issue
Block a user