Added Visual select for multiple tracks, added Queue management. Added info for tracks
This commit is contained in:
+29
-4
@@ -490,11 +490,25 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
cursor_item,
|
||||
&mut |frame, rect, item| match item {
|
||||
PlanItem::Track { cursor_index } => {
|
||||
let (track, number) = if *cursor_index < tracks {
|
||||
(&detail.top_tracks[*cursor_index], cursor_index + 1)
|
||||
let (track, number, visual_selected) = if *cursor_index < tracks {
|
||||
(
|
||||
&detail.top_tracks[*cursor_index],
|
||||
cursor_index + 1,
|
||||
state.track_selection.contains(
|
||||
&crate::app::state::TrackSelectionScope::ArtistTop(id),
|
||||
*cursor_index,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let offset = cursor_index - tracks - releases_len;
|
||||
(&detail.featured_tracks[offset], offset + 1)
|
||||
(
|
||||
&detail.featured_tracks[offset],
|
||||
offset + 1,
|
||||
state.track_selection.contains(
|
||||
&crate::app::state::TrackSelectionScope::ArtistFeatured(id),
|
||||
offset,
|
||||
),
|
||||
)
|
||||
};
|
||||
super::track_row(
|
||||
frame,
|
||||
@@ -503,6 +517,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
track,
|
||||
number.to_string(),
|
||||
cursor == *cursor_index,
|
||||
visual_selected,
|
||||
);
|
||||
}
|
||||
PlanItem::TileRow(row) => {
|
||||
@@ -678,7 +693,17 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
.track_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| (offset + 1).to_string());
|
||||
super::track_row(frame, rect, state, track, number, cursor == offset);
|
||||
super::track_row(
|
||||
frame,
|
||||
rect,
|
||||
state,
|
||||
track,
|
||||
number,
|
||||
cursor == offset,
|
||||
state
|
||||
.track_selection
|
||||
.contains(&crate::app::state::TrackSelectionScope::Release(id), offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -12,7 +12,7 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::state::{AppState, Screen, Tab};
|
||||
use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
@@ -63,6 +63,7 @@ pub(crate) fn track_row(
|
||||
track: &crate::api::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
) {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
@@ -82,6 +83,9 @@ pub(crate) fn track_row(
|
||||
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
||||
area,
|
||||
);
|
||||
if visual_selected {
|
||||
frame.buffer_mut().set_style(area, theme::selection());
|
||||
}
|
||||
if selected {
|
||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||
}
|
||||
@@ -125,7 +129,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let player = &state.player;
|
||||
let block = Block::bordered()
|
||||
.title(format!(
|
||||
" Queue — {} tracks · enter: play · shift-c: clear ",
|
||||
" Queue — {} tracks · enter: play · d: remove · shift-v: select · shift-c: clear ",
|
||||
player.queue.len()
|
||||
))
|
||||
.title_style(theme::header())
|
||||
@@ -168,10 +172,21 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
} else {
|
||||
(index + 1).to_string()
|
||||
};
|
||||
track_row(frame, row, state, track, label, index == cursor);
|
||||
let visual_selected = state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Queue, index);
|
||||
track_row(
|
||||
frame,
|
||||
row,
|
||||
state,
|
||||
track,
|
||||
label,
|
||||
index == cursor,
|
||||
visual_selected,
|
||||
);
|
||||
// Tracks before the playing one are history: greyed out unless the
|
||||
// cursor is on them.
|
||||
if index < player.queue_pos && index != cursor {
|
||||
if index < player.queue_pos && index != cursor && !visual_selected {
|
||||
frame.buffer_mut().set_style(row, played_style);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::{theme, track_row};
|
||||
use crate::app::state::{AppState, Loadable};
|
||||
use crate::app::state::{AppState, Loadable, TrackSelectionScope};
|
||||
use crate::app::update::playlist_tracks;
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
@@ -158,6 +158,9 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
track,
|
||||
(index + 1).to_string(),
|
||||
index == cursor,
|
||||
state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+153
-1
@@ -1,9 +1,10 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::api::models::{ArtistRef, TrackItem};
|
||||
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
@@ -13,6 +14,11 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
||||
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
|
||||
Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
scroll,
|
||||
}) => draw_track_info(frame, tracks, *cursor, *scroll),
|
||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||
None => {}
|
||||
}
|
||||
@@ -137,6 +143,152 @@ fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry)
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scroll: usize) {
|
||||
let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else {
|
||||
return;
|
||||
};
|
||||
let width = 92.min(frame.area().width.saturating_sub(4)).max(48);
|
||||
let height = 24.min(frame.area().height.saturating_sub(2)).max(10);
|
||||
let area = centered(frame.area(), width, height);
|
||||
let title = if tracks.len() > 1 {
|
||||
format!(" Track info — {}/{} ", cursor + 1, tracks.len())
|
||||
} else {
|
||||
" Track info ".to_string()
|
||||
};
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||
let lines = track_info_lines(track);
|
||||
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll.min(max_scroll) as u16, 0)),
|
||||
body,
|
||||
);
|
||||
|
||||
let hint = if tracks.len() > 1 {
|
||||
"j/k scroll · h/left previous · l/right next · esc close"
|
||||
} else {
|
||||
"j/k scroll · esc close"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(hint, theme::dim())).alignment(Alignment::Center),
|
||||
footer,
|
||||
);
|
||||
}
|
||||
|
||||
fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
vec![
|
||||
field("ID", track.id.to_string()),
|
||||
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("Disc", opt_display(track.disc_number)),
|
||||
field("Track number", opt_display(track.track_number)),
|
||||
field(
|
||||
"Duration",
|
||||
format!(
|
||||
"{} ({:.2}s)",
|
||||
track.duration_label(),
|
||||
track.duration_seconds
|
||||
),
|
||||
),
|
||||
field("Uploader", empty_dash(&track.uploader_name)),
|
||||
field("Audio format", opt_string(track.audio_format.clone())),
|
||||
field(
|
||||
"Bitrate",
|
||||
opt_map(track.audio_bitrate, |v| format!("{v} kbps")),
|
||||
),
|
||||
field(
|
||||
"Sample rate",
|
||||
opt_map(track.audio_sample_rate, |v| {
|
||||
format!("{:.1} kHz", f64::from(v) / 1000.0)
|
||||
}),
|
||||
),
|
||||
field(
|
||||
"Bit depth",
|
||||
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
|
||||
),
|
||||
field("File size", file_size(track.file_size_bytes)),
|
||||
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
|
||||
field("Last.fm plays", opt_display(track.lastfm_playcount)),
|
||||
field(
|
||||
"Last.fm rating",
|
||||
opt_map(track.lastfm_rating, |v| format!("{v:.3}")),
|
||||
),
|
||||
field(
|
||||
"Last.fm updated",
|
||||
opt_string(track.lastfm_updated_at.clone()),
|
||||
),
|
||||
field("Stream URL", empty_dash(&track.stream_url)),
|
||||
field("Cover URL", opt_string(track.cover_url.clone())),
|
||||
]
|
||||
}
|
||||
|
||||
fn field(label: &'static str, value: String) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<18}"), theme::dim()),
|
||||
Span::raw(value),
|
||||
])
|
||||
}
|
||||
|
||||
fn artist_refs(items: &[ArtistRef]) -> String {
|
||||
if items.is_empty() {
|
||||
return "—".to_string();
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|artist| format!("{} ({})", artist.name, artist.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn release_label(track: &TrackItem) -> String {
|
||||
let mut label = empty_dash(&track.release_title);
|
||||
if let Some(year) = track.release_year {
|
||||
label.push_str(&format!(" ({year})"));
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
fn empty_dash(value: &str) -> String {
|
||||
if value.trim().is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_string(value: Option<String>) -> String {
|
||||
value
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn opt_display<T: std::fmt::Display>(value: Option<T>) -> String {
|
||||
opt_map(value, |value| value.to_string())
|
||||
}
|
||||
|
||||
fn opt_map<T>(value: Option<T>, format: impl FnOnce(T) -> String) -> String {
|
||||
value.map(format).unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn file_size(value: Option<i64>) -> String {
|
||||
value
|
||||
.map(|bytes| format!("{:.1} MB ({} bytes)", bytes as f64 / 1_048_576.0, bytes))
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) {
|
||||
let options = addable_playlists(state);
|
||||
let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_)));
|
||||
|
||||
@@ -18,6 +18,10 @@ pub fn tab_active() -> Style {
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn selection() -> Style {
|
||||
Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72))
|
||||
}
|
||||
|
||||
pub fn header() -> Style {
|
||||
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user