Compare commits
5
Commits
v0.3.1
...
federation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd3ededf6 | ||
|
|
81fdcf13ae | ||
|
|
a6d502e319 | ||
|
|
3298204f2e | ||
|
|
9155bb9e00 |
No files matched your search
Generated
+1
-1
@@ -1715,7 +1715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "furumi_tui"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "A federated P2P player for personal music libraries"
|
||||
|
||||
@@ -30,6 +30,11 @@ pub enum AppEvent {
|
||||
id: i64,
|
||||
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.
|
||||
SearchLoaded {
|
||||
seq: u64,
|
||||
|
||||
+257
-8
@@ -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) {
|
||||
if !state.federation.settings.enabled || state.active_tab != state::Tab::Global {
|
||||
return;
|
||||
@@ -207,6 +327,38 @@ fn maybe_enrich_open_artist(state: &mut AppState, runtime: &Runtime) {
|
||||
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) {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
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_fetch_network_artist_images(state, runtime);
|
||||
maybe_enrich_open_artist(state, runtime);
|
||||
maybe_enrich_open_release(state, runtime);
|
||||
|
||||
// Liked ids load once per session — markers are shown everywhere.
|
||||
if !state.likes_loaded {
|
||||
@@ -2888,6 +3041,24 @@ fn reset_artist_pagination(state: &mut AppState) {
|
||||
global.reloading = false;
|
||||
}
|
||||
|
||||
/// Preserve the queue position while refreshing library-backed entries.
|
||||
///
|
||||
/// A playback key identifies a track, not a queue occurrence: the same track
|
||||
/// may intentionally appear more than once. Only entries removed before the
|
||||
/// current position can therefore move the current occurrence.
|
||||
fn queue_position_after_refresh(
|
||||
queue: &[crate::library::models::TrackItem],
|
||||
queue_pos: usize,
|
||||
refreshed_ids: &std::collections::HashSet<i64>,
|
||||
) -> usize {
|
||||
let removed_before_current = queue
|
||||
.iter()
|
||||
.take(queue_pos)
|
||||
.filter(|track| track.id >= 0 && !refreshed_ids.contains(&track.id))
|
||||
.count();
|
||||
queue_pos.saturating_sub(removed_before_current)
|
||||
}
|
||||
|
||||
/// Swap queue entries for their fresh library copies; tracks that were
|
||||
/// deleted leave the queue.
|
||||
fn apply_queue_refresh(
|
||||
@@ -2897,6 +3068,9 @@ fn apply_queue_refresh(
|
||||
) {
|
||||
let by_id: std::collections::HashMap<i64, _> =
|
||||
tracks.into_iter().map(|track| (track.id, track)).collect();
|
||||
let refreshed_ids: std::collections::HashSet<i64> = by_id.keys().copied().collect();
|
||||
let refreshed_queue_pos =
|
||||
queue_position_after_refresh(&state.player.queue, state.player.queue_pos, &refreshed_ids);
|
||||
let by_key: std::collections::HashMap<String, _> = by_id
|
||||
.values()
|
||||
.cloned()
|
||||
@@ -2932,26 +3106,38 @@ fn apply_queue_refresh(
|
||||
state.queue_tab.cursor = 0;
|
||||
return;
|
||||
}
|
||||
state.player.queue_pos = refreshed_queue_pos.min(state.player.queue.len() - 1);
|
||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||
match (current_id, current_key) {
|
||||
(Some(id), _) if id < 0 => {
|
||||
// The current source can be an ephemeral federated stream while
|
||||
// the queue is refreshed after save-on-listen. It is not a
|
||||
// deleted library row, so keep playback untouched.
|
||||
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||
state.player.queue_pos = refreshed_queue_pos.min(state.player.queue.len() - 1);
|
||||
}
|
||||
(Some(id), Some(key)) if by_id.contains_key(&id) || by_key.contains_key(&key) => {
|
||||
if let Some(position) = state
|
||||
// Keep the occurrence at the old position. Searching by key here
|
||||
// would jump to the first copy when a track is queued repeatedly.
|
||||
let position_matches_current = state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.position(|track| track_playback_key(track) == key)
|
||||
.get(state.player.queue_pos)
|
||||
.is_some_and(|track| track.id == id || track_playback_key(track) == key);
|
||||
if !position_matches_current
|
||||
&& let Some(position) = state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.position(|track| track.id == id || track_playback_key(track) == key)
|
||||
{
|
||||
state.player.queue_pos = position;
|
||||
}
|
||||
state.player.current = by_id
|
||||
.get(&id)
|
||||
state.player.current = state
|
||||
.player
|
||||
.queue
|
||||
.get(state.player.queue_pos)
|
||||
.cloned()
|
||||
.or_else(|| by_id.get(&id).cloned())
|
||||
.or_else(|| by_key.get(&key).cloned());
|
||||
push_media_metadata(state, runtime);
|
||||
}
|
||||
@@ -2959,14 +3145,14 @@ fn apply_queue_refresh(
|
||||
// The playing track was deleted from the library.
|
||||
runtime.player_start_pending = false;
|
||||
runtime.player.stop();
|
||||
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||
state.player.queue_pos = refreshed_queue_pos.min(state.player.queue.len() - 1);
|
||||
state.player.current = None;
|
||||
state.player.playing = false;
|
||||
state.player.paused = false;
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
(None, _) => {
|
||||
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||
state.player.queue_pos = refreshed_queue_pos.min(state.player.queue.len() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3591,6 +3777,17 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.artist_fed_views.insert(id, entry);
|
||||
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 } => {
|
||||
tracing::debug!(source = %source_id, count, "network artist cache updated");
|
||||
if state.active_tab == state::Tab::Global && state.global.stack.is_empty() {
|
||||
@@ -3714,6 +3911,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
};
|
||||
state.release_views.insert(id, entry);
|
||||
maybe_enrich_open_release(state, runtime);
|
||||
// A Shift-J jump was waiting for this release: focus its track.
|
||||
if let Some((release_id, track_id)) = state.pending_release_focus
|
||||
&& release_id == id
|
||||
@@ -4206,6 +4404,57 @@ mod similarity_search_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod queue_refresh_tests {
|
||||
use super::*;
|
||||
use crate::library::models::TrackItem;
|
||||
|
||||
fn track(id: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
id,
|
||||
title: "duplicate".into(),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id: 1,
|
||||
release_title: "release".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/music/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_refresh_keeps_the_selected_duplicate_occurrence() {
|
||||
let queue = vec![track(7), track(7), track(7)];
|
||||
let refreshed_ids = std::collections::HashSet::from([7]);
|
||||
|
||||
assert_eq!(
|
||||
queue_position_after_refresh(&queue, 2, &refreshed_ids),
|
||||
2,
|
||||
"refreshing duplicate queue entries must not select the first one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_refresh_shifts_position_only_for_removed_entries_before_it() {
|
||||
let queue = vec![track(8), track(7), track(7)];
|
||||
let refreshed_ids = std::collections::HashSet::from([7]);
|
||||
|
||||
assert_eq!(queue_position_after_refresh(&queue, 2, &refreshed_ids), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the playback state to the OS now-playing surface. `force` skips
|
||||
/// the position throttle (track switches, pauses).
|
||||
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
|
||||
|
||||
@@ -303,6 +303,79 @@ mod tests {
|
||||
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]
|
||||
fn artist_merged_releases_marks_partially_local_federated_release() {
|
||||
let local_id = "b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
@@ -1604,6 +1677,8 @@ pub struct AppState {
|
||||
/// 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>>,
|
||||
/// Federated tracks that enrich an already-open local release view.
|
||||
pub release_fed_views: HashMap<i64, Loadable<crate::federation::FedRelease>>,
|
||||
pub playlists: PlaylistsTab,
|
||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||
/// 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)]
|
||||
pub enum ArtistCursorAnchor {
|
||||
TopTrack(i64),
|
||||
|
||||
+36
-24
@@ -965,11 +965,10 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
||||
_ => None,
|
||||
},
|
||||
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
|
||||
Loadable::Ready(detail) => Some((
|
||||
TrackSelectionScope::Release(*id),
|
||||
*cursor,
|
||||
detail.tracks.len(),
|
||||
)),
|
||||
Loadable::Ready(detail) => {
|
||||
let tracks = crate::app::state::release_tracks(state, *id, detail);
|
||||
Some((TrackSelectionScope::Release(*id), *cursor, tracks.len()))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
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 {
|
||||
Tab::Global => match state.global.stack.last()? {
|
||||
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((
|
||||
TrackSelectionScope::ArtistTop(*id),
|
||||
*cursor,
|
||||
detail.top_tracks.iter().collect(),
|
||||
detail.top_tracks.clone(),
|
||||
))
|
||||
} else {
|
||||
let featured = cursor.checked_sub(tracks + releases)?;
|
||||
(featured < detail.featured_tracks.len()).then_some((
|
||||
TrackSelectionScope::ArtistFeatured(*id),
|
||||
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((
|
||||
TrackSelectionScope::Release(*id),
|
||||
*cursor,
|
||||
detail.tracks.iter().collect(),
|
||||
crate::app::state::release_tracks(state, *id, detail),
|
||||
)),
|
||||
_ => None,
|
||||
},
|
||||
@@ -1060,13 +1059,16 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
||||
Some((
|
||||
TrackSelectionScope::Playlist(opened.id),
|
||||
opened.cursor,
|
||||
playlist_tracks(state, opened.id)?,
|
||||
playlist_tracks(state, opened.id)?
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
Tab::Queue => Some((
|
||||
TrackSelectionScope::Queue,
|
||||
state.queue_tab.cursor,
|
||||
state.player.queue.iter().collect(),
|
||||
state.player.queue.clone(),
|
||||
)),
|
||||
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))]);
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
|
||||
.filter_map(|index| tracks.get(index).cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1315,7 +1317,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
_ => None,
|
||||
},
|
||||
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,
|
||||
},
|
||||
GlobalView::Search { cursor } => {
|
||||
@@ -1456,11 +1460,12 @@ fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
|
||||
}
|
||||
let release_id = track.release_id;
|
||||
let cursor = match state.release_views.get(&release_id) {
|
||||
Some(Loadable::Ready(detail)) => detail
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.id == track.id)
|
||||
.unwrap_or(0),
|
||||
Some(Loadable::Ready(detail)) => {
|
||||
crate::app::state::release_tracks(state, release_id, detail)
|
||||
.iter()
|
||||
.position(|t| t.id == track.id)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
_ => {
|
||||
state.pending_release_focus = Some((release_id, track.id));
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
let total = detail.tracks.len() as isize;
|
||||
let total = crate::app::state::release_tracks(state, id, detail).len() as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -2045,7 +2050,7 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
_ => 0,
|
||||
},
|
||||
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,
|
||||
},
|
||||
Some(GlobalView::Search { .. }) => {
|
||||
@@ -2339,10 +2344,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
_ => Outcome::Nothing,
|
||||
},
|
||||
Some(GlobalView::Release { id, cursor }) => match state.release_views.get(&id) {
|
||||
Some(Loadable::Ready(detail)) if !detail.tracks.is_empty() => Outcome::Play {
|
||||
tracks: detail.tracks.clone(),
|
||||
start: cursor.min(detail.tracks.len() - 1),
|
||||
},
|
||||
Some(Loadable::Ready(detail)) => {
|
||||
let tracks = crate::app::state::release_tracks(state, id, detail);
|
||||
if tracks.is_empty() {
|
||||
Outcome::Nothing
|
||||
} else {
|
||||
Outcome::Play {
|
||||
start: cursor.min(tracks.len() - 1),
|
||||
tracks,
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Outcome::Nothing,
|
||||
},
|
||||
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 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::styled(detail.title.clone(), theme::header_for(state)),
|
||||
Line::raw(artists.join(", ")),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!(
|
||||
"{}{year} · {} tracks",
|
||||
detail.release_type,
|
||||
detail.tracks.len()
|
||||
),
|
||||
format!("{}{year} · {} tracks", detail.release_type, tracks.len()),
|
||||
theme::dim(),
|
||||
),
|
||||
];
|
||||
if let Some(line) = federation_line {
|
||||
info.push(line);
|
||||
}
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Track list with centered scrolling.
|
||||
let visible = usize::from(tracks_area.height.max(1));
|
||||
let total = detail.tracks.len();
|
||||
let total = tracks.len();
|
||||
let first = cursor
|
||||
.saturating_sub(visible / 2)
|
||||
.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 {
|
||||
x: tracks_area.x,
|
||||
y: tracks_area.y + (offset - first) as u16,
|
||||
|
||||
Reference in new issue
Block a user