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
+8 -5
View File
@@ -59,11 +59,14 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
let label_width = 48usize;
let line = Line::from(vec![
Span::styled(marker, theme::accent()),
Span::styled(format!("{label:<label_width$}"), if selected {
theme::accent()
} else {
ratatui::style::Style::default()
}),
Span::styled(
format!("{label:<label_width$}"),
if selected {
theme::accent()
} else {
ratatui::style::Style::default()
},
),
Span::styled(value, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), rect);
+202 -49
View File
@@ -6,12 +6,13 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{art, theme};
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
use crate::app::state::{
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, fed_release_display_order,
fed_release_groups, release_groups,
};
use crate::art::cache_key;
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
const TILE_MARQUEE_STEP_MS: u128 = 250;
const TILE_MARQUEE_PAUSE_STEPS: u128 = 4;
@@ -956,68 +957,217 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
},
header_art(state, card.image_path.as_ref()),
);
let tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
let release_tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
let appears_on_len = card.appears_on.len();
let mut stats = format!(
"{} releases · {} tracks",
card.releases.len(),
release_tracks_total
);
if appears_on_len > 0 {
stats.push_str(&format!(" · appears on {appears_on_len}"));
}
stats.push_str(&format!(" · from {} peers", card.peers));
let info = vec![
Line::default(),
Line::styled(name.clone(), theme::header()),
Line::default(),
Line::styled(
format!(
"{} releases · {} tracks · from {} peers",
card.releases.len(),
tracks_total,
card.peers
),
theme::dim(),
),
Line::styled("enter: open a release · esc: back", theme::dim()),
Line::styled(stats, theme::dim()),
Line::styled("enter: open release / play track · esc: back", theme::dim()),
];
frame.render_widget(Paragraph::new(info), info_area);
if card.releases.is_empty() {
if card.releases.is_empty() && card.appears_on.is_empty() {
return centered_line(
frame,
content_area,
Line::styled("the peers returned no releases", theme::dim()),
Line::styled(
"the peers returned no releases or appearances",
theme::dim(),
),
);
}
// Release tiles: a flat grid ordered by year, scrolled to the cursor.
// Release tiles grouped by type, then featured appearances as tracks.
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 releases_len = card.releases.len();
let mut items = Vec::new();
let mut cursor_item = None;
let mut position = 0;
for (label, group) in fed_release_groups(&card.releases) {
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
for chunk in group.chunks(columns) {
let row: Vec<usize> = (position..position + chunk.len()).collect();
if row.contains(&cursor) {
cursor_item = Some(items.len());
}
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,
);
items.push(PlanItem::TileRow(row));
position += chunk.len();
}
items.push(PlanItem::Gap);
}
if appears_on_len > 0 {
items.push(PlanItem::Header(format!("Appears on ({appears_on_len})")));
for index in 0..appears_on_len {
let flat = releases_len + index;
if cursor == flat {
cursor_item = Some(items.len());
}
items.push(PlanItem::Track { cursor_index: flat });
}
items.push(PlanItem::Gap);
}
let display_order = fed_release_display_order(&card.releases);
let appears_scope = crate::app::state::TrackSelectionScope::FedAppearsOn;
render_plan(
frame,
content_area,
state,
&items,
cursor_item,
&mut |frame, rect, item| match item {
PlanItem::TileRow(row) => {
for (column, position) in row.iter().enumerate() {
let release = &card.releases[display_order[*position]];
let tile = Rect {
x: rect.x + column as u16 * TILE_WIDTH,
y: rect.y,
width: TILE_WIDTH
.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)),
height: rect.height,
};
if tile.width < 3 || tile.height < 3 {
break;
}
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,
cursor == *position,
);
}
}
PlanItem::Track { cursor_index } => {
let index = cursor_index - releases_len;
let Some(appearance) = card.appears_on.get(index) else {
return;
};
draw_fed_appearance_row(
frame,
rect,
state,
appearance,
index + 1,
cursor == *cursor_index,
state.track_selection.contains(&appears_scope, index),
);
}
_ => unreachable!("headers and gaps are rendered by render_plan"),
},
);
}
fn draw_fed_appearance_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
appearance: &crate::federation::FedAppearsOn,
number: usize,
selected: bool,
visual_selected: bool,
) {
let track = &appearance.track;
let liked = track
.sources
.iter()
.any(|(_, item_id)| state.fed_likes.contains(item_id));
let heart = if liked {
Span::styled("", theme::accent())
} else {
Span::raw(" ")
};
let mut context = fed_card_track_artist_line(track);
if !appearance.release_title.is_empty() {
if !context.is_empty() {
context.push_str(" · ");
}
context.push_str(&appearance.release_title);
}
let line = Line::from(vec![
Span::styled(format!("{number:>3} "), theme::dim()),
heart,
Span::styled("", theme::accent()),
Span::raw(track.title.clone()),
Span::styled(format!(" {context}"), theme::dim()),
]);
let mut meta = track
.duration_seconds
.map(|duration| {
let total = duration.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
})
.unwrap_or_default();
if let Some(year) = appearance.year {
if !meta.is_empty() {
meta.push_str(" · ");
}
meta.push_str(&year.to_string());
}
if track.sources.len() > 1 {
if !meta.is_empty() {
meta.push_str(" · ");
}
meta.push_str(&format!("{} peers", track.sources.len()));
}
if visual_selected && !selected {
frame.buffer_mut().set_style(area, theme::selection());
}
draw_row(
frame,
area,
line,
(!meta.is_empty()).then_some(meta),
selected,
);
}
fn fed_card_track_artist_line(track: &crate::federation::FedCardTrack) -> String {
let mut main = Vec::new();
for artist in &track.artists {
push_display_artist(&mut main, artist);
}
let mut featured_names = Vec::new();
for artist in &track.featured_artists {
if !main
.iter()
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
{
push_display_artist(&mut featured_names, artist);
}
}
let artists = main.join(", ");
let featured = featured_names.join(", ");
match (artists.is_empty(), featured.is_empty()) {
(false, false) => format!("{artists} feat. {featured}"),
(false, true) => artists,
(true, false) => format!("feat. {featured}"),
(true, true) => String::new(),
}
}
fn push_display_artist(names: &mut Vec<String>, artist: &str) {
if !names
.iter()
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
{
names.push(artist.to_string());
}
}
@@ -1075,7 +1225,10 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz
format!(" ⤓ Download the whole release ({}) ", release.tracks.len()),
button_style,
),
Line::styled("shift+v: select · y: download · p: add to playlist", theme::dim()),
Line::styled(
"shift+v: select · y: download · p: add to playlist",
theme::dim(),
),
];
frame.render_widget(Paragraph::new(info), info_area);
+28 -29
View File
@@ -210,31 +210,32 @@ fn format_secs(secs: f64) -> String {
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(track) = &player.current
&& player.playing {
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
80..=109 => 14,
_ => 22,
};
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
if bar_width > 0 && track.duration_seconds > 0.0 {
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
let filled = (ratio * bar_width as f64).round() as usize;
spans.push(Span::styled("".repeat(filled), theme::accent()));
spans.push(Span::styled("".repeat(bar_width - filled), theme::dim()));
spans.push(Span::raw(" "));
} else {
spans.push(Span::styled("/ ", theme::dim()));
}
spans.push(Span::raw(track.duration_label()));
if !player.queue.is_empty() && width >= 70 {
spans.push(Span::styled(
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
theme::dim(),
));
}
&& player.playing
{
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
80..=109 => 14,
_ => 22,
};
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
if bar_width > 0 && track.duration_seconds > 0.0 {
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
let filled = (ratio * bar_width as f64).round() as usize;
spans.push(Span::styled("".repeat(filled), theme::accent()));
spans.push(Span::styled("".repeat(bar_width - filled), theme::dim()));
spans.push(Span::raw(" "));
} else {
spans.push(Span::styled("/ ", theme::dim()));
}
spans.push(Span::raw(track.duration_label()));
if !player.queue.is_empty() && width >= 70 {
spans.push(Span::styled(
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
theme::dim(),
));
}
}
if width >= 80 {
let volume_cells = usize::from(player.volume / 10);
spans.extend([
@@ -277,11 +278,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center_width = (center.width() as u16).min(area.width);
let [title_area, right_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
])
.areas(player_row);
let [title_area, right_area] =
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
.areas(player_row);
let mut spans = Vec::new();
match &player.current {
+51 -12
View File
@@ -59,7 +59,11 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
let text_width = usize::from(width.saturating_sub(2));
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height));
let area = centered(
frame.area(),
width,
lines_needed.clamp(5, frame.area().height),
);
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header())
@@ -99,11 +103,13 @@ fn draw_edit(
for (index, field) in fields.iter().enumerate() {
let focused = index == focus;
let field_block = Block::bordered().title(field.label).border_style(if focused {
theme::accent()
} else {
theme::dim()
});
let field_block = Block::bordered()
.title(field.label)
.border_style(if focused {
theme::accent()
} else {
theme::dim()
});
let field_inner = field_block.inner(areas[index]);
frame.render_widget(field_block, areas[index]);
let width = usize::from(field_inner.width);
@@ -218,8 +224,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol
body,
);
let hint = if tracks.len() > 1 {
let can_share = crate::share::track_can_share(track);
let hint = if tracks.len() > 1 && can_share {
"j/k scroll · h/left previous · l/right next · c copy frid link · esc close"
} else if tracks.len() > 1 {
"j/k scroll · h/left previous · l/right next · esc close"
} else if can_share {
"j/k scroll · c copy frid link · esc close"
} else {
"j/k scroll · esc close"
};
@@ -230,13 +241,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol
}
fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
vec![
field("ID", track.id.to_string()),
let mut lines = vec![
field("ID", row_id(track.id)),
field("Title", track.title.clone()),
field("Artists", artist_refs(&track.artists)),
field("Featured artists", artist_refs(&track.featured_artists)),
field("Release", release_label(track)),
field("Release ID", track.release_id.to_string()),
field("Release ID", row_id(track.release_id)),
field("Disc", opt_display(track.disc_number)),
field("Track number", opt_display(track.track_number)),
field(
@@ -264,9 +275,23 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
),
field("File size", file_size(track.file_size_bytes)),
field("Plays", track.play_count.to_string()),
field(
"Content ID",
crate::share::track_content_id(track).unwrap_or_else(|| {
if !track.file_path.trim().is_empty() {
"press c to compute".to_string()
} else {
"".to_string()
}
}),
),
field("File path", empty_dash(&track.file_path)),
field("Cover path", opt_string(track.cover_path.clone())),
]
];
if let Some(link) = crate::share::cached_track_share_link(track) {
lines.push(field("Share link", link));
}
lines
}
fn field(label: &'static str, value: String) -> Line<'static> {
@@ -282,11 +307,25 @@ fn artist_refs(items: &[ArtistRef]) -> String {
}
items
.iter()
.map(|artist| format!("{} ({})", artist.name, artist.id))
.map(|artist| {
if artist.id >= 0 {
format!("{} ({})", artist.name, artist.id)
} else {
artist.name.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn row_id(id: i64) -> String {
if id >= 0 {
id.to_string()
} else {
"".to_string()
}
}
fn release_label(track: &TrackItem) -> String {
let mut label = empty_dash(&track.release_title);
if let Some(year) = track.release_year {