Improved fed artist page

This commit is contained in:
Ultradesu
2026-07-17 01:43:09 +03:00
parent a723fcc18e
commit ff97053156
13 changed files with 929 additions and 208 deletions
+4
View File
@@ -32,6 +32,8 @@ pub enum Action {
OpenTrackInfo,
QueueAddNext,
QueueAddLast,
/// Download the selected federated track(s) into the local library.
DownloadSelected,
RemoveFromQueue,
ClearQueue,
GoToRelease,
@@ -92,6 +94,7 @@ impl Action {
| Action::CycleRepeat => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::DownloadSelected
| Action::RemoveFromQueue
| Action::ClearQueue
| Action::AddToPlaylist
@@ -169,6 +172,7 @@ impl Action {
Action::OpenTrackInfo => "Track info".into(),
Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(),
Action::DownloadSelected => "Federation: download to library".into(),
Action::RemoveFromQueue => "Queue: remove selected".into(),
Action::ClearQueue => "Queue: clear".into(),
Action::GoToRelease => "Open the track's release".into(),
+9 -2
View File
@@ -63,8 +63,8 @@ pub enum AppEvent {
},
PlaylistCreated {
result: Result<PlaylistCard, String>,
/// Add this track to the new playlist right away (Shift-P flow).
add_track: Option<TrackItem>,
/// Add this target to the new playlist right away (Shift-P flow).
add_target: Option<crate::app::state::PlaylistAddTarget>,
},
PlaylistTracksAdded {
playlist_id: i64,
@@ -100,6 +100,13 @@ pub enum AppEvent {
name: String,
result: Result<crate::federation::FedArtistCard, String>,
},
/// A streamed image for the open card arrived (artist image when
/// `release` is None, a release cover otherwise).
FedCardArt {
name: String,
release: Option<String>,
path: String,
},
/// A federated track finished downloading and is ready to play.
FedPlayReady {
result: Result<crate::federation::FedPlayable, String>,
+122 -17
View File
@@ -245,7 +245,9 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
});
}
}
state::GlobalView::Search { .. } | state::GlobalView::FedArtist { .. } => {}
state::GlobalView::Search { .. }
| state::GlobalView::FedArtist { .. }
| state::GlobalView::FedRelease { .. } => {}
}
}
@@ -287,6 +289,17 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
wanted.push((path.clone(), header.0, header.1));
}
}
if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
if let Some(path) = &card.image_path {
wanted.push((path.clone(), header.0, header.1));
}
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));
}
}
}
for (path, width, height) in wanted {
let key = crate::art::cache_key(&path, width, height);
if state.art.contains_key(&key) {
@@ -434,9 +447,37 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
.artist_card(&name)
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedArtistLoaded { name, result });
let card = result.as_ref().ok().cloned();
let _ = tx.send(AppEvent::FedArtistLoaded {
name: name.clone(),
result,
});
// Stream the artwork in after the card is on screen: the
// artist image first, then every release cover.
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,
});
}
});
}
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
Effect::FedPlay(fed_track) => {
let fed = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
@@ -626,6 +667,64 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) {
});
}
/// Downloads federated tracks into the library one by one (with progress in
/// the status bar) and optionally links them to a playlist afterwards.
pub(crate) fn fed_download_spawn(
runtime: &Runtime,
tracks: Vec<crate::federation::FedTrack>,
playlist: Option<(i64, String)>,
) {
if tracks.is_empty() {
return;
}
let fed = Arc::clone(&runtime.federation);
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let total = tracks.len();
let mut imported_ids = Vec::new();
let mut failed = 0usize;
for (index, track) in tracks.iter().enumerate() {
let _ = tx.send(AppEvent::StatusMessage(format!(
"federation: скачивание {}/{total}: {}",
index + 1,
track.title
)));
match fed.download_to_library(track).await {
Ok(imported) => imported_ids.push(imported.id),
Err(err) => {
failed += 1;
tracing::warn!(title = %track.title, "federated download failed: {err:#}");
}
}
}
let mut message = format!("federation: скачано {} из {total}", imported_ids.len());
if failed > 0 {
message.push_str(&format!(" ({failed} с ошибкой)"));
}
if let Some((playlist_id, playlist_title)) = playlist
&& !imported_ids.is_empty()
{
let library = Arc::clone(&library);
let tx_add = tx.clone();
let title = playlist_title.clone();
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(playlist_id, &imported_ids)
.map_err(|err| format!("{err:#}"));
let _ = tx_add.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title: title,
result,
});
});
}
let _ = tx.send(AppEvent::LibraryChanged {
message: Some(message),
});
});
}
/// Request a fresh status snapshot for the Federation tab.
fn fed_spawn_status(runtime: &Runtime) {
let fed = Arc::clone(&runtime.federation);
@@ -945,6 +1044,24 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
};
}
}
AppEvent::FedCardArt {
name,
release,
path,
} => {
if let Some((current, state::Loadable::Ready(card))) = &mut state.fed_artist_view
&& *current == name
{
match release {
None => card.image_path = Some(path),
Some(title) => {
if let Some(slot) = card.releases.iter_mut().find(|r| r.title == title) {
slot.cover_path = Some(path);
}
}
}
}
}
AppEvent::FedTicket(result) => match result {
Ok(ticket) => {
state.popup = Some(state::Popup::FedText {
@@ -1144,27 +1261,15 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
format!("{count} tracks queued")
});
}
AppEvent::PlaylistCreated { result, add_track } => match result {
AppEvent::PlaylistCreated { result, add_target } => match result {
Ok(playlist) => {
tracing::info!(title = %playlist.title, "playlist created");
state.status_message = Some(format!("playlist \"{}\" created", playlist.title));
state.popup = None;
// The list is stale; refetch when next needed.
state.playlists.list = None;
if let Some(track) = add_track {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
let (id, title) = (playlist.id, playlist.title.clone());
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(id, &[track.id])
.map_err(|e| format!("{e:#}"));
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id: id,
playlist_title: title,
result,
});
});
if let Some(target) = add_target {
popup::spawn_add_target(runtime, playlist.id, playlist.title.clone(), target);
}
}
Err(message) => {
+52 -33
View File
@@ -19,14 +19,14 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
return;
};
match popup {
Popup::AddToPlaylist { track, cursor } => {
handle_picker(state, runtime, track, cursor, key);
Popup::AddToPlaylist { target, cursor } => {
handle_picker(state, runtime, target, cursor, key);
}
Popup::NewPlaylist {
for_track,
for_target,
input,
busy,
} => handle_name_entry(state, runtime, for_track, input, busy, key),
} => handle_name_entry(state, runtime, for_target, input, busy, key),
Popup::Edit {
target,
title,
@@ -359,7 +359,7 @@ fn handle_track_info(
fn handle_picker(
state: &mut AppState,
runtime: &Runtime,
track: TrackItem,
target: crate::app::state::PlaylistAddTarget,
cursor: usize,
key: KeyEvent,
) {
@@ -368,42 +368,42 @@ fn handle_picker(
KeyCode::Esc => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::AddToPlaylist {
track,
target,
cursor: cursor.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::AddToPlaylist {
track,
target,
cursor: (cursor + 1).min(options.len()),
});
}
KeyCode::Enter => {
if cursor == 0 {
state.popup = Some(Popup::NewPlaylist {
for_track: Some(track),
for_target: Some(target),
input: crate::app::input::LineEdit::default(),
busy: false,
});
} else if let Some((id, title)) = options.get(cursor - 1).cloned() {
spawn_add_track(runtime, id, title, track);
spawn_add_target(runtime, id, title, target);
}
}
_ => state.popup = Some(Popup::AddToPlaylist { track, cursor }),
_ => state.popup = Some(Popup::AddToPlaylist { target, cursor }),
}
}
fn handle_name_entry(
state: &mut AppState,
runtime: &Runtime,
for_track: Option<TrackItem>,
for_target: Option<crate::app::state::PlaylistAddTarget>,
mut input: crate::app::input::LineEdit,
busy: bool,
key: KeyEvent,
) {
if busy {
state.popup = Some(Popup::NewPlaylist {
for_track,
for_target,
input,
busy,
});
@@ -412,8 +412,8 @@ fn handle_name_entry(
match key.code {
KeyCode::Esc => {
// Reached from the picker → step back to it; otherwise close.
if let Some(track) = for_track {
state.popup = Some(Popup::AddToPlaylist { track, cursor: 0 });
if let Some(target) = for_target {
state.popup = Some(Popup::AddToPlaylist { target, cursor: 0 });
}
}
KeyCode::Enter => {
@@ -421,15 +421,15 @@ fn handle_name_entry(
if title.is_empty() {
state.status_message = Some("playlist name is empty".into());
state.popup = Some(Popup::NewPlaylist {
for_track,
for_target,
input,
busy: false,
});
return;
}
spawn_create_playlist(runtime, title, for_track.clone());
spawn_create_playlist(runtime, title, for_target.clone());
state.popup = Some(Popup::NewPlaylist {
for_track,
for_target,
input,
busy: true,
});
@@ -437,7 +437,7 @@ fn handle_name_entry(
_ => {
input.handle_key(key);
state.popup = Some(Popup::NewPlaylist {
for_track,
for_target,
input,
busy: false,
});
@@ -445,28 +445,47 @@ fn handle_name_entry(
}
}
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(playlist_id, &[track.id])
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
result,
});
});
/// Adds a target to a playlist: local tracks directly; federated ones are
/// downloaded into the library first, then linked.
pub(crate) fn spawn_add_target(
runtime: &Runtime,
playlist_id: i64,
playlist_title: String,
target: crate::app::state::PlaylistAddTarget,
) {
match target {
crate::app::state::PlaylistAddTarget::Local(tracks) => {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
let ids: Vec<i64> = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect();
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(playlist_id, &ids)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
result,
});
});
}
crate::app::state::PlaylistAddTarget::Fed(tracks) => {
super::fed_download_spawn(runtime, tracks, Some((playlist_id, playlist_title)));
}
}
}
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
fn spawn_create_playlist(
runtime: &Runtime,
title: String,
add_target: Option<crate::app::state::PlaylistAddTarget>,
) {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = library
.create_playlist(&title)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
let _ = tx.send(AppEvent::PlaylistCreated { result, add_target });
});
}
+41 -6
View File
@@ -74,6 +74,12 @@ pub enum GlobalView {
FedArtist {
cursor: usize,
},
/// One release of the open federated card: row 0 is the
/// download-release button, rows 1..=n are its tracks.
FedRelease {
index: usize,
cursor: usize,
},
}
/// The Global tab: the whole server library of artists.
@@ -202,6 +208,10 @@ pub enum TrackSelectionScope {
Release(i64),
Playlist(i64),
Queue,
/// The federated section of the search results (its tracks).
FedSearch,
/// The tracklist of the open federated release view.
FedRelease(usize),
}
/// Vim-like Shift-V selection for line-oriented track lists. The selected
@@ -325,16 +335,41 @@ impl EditField {
}
}
/// What an add-to-playlist flow adds: local library tracks directly, or
/// federated tracks that are downloaded into the library first.
#[derive(Debug, Clone)]
pub enum PlaylistAddTarget {
Local(Vec<TrackItem>),
Fed(Vec<crate::federation::FedTrack>),
}
impl PlaylistAddTarget {
/// Short description for popup titles.
pub fn label(&self) -> String {
match self {
PlaylistAddTarget::Local(tracks) if tracks.len() == 1 => tracks[0].title.clone(),
PlaylistAddTarget::Fed(tracks) if tracks.len() == 1 => {
format!("{} (federation)", tracks[0].title)
}
PlaylistAddTarget::Local(tracks) => format!("{} tracks", tracks.len()),
PlaylistAddTarget::Fed(tracks) => format!("{} tracks (federation)", tracks.len()),
}
}
}
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the playlists (row 0 = "create new"); the track is added
/// on Enter.
AddToPlaylist { track: TrackItem, cursor: usize },
/// Name input for a new playlist; when `for_track` is set, the track is
/// added to it right after creation.
/// Pick one of the playlists (row 0 = "create new"); the target is
/// added on Enter (federated tracks are downloaded first).
AddToPlaylist {
target: PlaylistAddTarget,
cursor: usize,
},
/// Name input for a new playlist; when `for_target` is set, it is
/// added to the playlist right after creation.
NewPlaylist {
for_track: Option<TrackItem>,
for_target: Option<PlaylistAddTarget>,
input: LineEdit,
busy: bool,
},
+206 -35
View File
@@ -51,6 +51,10 @@ pub enum Effect {
FedPlay(crate::federation::FedTrack),
/// Assemble the federated artist card (fan-out to the owning peers).
FedOpenArtist(String),
/// Download federated tracks into the local library, one by one.
FedDownload {
tracks: Vec<crate::federation::FedTrack>,
},
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -278,18 +282,46 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
Action::AddToPlaylist => {
let track = selected_track(state).or_else(|| state.player.current.clone());
match track {
Some(track) => {
state.popup = Some(super::state::Popup::AddToPlaylist { track, cursor: 0 });
let fed = selected_fed_tracks(state);
let target = if !fed.is_empty() {
Some(super::state::PlaylistAddTarget::Fed(fed))
} else {
let local = selected_tracks(state);
if !local.is_empty() {
Some(super::state::PlaylistAddTarget::Local(local))
} else {
selected_track(state)
.or_else(|| state.player.current.clone())
.map(|track| super::state::PlaylistAddTarget::Local(vec![track]))
}
};
match target {
Some(target) => {
state.popup = Some(super::state::Popup::AddToPlaylist { target, cursor: 0 });
state.track_selection.clear();
}
None => state.status_message = Some("no track selected".into()),
}
None
}
Action::DownloadSelected => {
let tracks = selected_fed_tracks(state);
if tracks.is_empty() {
state.status_message =
Some("select federated tracks first (works in the federation results)".into());
None
} else {
state.track_selection.clear();
state.status_message = Some(format!(
"federation: скачивание {} трек(ов) в библиотеку…",
tracks.len()
));
Some(Effect::FedDownload { tracks })
}
}
Action::NewPlaylist => {
state.popup = Some(super::state::Popup::NewPlaylist {
for_track: None,
for_target: None,
input: crate::app::input::LineEdit::default(),
busy: false,
});
@@ -550,7 +582,7 @@ fn selected_release_card(state: &AppState) -> Option<crate::library::models::Rel
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).cloned()
}
GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None,
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
}
}
@@ -632,6 +664,14 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
TrackSelectionScope::Queue => {
state.queue_tab.cursor = value;
}
TrackSelectionScope::FedSearch => {
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
set_view_cursor(state, base + value);
}
TrackSelectionScope::FedRelease(_) => {
set_view_cursor(state, value + 1);
}
}
}
@@ -663,6 +703,23 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
)),
_ => None,
},
GlobalView::Search { cursor } => {
// Only the federated tracks section is selectable here.
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
let len = state.search.fed_tracks.len();
let relative = cursor.checked_sub(base)?;
(relative < len).then_some((TrackSelectionScope::FedSearch, relative, len))
}
GlobalView::FedRelease { index, cursor } => {
let len = fed_card_release(state, *index)?.tracks.len();
let relative = cursor.checked_sub(1)?;
(relative < len).then_some((
TrackSelectionScope::FedRelease(*index),
relative,
len,
))
}
_ => None,
},
Tab::Playlists => {
@@ -882,7 +939,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
results.tracks.get(offset).cloned()
}
GlobalView::FedArtist { .. } => None,
GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
@@ -922,7 +979,7 @@ fn selected_release_id(state: &AppState) -> Option<i64> {
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).map(|r| r.id)
}
GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None,
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
}
}
@@ -1188,7 +1245,8 @@ fn page_step(state: &AppState) -> isize {
}
Some(GlobalView::Release { .. })
| Some(GlobalView::Search { .. })
| Some(GlobalView::FedArtist { .. }) => lines,
| Some(GlobalView::FedArtist { .. })
| Some(GlobalView::FedRelease { .. }) => lines,
}
}
@@ -1358,23 +1416,31 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
state.track_selection.clear();
}
Some(GlobalView::FedArtist { cursor }) => {
// A flat tile grid of releases.
let total = fed_card_len(state) as isize;
if total == 0 {
return;
}
let next = (cursor as isize + dy).clamp(0, total - 1);
let columns = grid_columns() as isize;
let step = if dx != 0 { dx } else { dy * columns };
let next = (cursor as isize + step).clamp(0, total - 1);
set_view_cursor(state, next as usize);
state.track_selection.clear();
}
Some(GlobalView::FedRelease { index, cursor }) => {
let tracks = fed_card_release(state, index).map_or(0, |r| r.tracks.len()) as isize;
// Row 0 is the download button, 1..=tracks are the tracks.
let next = (cursor as isize + dy).clamp(0, tracks);
set_view_cursor(state, next as usize);
refresh_track_selection_cursor(state);
}
}
}
/// Selectable rows of the open federated artist card (its tracks).
/// Selectable tiles of the open federated artist card (its releases).
pub(crate) fn fed_card_len(state: &AppState) -> usize {
match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) => {
card.releases.iter().map(|r| r.tracks.len()).sum()
}
Some((_, Loadable::Ready(card))) => card.releases.len(),
_ => 0,
}
}
@@ -1385,7 +1451,8 @@ fn set_view_cursor(state: &mut AppState, value: usize) {
GlobalView::Artist { cursor, .. }
| GlobalView::Release { cursor, .. }
| GlobalView::Search { cursor }
| GlobalView::FedArtist { cursor } => *cursor = value,
| GlobalView::FedArtist { cursor }
| GlobalView::FedRelease { cursor, .. } => *cursor = value,
}
}
}
@@ -1429,6 +1496,9 @@ fn current_view_len(state: &AppState) -> usize {
+ state.search.fed_tracks.len()
}
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
Some(GlobalView::FedRelease { index, .. }) => {
fed_card_release(state, *index).map_or(0, |r| r.tracks.len() + 1)
}
}
}
@@ -1629,27 +1699,47 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}
None => fed_outcome(state, cursor),
},
Some(GlobalView::FedArtist { cursor }) => {
match &state.fed_artist_view {
Some((name, Loadable::Ready(card))) => {
match card
.releases
.iter()
.flat_map(|release| release.tracks.iter().map(move |t| (release, t)))
.nth(cursor)
{
Some((release, track)) => {
match fed_track_from_card(name, release, track) {
Some(fed) => Outcome::PlayFed(fed),
None => Outcome::Nothing,
}
}
None => Outcome::Nothing,
}
}
_ => Outcome::Nothing,
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => {
// Focus starts on the first track; Up from it reaches the
// download-release button (row 0).
let start = if card.releases[cursor].tracks.is_empty() {
0
} else {
1
};
Outcome::Push(GlobalView::FedRelease {
index: cursor,
cursor: start,
})
}
}
_ => Outcome::Nothing,
},
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
Some(_) if cursor == 0 => {
// The download-whole-release button.
match fed_release_tracks(state, index) {
tracks if tracks.is_empty() => Outcome::Nothing,
tracks => Outcome::DownloadFed(tracks),
}
}
Some(release) => {
let name = state
.fed_artist_view
.as_ref()
.map(|(name, _)| name.clone())
.unwrap_or_default();
match release
.tracks
.get(cursor - 1)
.and_then(|track| fed_track_from_card(&name, release, track))
{
Some(fed) => Outcome::PlayFed(fed),
None => Outcome::Nothing,
}
}
None => Outcome::Nothing,
},
};
match outcome {
Outcome::Push(view) => {
@@ -1672,10 +1762,90 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
state.active_tab = Tab::Global;
Some(Effect::FedOpenArtist(name))
}
Outcome::DownloadFed(tracks) => {
state.status_message = Some(format!(
"federation: скачивание {} трек(ов) в библиотеку…",
tracks.len()
));
Some(Effect::FedDownload { tracks })
}
Outcome::Nothing => None,
}
}
/// The open card's release by index.
pub(crate) fn fed_card_release(
state: &AppState,
index: usize,
) -> Option<&crate::federation::FedRelease> {
match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) => card.releases.get(index),
_ => None,
}
}
/// Every track of one card release as playable FedTracks.
pub(crate) fn fed_release_tracks(
state: &AppState,
index: usize,
) -> Vec<crate::federation::FedTrack> {
let Some((name, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
let Some(release) = card.releases.get(index) else {
return Vec::new();
};
release
.tracks
.iter()
.filter_map(|track| fed_track_from_card(name, release, track))
.collect()
}
/// Federated tracks covered by the active visual selection, or the single
/// one under the cursor in a federated context.
pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
// An active Shift-V range in a federated scope.
if let Some(scope) = state.track_selection.scope.clone() {
match scope {
TrackSelectionScope::FedSearch => {
let len = state.search.fed_tracks.len();
if let Some(indices) = state.track_selection.indices(&scope, len) {
return indices
.into_iter()
.filter_map(|i| state.search.fed_tracks.get(i).cloned())
.collect();
}
}
TrackSelectionScope::FedRelease(index) => {
let all = fed_release_tracks(state, index);
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
return indices.into_iter().filter_map(|i| all.get(i).cloned()).collect();
}
}
_ => {}
}
}
// No selection: the federated track under the cursor.
match state.global.stack.last() {
Some(GlobalView::Search { cursor }) => {
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
cursor
.checked_sub(base)
.and_then(|i| state.search.fed_tracks.get(i).cloned())
.into_iter()
.collect()
}
Some(GlobalView::FedRelease { index, cursor }) => cursor
.checked_sub(1)
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
/// What Enter resolved to in the current view.
enum Outcome {
Push(GlobalView),
@@ -1685,6 +1855,7 @@ enum Outcome {
},
PlayFed(crate::federation::FedTrack),
OpenFedArtist(String),
DownloadFed(Vec<crate::federation::FedTrack>),
Nothing,
}