fed fixes

This commit is contained in:
Ultradesu
2026-07-21 00:12:45 +03:00
parent 151e2cf337
commit da5d2410ac
24 changed files with 2660 additions and 461 deletions
+6 -2
View File
@@ -21,8 +21,12 @@ pub enum Action {
PlayPause,
NextTrack,
PrevTrack,
SeekForward { seconds: u32 },
SeekBackward { seconds: u32 },
SeekForward {
seconds: u32,
},
SeekBackward {
seconds: u32,
},
VolumeUp,
VolumeDown,
ToggleShuffle,
+22
View File
@@ -49,6 +49,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
// One-shot commands have no live effect.
Command::Quit
| Command::Import(_)
| Command::Open(_)
| Command::Volume(_)
| Command::Seek(_)
| Command::SeekTo(_)
@@ -165,6 +166,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
Command::Search(_) => {}
Command::Quit => state.should_quit = true,
Command::Import(path) => super::spawn_import(state, runtime, &path),
Command::Open(link) => open_frid_link(state, runtime, link),
Command::Volume(value) => {
state.player.volume = value;
super::perform_effect(state, runtime, Effect::SetVolume(value));
@@ -208,6 +210,26 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
}
}
fn open_frid_link(state: &mut AppState, runtime: &Runtime, link: String) {
let Some(content_id) = crate::share::parse_frid_content_id(&link) else {
state.status_message = Some("usage: :open frid://<content_id>".into());
return;
};
state.status_message = Some("federation: opening shared track…".into());
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match federation.track_by_content_id(&content_id).await {
Ok(track) => AppEvent::EnqueueTracks {
tracks: vec![crate::federation::pending_track(&track)],
next: false,
},
Err(err) => AppEvent::StatusMessage(format!("open failed: {err:#}")),
};
let _ = tx.send(event);
});
}
/// Esc: close the line and undo any live effect it had.
fn cancel(state: &mut AppState) {
retract_live(state);
+19
View File
@@ -25,6 +25,8 @@ pub enum Command {
/// `:import <path>` — import an audio file or a directory into the
/// library.
Import(String),
/// `:open frid://...` — open a shared federation content link.
Open(String),
/// `:volume 40` (also `:vol`) — set the volume precisely.
Volume(u8),
/// `:seek +30` / `:seek -10` — relative seek in seconds.
@@ -82,6 +84,13 @@ pub fn parse(input: &str) -> Parsed {
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
}
}
"open" => {
let value = input.trim_start().split_once(char::is_whitespace);
match value.map(|(_, rest)| rest.trim()) {
Some(value) if !value.is_empty() => Parsed::Command(Command::Open(value.into())),
_ => Parsed::Invalid("usage: :open frid://<content_id>".to_string()),
}
}
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
@@ -162,7 +171,17 @@ mod tests {
parse("import ~/Music/My Album"),
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
);
assert_eq!(
parse(
"open frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
),
Parsed::Command(Command::Open(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
.to_string()
))
);
assert!(matches!(parse("import"), Parsed::Invalid(_)));
assert!(matches!(parse("open"), Parsed::Invalid(_)));
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
+7
View File
@@ -119,6 +119,13 @@ pub enum AppEvent {
placeholder_id: i64,
result: Result<Box<crate::federation::FedPlayable>, String>,
},
/// Rich metadata for a federated track-info preview arrived without
/// downloading the audio file.
FedTrackInfoLoaded {
placeholder_id: i64,
item_id: String,
result: Result<TrackItem, String>,
},
/// This peer's connection ticket, requested from the Federation tab.
FedTicket(Result<String, String>),
}
+64 -25
View File
@@ -1,8 +1,8 @@
pub mod action;
mod cmdline;
pub mod input;
pub mod command;
pub mod event;
pub mod input;
mod popup;
pub mod state;
pub mod update;
@@ -290,9 +290,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
}
for detail in state.release_views.values() {
if let state::Loadable::Ready(detail) = detail
&& let Some(path) = &detail.cover_path {
wanted.push((path.clone(), header.0, header.1));
}
&& let Some(path) = &detail.cover_path
{
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 {
@@ -501,6 +502,26 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
});
}
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
Effect::FedFetchTrackInfo { tracks } => {
for (placeholder_id, fed_track) in tracks {
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let mut preview = crate::federation::pending_track(&fed_track);
preview.id = placeholder_id;
let item_id = fed_track.item_id.clone();
let result = federation
.track_info(preview)
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedTrackInfoLoaded {
placeholder_id,
item_id,
result,
});
});
}
}
Effect::RemoveQueueIndices {
restart_paused,
stop,
@@ -900,9 +921,10 @@ pub(super) fn spawn_import(state: &mut AppState, runtime: &Runtime, path: &str)
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = std::env::home_dir() {
return home.join(rest);
}
&& let Some(home) = std::env::home_dir()
{
return home.join(rest);
}
PathBuf::from(path)
}
@@ -1071,11 +1093,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
state.federation.status = Some(status);
}
AppEvent::FedSearchLoaded { seq, result } => {
if runtime
.search_seq
.load(std::sync::atomic::Ordering::SeqCst)
!= seq
{
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
return;
}
state.search.fed_loading = false;
@@ -1153,6 +1171,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
}
}
AppEvent::FedTrackInfoLoaded {
placeholder_id,
item_id,
result,
} => match result {
Ok(enriched) => {
if let Some(state::Popup::TrackInfo { tracks, .. }) = &mut state.popup
&& let Some(slot) = tracks.iter_mut().find(|track| {
track.id == placeholder_id
&& track.fed.as_ref().is_some_and(|fed| fed.item_id == item_id)
})
{
*slot = enriched;
}
}
Err(message) => {
state.status_message = Some(format!("federation metadata: {message}"));
}
},
AppEvent::FedArtistLoaded { name, result } => {
if let Some((current, data)) = &mut state.fed_artist_view
&& *current == name
@@ -1250,21 +1287,23 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
state.release_views.insert(id, entry);
// A Shift-J jump was waiting for this release: focus its track.
if let Some((release_id, track_id)) = state.pending_release_focus
&& release_id == id {
state.pending_release_focus = None;
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
let position = detail
.tracks
.iter()
.position(|t| t.id == track_id)
.unwrap_or(0);
if let Some(state::GlobalView::Release { id: top, cursor }) =
state.global.stack.last_mut()
&& *top == release_id {
*cursor = position;
}
&& release_id == id
{
state.pending_release_focus = None;
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
let position = detail
.tracks
.iter()
.position(|t| t.id == track_id)
.unwrap_or(0);
if let Some(state::GlobalView::Release { id: top, cursor }) =
state.global.stack.last_mut()
&& *top == release_id
{
*cursor = position;
}
}
}
}
AppEvent::SearchLoaded { seq, result } => {
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
+69 -1
View File
@@ -3,6 +3,8 @@
//! the state, handled as an owned value and put back unless the action
//! closed it.
use std::io::Write as _;
use std::process::{Command, Stdio};
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
@@ -111,7 +113,10 @@ pub fn handle_paste(state: &mut AppState, pasted: &str) {
// Edit form
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
#[allow(
clippy::too_many_arguments,
reason = "owned popup state passed back in"
)]
fn handle_edit(
state: &mut AppState,
runtime: &Runtime,
@@ -342,6 +347,22 @@ fn handle_track_info(
scroll: 0,
});
}
KeyCode::Char('c') => {
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
match crate::share::track_share_link(track) {
Some(link) => match copy_to_clipboard(&link) {
Ok(()) => state.status_message = Some("frid link copied".into()),
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
},
None => state.status_message = Some("no content id for this track yet".into()),
}
}
state.popup = Some(Popup::TrackInfo {
tracks,
cursor: cursor.min(len.saturating_sub(1)),
scroll,
});
}
_ => {
state.popup = Some(Popup::TrackInfo {
tracks,
@@ -352,6 +373,53 @@ fn handle_track_info(
}
}
fn copy_to_clipboard(text: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
return run_clipboard_command("pbcopy", &[], text);
}
#[cfg(target_os = "windows")]
{
return run_clipboard_command("cmd", &["/C", "clip"], text);
}
#[cfg(all(unix, not(target_os = "macos")))]
{
for (program, args) in [
("wl-copy", &[][..]),
("xclip", &["-selection", "clipboard"][..]),
("xsel", &["--clipboard", "--input"][..]),
] {
if run_clipboard_command(program, args, text).is_ok() {
return Ok(());
}
}
Err("clipboard command not found (tried wl-copy, xclip, xsel)".into())
}
}
fn run_clipboard_command(program: &str, args: &[&str], text: &str) -> Result<(), String> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|err| format!("{program}: {err}"))?;
let Some(stdin) = child.stdin.as_mut() else {
return Err(format!("{program}: stdin unavailable"));
};
stdin
.write_all(text.as_bytes())
.map_err(|err| format!("{program}: {err}"))?;
drop(child.stdin.take());
let status = child.wait().map_err(|err| format!("{program}: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{program}: exited with {status}"))
}
}
// ---------------------------------------------------------------------------
// Add-to-playlist picker & new playlist
// ---------------------------------------------------------------------------
+45 -7
View File
@@ -120,24 +120,31 @@ impl Default for GlobalTab {
}
}
/// Releases of an artist in display order: grouped by type (albums, EPs,
/// singles, compilations, then anything else), keeping server order within a
/// Releases of an artist in display order: grouped by type (albums, singles,
/// EPs, compilations, then anything else), keeping server order within a
/// group. Returns (group label, indices into the original slice). Cursor
/// positions use this flattened order, so update() and ui must both go
/// through here.
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
release_type_groups(releases, |release| &release.release_type)
}
fn release_type_groups<T>(
items: &[T],
release_type: impl Fn(&T) -> &str,
) -> Vec<(&'static str, Vec<usize>)> {
const GROUPS: [(&str, &str); 4] = [
("album", "Albums"),
("ep", "EPs"),
("single", "Singles"),
("ep", "EPs"),
("compilation", "Compilations"),
];
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
for (kind, label) in GROUPS {
let indices: Vec<usize> = releases
let indices: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
.map(|(i, _)| i)
.collect();
if !indices.is_empty() {
@@ -145,7 +152,7 @@ pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>
}
}
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
let other: Vec<usize> = (0..releases.len()).filter(|i| !known.contains(i)).collect();
let other: Vec<usize> = (0..items.len()).filter(|i| !known.contains(i)).collect();
if !other.is_empty() {
groups.push(("Other", other));
}
@@ -164,10 +171,39 @@ pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
/// rows, chunked by the column count. Values are display-order positions.
/// Vertical cursor movement must follow these rows to match the rendering.
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
grouped_release_rows(release_groups(releases), columns)
}
pub fn fed_release_groups(
releases: &[crate::federation::FedRelease],
) -> Vec<(&'static str, Vec<usize>)> {
release_type_groups(releases, |release| &release.release_type)
}
/// Flattened display order of federated releases (concatenated groups).
pub fn fed_release_display_order(releases: &[crate::federation::FedRelease]) -> Vec<usize> {
fed_release_groups(releases)
.into_iter()
.flat_map(|(_, indices)| indices)
.collect()
}
/// Visual tile-grid rows of the federated releases section.
pub fn fed_release_rows(
releases: &[crate::federation::FedRelease],
columns: usize,
) -> Vec<Vec<usize>> {
grouped_release_rows(fed_release_groups(releases), columns)
}
fn grouped_release_rows(
groups: Vec<(&'static str, Vec<usize>)>,
columns: usize,
) -> Vec<Vec<usize>> {
let columns = columns.max(1);
let mut rows = Vec::new();
let mut position = 0;
for (_, group) in release_groups(releases) {
for (_, group) in groups {
for chunk in group.chunks(columns) {
rows.push((position..position + chunk.len()).collect());
position += chunk.len();
@@ -212,6 +248,8 @@ pub enum TrackSelectionScope {
FedSearch,
/// The tracklist of the open federated release view.
FedRelease(usize),
/// The appears-on track list of the open federated artist card.
FedAppearsOn,
}
/// Vim-like Shift-V selection for line-oriented track lists. The selected
+274 -59
View File
@@ -5,7 +5,8 @@ use crate::library::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
TrackSelectionScope, ViewMode, release_display_order, release_rows,
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
release_display_order, release_rows,
};
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
@@ -54,6 +55,10 @@ pub enum Effect {
FedDownload {
tracks: Vec<crate::federation::FedTrack>,
},
/// Fetch richer metadata for federated tracks without downloading audio.
FedFetchTrackInfo {
tracks: Vec<(i64, crate::federation::FedTrack)>,
},
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -280,14 +285,25 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
let tracks = selected_tracks(state);
if tracks.is_empty() {
state.status_message = Some("no track selected".into());
None
} else {
let fed_tracks = tracks
.iter()
.filter(|track| track_info_needs_fed_metadata(track))
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
.collect::<Vec<_>>();
state.popup = Some(super::state::Popup::TrackInfo {
tracks,
cursor: 0,
scroll: 0,
});
if fed_tracks.is_empty() {
None
} else {
state.status_message = Some("federation: fetching track metadata…".to_string());
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
}
}
None
}
Action::RemoveFromQueue => remove_selected_from_queue(state),
Action::QueueAddNext => queue_add(state, true),
@@ -372,6 +388,17 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
}
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
track.fed.is_some()
&& (track.featured_artists.is_empty()
|| track.audio_format.is_none()
|| track.audio_bitrate.is_none()
|| track.audio_sample_rate.is_none()
|| track.audio_bit_depth.is_none()
|| track.file_size_bytes.is_none()
|| track.file_path.is_empty())
}
/// `e`: open the metadata edit form for whatever is under the cursor —
/// an artist tile, a release, a track or a playlist.
fn open_edit_popup(state: &mut AppState) {
@@ -402,11 +429,19 @@ fn open_edit_popup(state: &mut AppState) {
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
return;
};
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
state.popup = Some(artist_edit_popup(
artist.id,
&artist.name,
artist.image_path,
));
return;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
state.popup = Some(artist_edit_popup(
artist.id,
&artist.name,
artist.image_path,
));
return;
}
if let Some(release) = selected_release_card(state) {
@@ -433,11 +468,7 @@ fn open_edit_popup(state: &mut AppState) {
state.status_message = Some("nothing to edit here".into());
}
fn artist_edit_popup(
id: i64,
name: &str,
image_path: Option<String>,
) -> super::state::Popup {
fn artist_edit_popup(id: i64, name: &str, image_path: Option<String>) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
Popup::Edit {
target: EditTarget::Artist(id),
@@ -469,7 +500,10 @@ fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
EditField::new("Featured", join(&track.featured_artists)),
EditField::new(
"Track #",
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
track
.track_number
.map(|n| n.to_string())
.unwrap_or_default(),
),
EditField::new(
"Disc #",
@@ -527,8 +561,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
fed_tracks: vec![],
});
}
state.status_message =
Some(format!("removing {} track(s) from playlist", track_ids.len()));
state.status_message = Some(format!(
"removing {} track(s) from playlist",
track_ids.len()
));
return Some(Effect::RemoveFromPlaylist {
playlist_id: opened.id,
track_ids,
@@ -543,14 +579,20 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
let artist = state.global.artists.get(state.global.selected).cloned()?;
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
label: format!(
"artist \"{}\" with all their releases and tracks",
artist.name
),
});
return None;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
label: format!(
"artist \"{}\" with all their releases and tracks",
artist.name
),
});
return None;
}
@@ -604,7 +646,9 @@ 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 { .. } | GlobalView::FedRelease { .. } => None,
GlobalView::Release { .. }
| GlobalView::FedArtist { .. }
| GlobalView::FedRelease { .. } => None,
}
}
@@ -679,9 +723,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
}
TrackSelectionScope::Playlist(id) => {
if let Some(opened) = &mut state.playlists.opened
&& opened.id == *id {
opened.cursor = value;
}
&& opened.id == *id
{
opened.cursor = value;
}
}
TrackSelectionScope::Queue => {
state.queue_tab.cursor = value;
@@ -694,6 +739,13 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
TrackSelectionScope::FedRelease(_) => {
set_view_cursor(state, value + 1);
}
TrackSelectionScope::FedAppearsOn => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return;
};
let release_count = card.releases.len();
set_view_cursor(state, release_count + value);
}
}
}
@@ -739,13 +791,19 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
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).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
}
GlobalView::FedArtist { cursor } => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return None;
};
let relative = cursor.checked_sub(card.releases.len())?;
(relative < card.appears_on.len()).then_some((
TrackSelectionScope::FedAppearsOn,
relative,
len,
card.appears_on.len(),
))
}
_ => None,
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
@@ -990,17 +1048,24 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
}
}
}
GlobalView::FedArtist { .. } => None,
GlobalView::FedRelease { index, cursor } => {
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
let release = fed_card_release(state, *index)?;
release
.tracks
.get(cursor.checked_sub(1)?)
.and_then(|track| fed_track_from_card(&name, release, track))
GlobalView::FedArtist { cursor } => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return None;
};
let index = cursor.checked_sub(card.releases.len())?;
card.appears_on
.get(index)
.and_then(|appearance| {
fed_track_from_appearance(appearance, card.own_owner.as_deref())
})
.as_ref()
.map(crate::federation::pending_track)
}
GlobalView::FedRelease { index, cursor } => fed_release_tracks(state, *index)
.into_iter()
.nth(cursor.checked_sub(1)?)
.as_ref()
.map(crate::federation::pending_track),
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
@@ -1040,7 +1105,9 @@ 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 { .. } | GlobalView::FedRelease { .. } => None,
GlobalView::Release { .. }
| GlobalView::FedArtist { .. }
| GlobalView::FedRelease { .. } => None,
}
}
@@ -1052,6 +1119,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
let count = tracks.len();
let title = tracks[0].title.clone();
enqueue_tracks(state, tracks, next);
state.track_selection.clear();
state.status_message = Some(if count == 1 && next {
format!("queued next: {title}")
} else if count == 1 {
@@ -1130,9 +1198,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
player.queue.insert(insert_at + offset, track);
}
if let Some(prefetched) = &mut player.prefetched_pos
&& insert_at <= *prefetched {
*prefetched += count;
}
&& insert_at <= *prefetched
{
*prefetched += count;
}
if insert_at <= player.queue_pos && player.current.is_some() {
player.queue_pos += count;
}
@@ -1310,8 +1379,14 @@ fn page_step(state: &AppState) -> isize {
}
Some(GlobalView::Release { .. })
| Some(GlobalView::Search { .. })
| Some(GlobalView::FedArtist { .. })
| Some(GlobalView::FedRelease { .. }) => lines,
Some(GlobalView::FedArtist { cursor }) => {
let in_release_tiles = match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) => *cursor < card.releases.len(),
_ => false,
};
if in_release_tiles { tile_rows } else { lines }
}
}
}
@@ -1492,14 +1567,39 @@ 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;
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return;
};
let releases = card.releases.len();
let appears_on = card.appears_on.len();
let total = (releases + appears_on) as isize;
if total == 0 {
return;
}
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);
let in_release_tiles = cursor < releases;
let next = if !in_release_tiles {
(cursor as isize + dy).clamp(0, total - 1) as usize
} else {
let rows = fed_release_rows(&card.releases, grid_columns());
let (row, column) = rows
.iter()
.enumerate()
.find_map(|(r, items)| items.iter().position(|p| *p == cursor).map(|c| (r, c)))
.unwrap_or((0, 0));
if dx != 0 {
(cursor as isize + dx).clamp(0, releases as isize - 1) as usize
} else {
let target = row as isize + dy;
if target < 0 {
cursor
} else if target as usize >= rows.len() {
if appears_on > 0 { releases } else { cursor }
} else {
let items = &rows[target as usize];
items[column.min(items.len() - 1)]
}
}
};
set_view_cursor(state, next as usize);
state.track_selection.clear();
}
@@ -1513,10 +1613,10 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
}
}
/// Selectable tiles of the open federated artist card (its releases).
/// Selectable rows of the open federated artist card: releases, then appearances.
pub(crate) fn fed_card_len(state: &AppState) -> usize {
match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) => card.releases.len(),
Some((_, Loadable::Ready(card))) => card.releases.len() + card.appears_on.len(),
_ => 0,
}
}
@@ -1607,10 +1707,11 @@ fn jump_selection(state: &mut AppState, first: bool) {
if first {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer()
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN)
{
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
} else {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -1787,18 +1888,42 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
},
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => {
let order = fed_release_display_order(&card.releases);
let Some(&release_index) = order.get(cursor) else {
return None;
};
// 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() {
let start = if card.releases[release_index].tracks.is_empty() {
0
} else {
1
};
Outcome::Push(GlobalView::FedRelease {
index: cursor,
index: release_index,
cursor: start,
})
}
Some((_, Loadable::Ready(card))) => {
let start = cursor - card.releases.len();
if card.appears_on.get(start).is_none() {
Outcome::Nothing
} else {
let tracks: Vec<_> = card
.appears_on
.iter()
.filter_map(|appearance| {
fed_track_from_appearance(appearance, card.own_owner.as_deref())
})
.map(|fed| crate::federation::pending_track(&fed))
.collect();
if tracks.is_empty() {
Outcome::Nothing
} else {
Outcome::Play { tracks, start }
}
}
}
_ => Outcome::Nothing,
},
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
@@ -1882,7 +2007,17 @@ pub(crate) fn fed_release_tracks(
release
.tracks
.iter()
.filter_map(|track| fed_track_from_card(name, release, track))
.filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref()))
.collect()
}
pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
card.appears_on
.iter()
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
.collect()
}
@@ -1904,7 +2039,19 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
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();
return indices
.into_iter()
.filter_map(|i| all.get(i).cloned())
.collect();
}
}
TrackSelectionScope::FedAppearsOn => {
let all = fed_appears_on_tracks(state);
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
return indices
.into_iter()
.filter_map(|i| all.get(i).cloned())
.collect();
}
}
_ => {}
@@ -1926,6 +2073,16 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
.into_iter()
.collect(),
Some(GlobalView::FedArtist { cursor }) => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
cursor
.checked_sub(card.releases.len())
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
.into_iter()
.collect()
}
_ => Vec::new(),
}
}
@@ -1970,22 +2127,73 @@ fn fed_track_from_card(
artist: &str,
release: &crate::federation::FedRelease,
track: &crate::federation::FedCardTrack,
own_owner: Option<&str>,
) -> Option<crate::federation::FedTrack> {
let (owner, item_id) = track.sources.first()?.clone();
let own = own_owner == Some(owner.as_str());
Some(crate::federation::FedTrack {
item_id,
owner,
own: false,
own,
title: track.title.clone(),
artist_names: vec![artist.to_string()],
artist_names: fed_card_main_artist_names(track, Some(artist)),
featured_artist_names: fed_card_featured_artist_names(track),
year: release.year,
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
content_id: track.content_id.clone(),
release_title: Some(release.title.clone()),
track_number: track.track_number,
disc_number: track.disc_number,
})
}
fn fed_track_from_appearance(
appearance: &crate::federation::FedAppearsOn,
own_owner: Option<&str>,
) -> Option<crate::federation::FedTrack> {
let (owner, item_id) = appearance.track.sources.first()?.clone();
let own = own_owner == Some(owner.as_str());
Some(crate::federation::FedTrack {
item_id,
owner,
own,
title: appearance.track.title.clone(),
artist_names: fed_card_main_artist_names(&appearance.track, None),
featured_artist_names: fed_card_featured_artist_names(&appearance.track),
year: appearance.year,
duration_seconds: appearance.track.duration_seconds.map(|d| d.round() as i64),
content_id: appearance.track.content_id.clone(),
release_title: (!appearance.release_title.is_empty())
.then(|| appearance.release_title.clone()),
track_number: appearance.track.track_number,
disc_number: appearance.track.disc_number,
})
}
fn fed_card_main_artist_names(
track: &crate::federation::FedCardTrack,
fallback: Option<&str>,
) -> Vec<String> {
if track.artists.is_empty() {
fallback.into_iter().map(str::to_string).collect()
} else {
track.artists.clone()
}
}
fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for artist in &track.featured_artists {
if !names
.iter()
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
{
names.push(artist.clone());
}
}
names
}
/// Enter on the Federation tab: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
@@ -2071,12 +2279,13 @@ fn go_back(state: &mut AppState) {
// Esc on a view opened by Shift-J from another tab goes back to
// that tab, not down the Global stack.
if let Some((origin, depth)) = state.jump_origin
&& state.global.stack.len() == depth + 1 {
state.global.stack.pop();
state.jump_origin = None;
state.active_tab = origin;
return;
}
&& state.global.stack.len() == depth + 1
{
state.global.stack.pop();
state.jump_origin = None;
state.active_tab = origin;
return;
}
if let Some(popped) = state.global.stack.pop() {
if matches!(popped, GlobalView::Search { .. }) {
state.search = SearchState::default();
@@ -2161,6 +2370,7 @@ mod tests {
release_year: None,
cover_path: None,
file_path: format!("/s/{id}"),
content_id: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
@@ -2370,8 +2580,8 @@ mod tests {
#[test]
fn queue_advances_and_respects_repeat() {
use crate::library::models::TrackItem;
use crate::app::state::RepeatMode;
use crate::library::models::TrackItem;
let track = |id: i64| TrackItem {
id,
@@ -2386,6 +2596,7 @@ mod tests {
release_year: None,
cover_path: None,
file_path: format!("/api/player/stream/{id}"),
content_id: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
@@ -2450,6 +2661,7 @@ mod tests {
release_year: None,
cover_path: None,
file_path: format!("/s/{id}"),
content_id: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
@@ -2541,6 +2753,7 @@ mod tests {
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
assert_eq!(queued, vec![1, 2]);
assert!(!state.track_selection.is_active());
}
#[test]
@@ -2615,6 +2828,7 @@ mod tests {
release_year: None,
cover_path: None,
file_path: format!("/s/{id}"),
content_id: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
@@ -2661,6 +2875,7 @@ mod tests {
release_year: None,
cover_path: None,
file_path: format!("/s/{id}"),
content_id: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,