Improved fed artist page
This commit is contained in:
Generated
+15
-39
@@ -1078,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 2.0.117",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1216,7 +1216,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1373,7 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1484,7 +1484,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#555222d6d7527a1da8188814b0f308ee1562cb5d"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -3039,7 +3039,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#555222d6d7527a1da8188814b0f308ee1562cb5d"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -3352,7 +3352,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3427,7 +3427,7 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -4076,16 +4076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4510,7 +4501,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4568,7 +4559,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4660,7 +4651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5289,7 +5280,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5590,18 +5581,6 @@ dependencies = [
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -6209,7 +6188,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6585,9 +6564,6 @@ name = "winnow"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
@@ -6818,7 +6794,7 @@ version = "3.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -6972,7 +6948,7 @@ version = "3.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ command = { GoToTab = 3 }
|
||||
key_sequence = "5"
|
||||
command = { GoToTab = 4 }
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "y"
|
||||
command = "DownloadSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "a"
|
||||
command = "QueueAddNext"
|
||||
|
||||
+190
-10
@@ -27,8 +27,30 @@ const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||
struct CatalogRequest {
|
||||
/// Artist display name; matched case-insensitively by the owner.
|
||||
artist: String,
|
||||
/// What is being asked for: `None`/"catalog" — the JSON catalog;
|
||||
/// "artist_image" — the artist's image; "release_cover" — the cover of
|
||||
/// `release`. Image responses are a JSON header line + raw bytes.
|
||||
#[serde(default)]
|
||||
want: Option<String>,
|
||||
#[serde(default)]
|
||||
release: Option<String>,
|
||||
}
|
||||
|
||||
/// Header line preceding raw image bytes (artist image / release cover).
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ImageHeader {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
mime_type: String,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Images above this size are skipped rather than transferred.
|
||||
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct CatalogResponse {
|
||||
ok: bool,
|
||||
@@ -94,22 +116,106 @@ pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc<Library>, ow
|
||||
async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointId) -> Result<()> {
|
||||
let request: CatalogRequest =
|
||||
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(peer = %stream.peer_id, artist = %request.artist, "peer requested a catalog");
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
artist = %request.artist,
|
||||
want = request.want.as_deref().unwrap_or("catalog"),
|
||||
"peer requested a catalog"
|
||||
);
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist))
|
||||
.await?
|
||||
.unwrap_or_else(|err| CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
artist: None,
|
||||
});
|
||||
let payload = serde_json::to_vec(&response)?;
|
||||
stream.send.write_all(&payload).await?;
|
||||
match request.want.as_deref() {
|
||||
None | Some("catalog") => {
|
||||
let response =
|
||||
tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist))
|
||||
.await?
|
||||
.unwrap_or_else(|err| CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
artist: None,
|
||||
});
|
||||
let payload = serde_json::to_vec(&response)?;
|
||||
stream.send.write_all(&payload).await?;
|
||||
}
|
||||
Some(want @ ("artist_image" | "release_cover")) => {
|
||||
let want_cover = want == "release_cover";
|
||||
let release = request.release.clone().unwrap_or_default();
|
||||
let artist = request.artist.clone();
|
||||
let path = tokio::task::spawn_blocking(move || -> Result<Option<String>> {
|
||||
if want_cover {
|
||||
library.release_cover_by_names(&artist, &release)
|
||||
} else {
|
||||
let Some(artist_id) = library.artist_id_by_name(&artist)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
library.artist_image(artist_id)
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
serve_image(&mut stream, path.as_deref()).await?;
|
||||
}
|
||||
Some(other) => {
|
||||
let response = CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("unknown request kind '{other}'")),
|
||||
artist: None,
|
||||
};
|
||||
stream.send.write_all(&serde_json::to_vec(&response)?).await?;
|
||||
}
|
||||
}
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Streams one image file: header line, then the raw bytes.
|
||||
async fn serve_image(stream: &mut ByteStream, path: Option<&str>) -> Result<()> {
|
||||
let loaded = match path {
|
||||
Some(path) => match tokio::fs::read(path).await {
|
||||
Ok(bytes) if !bytes.is_empty() && bytes.len() as u64 <= MAX_IMAGE_BYTES => {
|
||||
Some((bytes, image_mime_by_path(path)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let header = match &loaded {
|
||||
Some((bytes, mime)) => ImageHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: (*mime).to_string(),
|
||||
size: bytes.len() as u64,
|
||||
},
|
||||
None => ImageHeader {
|
||||
ok: false,
|
||||
error: Some("no image".to_string()),
|
||||
..ImageHeader::default()
|
||||
},
|
||||
};
|
||||
let mut line = serde_json::to_vec(&header)?;
|
||||
line.push(b'\n');
|
||||
stream.send.write_all(&line).await?;
|
||||
if let Some((bytes, _)) = &loaded {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn image_mime_by_path(path: &str) -> &'static str {
|
||||
match std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
_ => "image/jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds this instance's library slice for `artist`.
|
||||
fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||
let Some(artist_id) = library.artist_id_by_name(artist)? else {
|
||||
@@ -173,6 +279,8 @@ pub async fn fetch_catalog(
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
||||
let mut line = serde_json::to_vec(&CatalogRequest {
|
||||
artist: artist.to_string(),
|
||||
want: None,
|
||||
release: None,
|
||||
})?;
|
||||
line.push(b'\n');
|
||||
stream.send.write_all(&line).await?;
|
||||
@@ -200,6 +308,59 @@ pub async fn fetch_catalog(
|
||||
response.artist.context("empty catalog response")
|
||||
}
|
||||
|
||||
/// Fetches an image (artist image or a release cover) from a peer over the
|
||||
/// catalog protocol. `release: None` asks for the artist image. Returns the
|
||||
/// raw bytes and a file extension, or None when the peer has no image.
|
||||
pub async fn fetch_image(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
artist: &str,
|
||||
release: Option<&str>,
|
||||
) -> Result<Option<(Vec<u8>, &'static str)>> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, CATALOG_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
||||
let mut line = serde_json::to_vec(&CatalogRequest {
|
||||
artist: artist.to_string(),
|
||||
want: Some(if release.is_some() {
|
||||
"release_cover".to_string()
|
||||
} else {
|
||||
"artist_image".to_string()
|
||||
}),
|
||||
release: release.map(str::to_string),
|
||||
})?;
|
||||
line.push(b'\n');
|
||||
stream.send.write_all(&line).await?;
|
||||
stream.send.finish()?;
|
||||
|
||||
let header: ImageHeader =
|
||||
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)
|
||||
.context("malformed image header")?;
|
||||
if !header.ok || header.size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
header.size <= MAX_IMAGE_BYTES,
|
||||
"image of {} bytes exceeds the {MAX_IMAGE_BYTES} byte limit",
|
||||
header.size
|
||||
);
|
||||
let mut bytes = vec![0u8; header.size as usize];
|
||||
stream
|
||||
.recv
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.context("stream ended inside the image")?;
|
||||
let extension = match header.mime_type.as_str() {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/bmp" => "bmp",
|
||||
_ => "jpg",
|
||||
};
|
||||
Ok(Some((bytes, extension)))
|
||||
}
|
||||
|
||||
/// AsyncRead adapter over the receive half of a byte stream.
|
||||
struct StreamReader<'a>(&'a mut ByteStream);
|
||||
|
||||
@@ -224,6 +385,10 @@ pub struct FedArtistCard {
|
||||
pub name: String,
|
||||
/// Peers whose catalogs contributed to the card.
|
||||
pub peers: usize,
|
||||
/// Every contributing peer (hex ids) — where images are fetched from.
|
||||
pub owners: Vec<String>,
|
||||
/// Local cache path of the artist image, streamed from a peer.
|
||||
pub image_path: Option<String>,
|
||||
pub releases: Vec<FedRelease>,
|
||||
}
|
||||
|
||||
@@ -232,6 +397,10 @@ pub struct FedRelease {
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
/// Peers holding this release (hex ids).
|
||||
pub owners: Vec<String>,
|
||||
/// Local cache path of the cover, streamed from a peer.
|
||||
pub cover_path: Option<String>,
|
||||
pub tracks: Vec<FedCardTrack>,
|
||||
}
|
||||
|
||||
@@ -254,7 +423,11 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
||||
let mut releases: Vec<FedRelease> = Vec::new();
|
||||
let mut release_index: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
let mut card_owners: Vec<String> = Vec::new();
|
||||
for (owner_hex, catalog) in catalogs {
|
||||
if !card_owners.contains(&owner_hex) {
|
||||
card_owners.push(owner_hex.clone());
|
||||
}
|
||||
for release in catalog.releases {
|
||||
let release_key = music_dht::normalize_name(&release.title);
|
||||
let slot = *release_index.entry(release_key).or_insert_with(|| {
|
||||
@@ -262,11 +435,16 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
||||
title: release.title.clone(),
|
||||
release_type: release.release_type.clone(),
|
||||
year: None,
|
||||
owners: Vec::new(),
|
||||
cover_path: None,
|
||||
tracks: Vec::new(),
|
||||
});
|
||||
releases.len() - 1
|
||||
});
|
||||
let merged = &mut releases[slot];
|
||||
if !merged.owners.contains(&owner_hex) {
|
||||
merged.owners.push(owner_hex.clone());
|
||||
}
|
||||
if merged.year.is_none() {
|
||||
merged.year = release.year;
|
||||
}
|
||||
@@ -320,6 +498,8 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
||||
FedArtistCard {
|
||||
name: name.to_string(),
|
||||
peers,
|
||||
owners: card_owners,
|
||||
image_path: None,
|
||||
releases,
|
||||
}
|
||||
}
|
||||
|
||||
+70
-1
@@ -580,10 +580,80 @@ impl Federation {
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
|
||||
/// Directory for streamed (never library-imported) card artwork.
|
||||
fn art_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir.join("art")
|
||||
}
|
||||
|
||||
/// Returns a cached-or-streamed image for the card: the artist image
|
||||
/// (`release: None`) or a release cover. Peers are tried in order until
|
||||
/// one answers with an image; the result lands in the art cache and its
|
||||
/// local path is returned.
|
||||
pub async fn card_image(
|
||||
&self,
|
||||
owners: &[String],
|
||||
artist: &str,
|
||||
release: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let dir = self.art_cache_dir();
|
||||
let stem = match release {
|
||||
Some(release) => format!(
|
||||
"cover-{}-{}",
|
||||
sanitize_file_stem(artist),
|
||||
sanitize_file_stem(release)
|
||||
),
|
||||
None => format!("artist-{}", sanitize_file_stem(artist)),
|
||||
};
|
||||
// Reuse a previously streamed copy of any known image type.
|
||||
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
|
||||
let path = dir.join(format!("{stem}.{extension}"));
|
||||
if path.is_file() {
|
||||
return Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
let service = self.service().await.ok()?;
|
||||
tokio::fs::create_dir_all(&dir).await.ok()?;
|
||||
for owner in owners {
|
||||
let Ok(owner) = EndpointId::from_str(owner) else {
|
||||
continue;
|
||||
};
|
||||
let fetched = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
catalog::fetch_image(&service, owner, artist, release),
|
||||
)
|
||||
.await;
|
||||
match fetched {
|
||||
Ok(Ok(Some((bytes, extension)))) => {
|
||||
let path = dir.join(format!("{stem}.{extension}"));
|
||||
if tokio::fs::write(&path, &bytes).await.is_ok() {
|
||||
return Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) => continue,
|
||||
Ok(Err(err)) => tracing::debug!(peer = %owner, "image fetch failed: {err:#}"),
|
||||
Err(_) => tracing::debug!(peer = %owner, "image fetch timed out"),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Downloads a federated track straight into the local library
|
||||
/// (regardless of the save-on-listen setting) and returns the imported
|
||||
/// track. Own/already-local tracks resolve without downloading.
|
||||
pub async fn download_to_library(self: &Arc<Self>, fed: &FedTrack) -> Result<TrackItem> {
|
||||
let playable = self.fetch_playable(fed, true).await?;
|
||||
Ok(playable.track)
|
||||
}
|
||||
|
||||
/// Prepares a federated track for playback: local tracks resolve
|
||||
/// straight to the library; remote tracks are downloaded — into the
|
||||
/// library when save-on-listen is enabled, into the cache otherwise.
|
||||
pub async fn prepare_playback(self: &Arc<Self>, fed: &FedTrack) -> Result<FedPlayable> {
|
||||
let save = self.settings().save_on_listen;
|
||||
self.fetch_playable(fed, save).await
|
||||
}
|
||||
|
||||
async fn fetch_playable(self: &Arc<Self>, fed: &FedTrack, save: bool) -> Result<FedPlayable> {
|
||||
let service = self.service().await?;
|
||||
let item_id =
|
||||
audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?;
|
||||
@@ -608,7 +678,6 @@ impl Federation {
|
||||
|
||||
let owner = EndpointId::from_str(&fed.owner)
|
||||
.map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?;
|
||||
let save = self.settings().save_on_listen;
|
||||
let dir = if save { &self.media_dir } else { &self.cache_dir };
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
|
||||
|
||||
@@ -731,6 +731,24 @@ impl Library {
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Cover path of an artist's release, matched by names (for the peer
|
||||
/// catalog image protocol).
|
||||
pub fn release_cover_by_names(&self, artist: &str, release: &str) -> Result<Option<String>> {
|
||||
let conn = self.lock();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT r.cover_path FROM releases r
|
||||
JOIN release_artists ra ON ra.release_id = r.id
|
||||
JOIN artists a ON a.id = ra.artist_id
|
||||
WHERE a.name = ?1 COLLATE NOCASE AND r.title = ?2 COLLATE NOCASE
|
||||
LIMIT 1",
|
||||
params![artist, release],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// Image of one artist, for the federation metadata exchange.
|
||||
pub fn artist_image(&self, artist_id: i64) -> Result<Option<String>> {
|
||||
let conn = self.lock();
|
||||
|
||||
+196
-63
@@ -24,6 +24,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor),
|
||||
Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor),
|
||||
Some(GlobalView::FedArtist { cursor }) => draw_fed_artist(frame, area, state, *cursor),
|
||||
Some(GlobalView::FedRelease { index, cursor }) => {
|
||||
draw_fed_release(frame, area, state, *index, *cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +855,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute cursor indices covered by an active Shift-V range in the
|
||||
// federated tracks section.
|
||||
let fed_scope = crate::app::state::TrackSelectionScope::FedSearch;
|
||||
let mut fed_selected: std::collections::HashSet<usize> = Default::default();
|
||||
if state.track_selection.is_active_for(&fed_scope) {
|
||||
let base = results.len() + state.search.fed_artists.len();
|
||||
if let Some(indices) = state
|
||||
.track_selection
|
||||
.indices(&fed_scope, state.search.fed_tracks.len())
|
||||
{
|
||||
fed_selected.extend(indices.into_iter().map(|i| base + i));
|
||||
}
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
.position(|(_, _, c)| *c == Some(cursor))
|
||||
@@ -869,6 +886,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
if let Some(row_index) = row_cursor
|
||||
&& fed_selected.contains(&row_index)
|
||||
&& row_index != cursor
|
||||
{
|
||||
frame.buffer_mut().set_style(rect, theme::selection());
|
||||
}
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
@@ -881,7 +904,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
||||
let Some((name, data)) = &state.fed_artist_view else {
|
||||
return centered_line(frame, area, Line::styled("no card is open", theme::dim()));
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" {name} — federation "));
|
||||
let inner = bordered(frame, area, format!(" Federation ▸ {name} "));
|
||||
let card = match data {
|
||||
Loadable::Loading => {
|
||||
return centered_line(
|
||||
@@ -896,82 +919,192 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
||||
Loadable::Ready(card) => card,
|
||||
};
|
||||
|
||||
// All rows are one line tall: (line, right column, cursor index).
|
||||
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||
rows.push((
|
||||
// Header: artist image (streamed from a peer) left, stats right.
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
|
||||
let [art_area, _, info_area] = Layout::horizontal([
|
||||
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(header_area);
|
||||
draw_art(
|
||||
frame,
|
||||
Rect {
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, card.image_path.as_ref()),
|
||||
);
|
||||
let tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
|
||||
let info = vec![
|
||||
Line::default(),
|
||||
Line::styled(name.clone(), theme::header()),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!(
|
||||
"{} релизов · {} треков · с {} пиров",
|
||||
card.releases.len(),
|
||||
card.releases.iter().map(|r| r.tracks.len()).sum::<usize>(),
|
||||
tracks_total,
|
||||
card.peers
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let mut index = 0;
|
||||
for release in &card.releases {
|
||||
rows.push((Line::default(), None, None));
|
||||
let mut header = release.title.clone();
|
||||
if let Some(year) = release.year {
|
||||
header.push_str(&format!(" ({year})"));
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled(header, theme::header()),
|
||||
Span::styled(format!(" {}", release.release_type), theme::dim()),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
for track in &release.tracks {
|
||||
let number = track
|
||||
.track_number
|
||||
.map(|n| format!("{n:>2}. "))
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let duration = track
|
||||
.duration_seconds
|
||||
.map(|d| {
|
||||
let total = d.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let sources = if track.sources.len() > 1 {
|
||||
format!("{} · {} пиров", duration, track.sources.len())
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(format!("{number}{}", track.title)),
|
||||
]),
|
||||
Some(sources),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
Line::styled("enter: открыть релиз · esc: назад", theme::dim()),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
if card.releases.is_empty() {
|
||||
return centered_line(
|
||||
frame,
|
||||
content_area,
|
||||
Line::styled("пиры не отдали ни одного релиза", theme::dim()),
|
||||
);
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
// Release tiles: a flat grid ordered by year, scrolled to the cursor.
|
||||
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
||||
let visible_rows = usize::from((content_area.height / TILE_HEIGHT).max(1));
|
||||
let cursor_row = cursor / columns;
|
||||
let total_rows = card.releases.len().div_ceil(columns);
|
||||
let first_row = cursor_row
|
||||
.saturating_sub(visible_rows / 2)
|
||||
.min(total_rows.saturating_sub(visible_rows));
|
||||
for (offset, row) in (first_row..total_rows).take(visible_rows).enumerate() {
|
||||
for column in 0..columns {
|
||||
let index = row * columns + column;
|
||||
let Some(release) = card.releases.get(index) else {
|
||||
break;
|
||||
};
|
||||
let tile = Rect {
|
||||
x: content_area.x + (column as u16) * TILE_WIDTH,
|
||||
y: content_area.y + (offset as u16) * TILE_HEIGHT,
|
||||
width: TILE_WIDTH,
|
||||
height: TILE_HEIGHT.min(content_area.height.saturating_sub((offset as u16) * TILE_HEIGHT)),
|
||||
};
|
||||
if tile.height < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut meta = release.release_type.clone();
|
||||
if let Some(year) = release.year {
|
||||
meta = format!("{meta} · {year}");
|
||||
}
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, release.cover_path.as_ref()),
|
||||
&release.title,
|
||||
&meta,
|
||||
index == cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usize, cursor: usize) {
|
||||
let Some((name, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return centered_line(frame, area, Line::styled("no card is open", theme::dim()));
|
||||
};
|
||||
let Some(release) = card.releases.get(index) else {
|
||||
return centered_line(frame, area, Line::styled("release is gone", theme::dim()));
|
||||
};
|
||||
let inner = bordered(
|
||||
frame,
|
||||
area,
|
||||
format!(" Federation ▸ {name} ▸ {} ", release.title),
|
||||
);
|
||||
|
||||
// Header: cover left; title, meta and the download button right.
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
|
||||
let [art_area, _, info_area] = Layout::horizontal([
|
||||
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(header_area);
|
||||
draw_art(
|
||||
frame,
|
||||
Rect {
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, release.cover_path.as_ref()),
|
||||
);
|
||||
let mut meta = release.release_type.clone();
|
||||
if let Some(year) = release.year {
|
||||
meta.push_str(&format!(" · {year}"));
|
||||
}
|
||||
meta.push_str(&format!(
|
||||
" · {} треков · с {} пиров",
|
||||
release.tracks.len(),
|
||||
release.owners.len().max(1)
|
||||
));
|
||||
let button_style = if cursor == 0 {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::accent()
|
||||
};
|
||||
let info = vec![
|
||||
Line::default(),
|
||||
Line::styled(release.title.clone(), theme::header()),
|
||||
Line::styled(meta, theme::dim()),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!(" ⤓ Скачать релиз целиком ({}) ", release.tracks.len()),
|
||||
button_style,
|
||||
),
|
||||
Line::styled("shift+v: выделение · y: скачать · p: в плейлист", theme::dim()),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Tracklist: rows 1..=n of the cursor space.
|
||||
let scope = crate::app::state::TrackSelectionScope::FedRelease(index);
|
||||
let visible = usize::from(content_area.height.max(1));
|
||||
let cursor_track = cursor.saturating_sub(1);
|
||||
let first = cursor_track
|
||||
.saturating_sub(visible / 2)
|
||||
.min(release.tracks.len().saturating_sub(visible));
|
||||
for (offset, (position, track)) in release
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|(_, _, c)| *c == Some(cursor))
|
||||
.unwrap_or(0);
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = cursor_row
|
||||
.saturating_sub(visible / 2)
|
||||
.min(rows.len().saturating_sub(visible));
|
||||
for (offset, (line, right, row_cursor)) in
|
||||
rows.into_iter().enumerate().skip(first).take(visible)
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(visible)
|
||||
.enumerate()
|
||||
{
|
||||
let rect = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (offset - first) as u16,
|
||||
width: inner.width,
|
||||
x: content_area.x,
|
||||
y: content_area.y + offset as u16,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let number = track
|
||||
.track_number
|
||||
.map(|n| format!("{n:>2}. "))
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let duration = track
|
||||
.duration_seconds
|
||||
.map(|d| {
|
||||
let total = d.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let right = if track.sources.len() > 1 {
|
||||
format!("{duration} · {} пиров", track.sources.len())
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
let in_selection = state.track_selection.contains(&scope, position);
|
||||
let line = Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(format!("{number}{}", track.title)),
|
||||
]);
|
||||
if in_selection && cursor != position + 1 {
|
||||
frame.buffer_mut().set_style(rect, theme::selection());
|
||||
}
|
||||
draw_row(frame, rect, line, Some(right), cursor == position + 1);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
match state.popup.as_ref() {
|
||||
Some(Popup::AddToPlaylist { track, cursor }) => {
|
||||
draw_picker(frame, state, &track.title, *cursor)
|
||||
Some(Popup::AddToPlaylist { target, cursor }) => {
|
||||
draw_picker(frame, state, &target.label(), *cursor)
|
||||
}
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
||||
Some(Popup::Edit {
|
||||
|
||||
Reference in New Issue
Block a user