Improved fed artist page

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