Initial commit — ferrosonic terminal Subsonic client
Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
197
src/ui/pages/playlists.rs
Normal file
197
src/ui/pages/playlists.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Playlists page with dual-panel browser
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
|
||||
/// Render the playlists page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
// Split into two panes: [Playlists] [Songs]
|
||||
let chunks =
|
||||
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
|
||||
|
||||
render_playlists(frame, chunks[0], state, &colors);
|
||||
render_songs(frame, chunks[1], state, &colors);
|
||||
}
|
||||
|
||||
/// Render the playlists list
|
||||
fn render_playlists(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let playlists = &state.playlists;
|
||||
|
||||
let focused = playlists.focus == 0;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Playlists ({}) ", playlists.playlists.len()))
|
||||
.border_style(border_style);
|
||||
|
||||
if playlists.playlists.is_empty() {
|
||||
let hint = Paragraph::new("No playlists found")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = playlists
|
||||
.playlists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, playlist)| {
|
||||
let is_selected = playlists.selected_playlist == Some(i);
|
||||
|
||||
let count = playlist.song_count.unwrap_or(0);
|
||||
let duration = playlist.duration.map(|d| {
|
||||
let mins = d / 60;
|
||||
let secs = d % 60;
|
||||
format!("{}:{:02}", mins, secs)
|
||||
});
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.album)
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(&playlist.name, style),
|
||||
Span::styled(
|
||||
format!(" ({} songs)", count),
|
||||
Style::default().fg(colors.muted),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(dur) = duration {
|
||||
spans.push(Span::styled(
|
||||
format!(" [{}]", dur),
|
||||
Style::default().fg(colors.muted),
|
||||
));
|
||||
}
|
||||
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▸ ");
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(playlists.selected_playlist);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.playlists.playlist_scroll_offset = list_state.offset();
|
||||
}
|
||||
|
||||
/// Render the songs in selected playlist
|
||||
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let playlists = &state.playlists;
|
||||
|
||||
let focused = playlists.focus == 1;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let title = if !playlists.songs.is_empty() {
|
||||
format!(" Songs ({}) ", playlists.songs.len())
|
||||
} else {
|
||||
" Songs ".to_string()
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style);
|
||||
|
||||
if playlists.songs.is_empty() {
|
||||
let hint = Paragraph::new("Select a playlist to view songs")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = playlists
|
||||
.songs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, song)| {
|
||||
let is_selected = playlists.selected_song == Some(i);
|
||||
let is_playing = state
|
||||
.current_song()
|
||||
.map(|s| s.id == song.id)
|
||||
.unwrap_or(false);
|
||||
|
||||
let indicator = if is_playing { "▶ " } else { " " };
|
||||
let artist = song.artist.clone().unwrap_or_default();
|
||||
let duration = song.format_duration();
|
||||
|
||||
// Colors based on state
|
||||
let (title_color, artist_color, time_color) = if is_selected {
|
||||
(
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
)
|
||||
} else if is_playing {
|
||||
(colors.playing, colors.muted, colors.muted)
|
||||
} else {
|
||||
(colors.song, colors.muted, colors.muted)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(colors.playing)),
|
||||
Span::styled(&song.title, Style::default().fg(title_color)),
|
||||
if !artist.is_empty() {
|
||||
Span::styled(format!(" - {}", artist), Style::default().fg(artist_color))
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
|
||||
]);
|
||||
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(playlists.selected_song);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.playlists.song_scroll_offset = list_state.offset();
|
||||
}
|
||||
Reference in New Issue
Block a user