Improved fed artist page
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user