feat: add Auto-DJ infinite library radio and dedicated Album Cover Art sidebar pane

This commit is contained in:
RarDog
2026-08-27 21:20:07 +03:00
parent e551de9fb7
commit d9b36ced19
9 changed files with 233 additions and 39 deletions
+15 -11
View File
@@ -23,9 +23,12 @@
- **Click to Play**: Select artists, albums, and tracks directly with mouse clicks or double-click.
- **Scroll Wheel**: Scroll library panes or adjust volume seamlessly.
- **Interactive Controls**: Clickable play/pause, next/prev, shuffle, repeat, star, volume, and header buttons.
- 🔁 **Smart Queue & Playback Modes**:
- 📻 **Auto-DJ / Library Radio Mode**: Press <kbd>a</kbd> or click `[📻 DJ]` to start continuous, infinite playback of random tracks across your whole server library.
- 🖼️ **Terminal Cover Art**: Automatic album cover thumbnail downloading and TrueColor ANSI Half-block pixel art rendering in the dedicated sidebar pane with retro vinyl fallback animation!
- 🔁 **Smart Queue & Playback Modes**:
- Auto-advance to the next song when a track ends.
- **Shuffle** mode (`🔀`) and **Repeat** modes (`🔁 Off`, `🔁 All`, `🔂 One`).
- **Shuffle** mode (`🔀`), **Repeat** modes (`🔁 Off`, `🔁 All`, `🔂 One`), and **Auto-DJ** (`📻 DJ`).
- Dedicated **Queue Viewer** (<kbd>u</kbd>) and **Lyrics Modal** (<kbd>y</kbd>).
- ❤️ **Favorites & Scrobbling**:
- Star / Like tracks (<kbd>f</kbd> / `❤️`) synced with your Navidrome server.
@@ -36,7 +39,6 @@
- **Nord**
- **Gruvbox Dark**
- *(Press <kbd>t</kbd> to cycle themes instantly!)*
- 🖼️ **Terminal Cover Art**: Unicode Half-block pixel art thumbnail rendering.
- ⚙️ **Interactive Setup & Settings**: Built-in 2-column configuration wizard on first launch with persistent TOML config.
- 🔍 **Instant Global Search**: Search artists, albums, and songs across your entire music library (<kbd>/</kbd>).
@@ -51,13 +53,14 @@
│ 🎤 Gorillaz (8) ││ 💿 Random Access Memories (2013) [13 tracks] │
│ 🎤 Radiohead (9) ││ 💿 Alive 2007 (2007) [12 tracks] │
│ 🎤 Tame Impala (4) │└────────────────────────────────────────────────────────────────────────────────┘
│ 🎤 The Weeknd (5) │┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐
│ 🎤 Pink Floyd (11) ││ 🔊 ❤️ One More Time [05:20] │
│ 🎤 Queen (15) ││ 02. Aerodynamic [03:27] │
│ 🎤 Arctic Monkeys (6) ││ 03. Digital Love [04:58] │
├─ 🖼️ Now Playing & Cover ──┤┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐
│ ▄██████████████▄ ││ 🔊 ❤️ One More Time [05:20] │
│ ████████████████ ││ 02. Aerodynamic [03:27] │
│ ▀██████████████▀ ││ 03. Digital Love [04:58] │
│ Daft Punk - Discovery ││ 04. Harder, Better, Faster, Stronger [03:45] │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────────────────┘
┌─ 🎵 rhyth-rs v0.1.0-beta ────────────────────────────────────────────────────────────────────────────────────┐
│ ❤️ One More Time • Daft Punk ⏮ ⏸ ⏭ │ 🔀 🔁 │ ▂▃▅▆▇▆▄▃▂▂▃▄▅ [y] Lyrics [u] Queue 🔊 85% │
│ ❤️ One More Time • Daft Punk ⏮ ⏸ ⏭ │ 🔀 🔁 [📻 DJ] │ ▂▃▅▆▇▆▄▃▂▂▃▄▅ [y] Lyrics [u] Queue 🔊 85% │
│ 02:14 ━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 05:20 (Click to Seek) │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
@@ -98,15 +101,16 @@ Configuration is automatically saved to:
|:---|:---|:---|
| <kbd>Space</kbd> | Play / Pause toggle | Click `[ ▶ / ⏸ ]` |
| <kbd>n</kbd> / <kbd>p</kbd> | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` |
| <kbd>Enter</kbd> | Play selected track / Expand album | Double-click track |
| <kbd>j</kbd> / <kbd>↓</kbd> | Move down | Scroll wheel down |
| <kbd>k</kbd> / <kbd>↑</kbd> | Move up | Scroll wheel up |
| <kbd>h</kbd> / <kbd>l</kbd> / <kbd>Tab</kbd> | Switch between Artists, Albums, and Tracks | Click pane |
| <kbd>a</kbd> | Toggle **Auto-DJ** (Infinite Library Radio) | Click `[ 📻 DJ ]` |
| <kbd>s</kbd> | Toggle Shuffle mode | Click `[ 🔀 ]` |
| <kbd>r</kbd> | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` |
| <kbd>f</kbd> / <kbd>L</kbd> | Star / Favorite track (Like) | Click `[ ❤️ ]` |
| <kbd>y</kbd> | Toggle Lyrics screen | Click `[y] Lyrics` |
| <kbd>u</kbd> | Toggle Queue screen | Click `[u] Queue` |
| <kbd>Enter</kbd> | Play selected track / Expand album | Double-click track |
| <kbd>j</kbd> / <kbd>↓</kbd> | Move down | Scroll wheel down |
| <kbd>k</kbd> / <kbd>↑</kbd> | Move up | Scroll wheel up |
| <kbd>h</kbd> / <kbd>l</kbd> / <kbd>Tab</kbd> | Switch between Artists, Albums, and Tracks | Click pane |
| <kbd>+</kbd> / <kbd>=</kbd> / <kbd>]</kbd> | Volume Up (+5%) | Click / Drag Volume slider |
| <kbd>-</kbd> / <kbd>_</kbd> / <kbd>[</kbd> | Volume Down (-5%) | Click / Drag Volume slider |
| <kbd>t</kbd> | Cycle Color Theme | Click `[t] Theme` |
+89 -2
View File
@@ -96,6 +96,7 @@ pub struct LayoutCache {
pub btn_next: Rect,
pub btn_shuffle: Rect,
pub btn_repeat: Rect,
pub btn_auto_dj: Rect,
pub btn_star: Rect,
pub btn_lyrics: Rect,
pub progress_bar: Rect,
@@ -122,6 +123,10 @@ pub enum AppAction {
LoadedArtists(Vec<ArtistID3>),
LoadedAlbums(Vec<AlbumID3>),
LoadedTracks(Vec<ChildSong>),
AutoDjLoaded {
songs: Vec<ChildSong>,
start_playing: bool,
},
AudioBuffered {
bytes: Bytes,
meta: TrackMetadata,
@@ -164,11 +169,13 @@ pub struct App {
pub tracks: Vec<ChildSong>,
pub tracks_state: ListState,
// Queue & Playback modes
// Queue, Auto-DJ & Playback modes
pub queue: Vec<ChildSong>,
pub queue_index: usize,
pub shuffle: bool,
pub repeat_mode: RepeatMode,
pub auto_dj: bool,
pub is_fetching_auto_dj: bool,
pub was_playing_before: bool,
pub scrobble_submitted: bool,
@@ -245,6 +252,8 @@ impl App {
queue_index: 0,
shuffle: false,
repeat_mode: RepeatMode::Off,
auto_dj: false,
is_fetching_auto_dj: false,
was_playing_before: false,
scrobble_submitted: false,
cover_thumbnail: None,
@@ -307,6 +316,17 @@ impl App {
self.tracks_state.select(Some(0));
}
}
AppAction::AutoDjLoaded { songs, start_playing } => {
self.is_loading = false;
self.is_fetching_auto_dj = false;
let was_empty = self.queue.is_empty();
self.queue.extend(songs);
if start_playing || was_empty {
self.queue_index = 0;
self.play_queue_index(0);
}
self.set_toast("📻 Auto-DJ: Queued random tracks from library!");
}
AppAction::AudioBuffered { bytes, meta } => {
self.is_loading = false;
self.audio_cache.insert(meta.id.clone(), bytes.clone());
@@ -343,10 +363,12 @@ impl App {
}
AppAction::ConnectionFailed(err) => {
self.is_loading = false;
self.is_fetching_auto_dj = false;
self.settings_form.status_message = Some((format!("Connection failed: {err}"), true));
}
AppAction::Error(err) => {
self.is_loading = false;
self.is_fetching_auto_dj = false;
self.set_toast(format!("Error: {}", err));
}
AppAction::Status(msg) => {
@@ -374,9 +396,20 @@ impl App {
}
}
// Auto-DJ: Replenish queue if low on tracks
if self.auto_dj && !self.is_fetching_auto_dj {
let remaining = self.queue.len().saturating_sub(self.queue_index);
if remaining <= 2 {
self.fetch_auto_dj_songs(false);
}
}
// Auto Next Track on track end
if self.was_playing_before && progress.state == PlaybackState::Stopped {
self.was_playing_before = false;
if self.auto_dj {
self.next_track();
} else {
match self.repeat_mode {
RepeatMode::Track => {
self.play_queue_index(self.queue_index);
@@ -394,6 +427,7 @@ impl App {
}
}
}
}
// Tick visualizer
self.visualizer.tick(is_playing);
@@ -406,6 +440,42 @@ impl App {
}
}
pub fn toggle_auto_dj(&mut self) {
self.auto_dj = !self.auto_dj;
if self.auto_dj {
self.set_toast("📻 Auto-DJ: ON (Continuous library radio)");
self.fetch_auto_dj_songs(true);
} else {
self.set_toast("📻 Auto-DJ: OFF (Standard queue mode)");
}
}
pub fn fetch_auto_dj_songs(&mut self, start_playing: bool) {
if self.is_fetching_auto_dj {
return;
}
self.is_fetching_auto_dj = true;
self.is_loading = true;
self.loading_msg = "📻 Auto-DJ: Fetching random songs from library...".to_string();
let client = self.client.clone();
let tx = self.action_tx.clone();
tokio::spawn(async move {
match client.get_random_songs(30).await {
Ok(songs) => {
let _ = tx.send(AppAction::AutoDjLoaded {
songs,
start_playing,
});
}
Err(e) => {
let _ = tx.send(AppAction::Error(format!("Auto-DJ error: {e}")));
}
}
});
}
pub fn handle_mouse_click(&mut self, x: u16, y: u16) {
// Modal mode (Setup / Settings)
if self.mode == AppMode::Setup || self.mode == AppMode::Settings {
@@ -480,6 +550,9 @@ impl App {
} else if hit(self.layout.btn_repeat, x, y) {
self.toggle_repeat();
return;
} else if hit(self.layout.btn_auto_dj, x, y) {
self.toggle_auto_dj();
return;
} else if hit(self.layout.btn_star, x, y) {
self.toggle_star();
return;
@@ -638,6 +711,20 @@ impl App {
let tx = self.action_tx.clone();
let album_id = album.id.clone();
// Also fetch album cover art preview in sidebar
if let Some(ref cover_id) = album.cover_art {
let c = client.clone();
let cid = cover_id.clone();
let t = tx.clone();
tokio::spawn(async move {
if let Ok(bytes) = c.download_cover_art(&cid, Some(100)).await {
if let Some(lines) = render_cover_thumbnail(&bytes, 20, 8) {
let _ = t.send(AppAction::CoverLoaded(lines));
}
}
});
}
tokio::spawn(async move {
match client.get_album(&album_id).await {
Ok(details) => {
@@ -712,7 +799,7 @@ impl App {
let t = tx.clone();
tokio::spawn(async move {
if let Ok(bytes) = c.download_cover_art(&cover_id, Some(100)).await {
if let Some(lines) = render_cover_thumbnail(&bytes, 16, 8) {
if let Some(lines) = render_cover_thumbnail(&bytes, 20, 8) {
let _ = t.send(AppAction::CoverLoaded(lines));
}
}
+3
View File
@@ -210,6 +210,9 @@ fn handle_key(app: &mut App, key: KeyCode) {
KeyCode::Char('r') => {
app.toggle_repeat();
}
KeyCode::Char('a') => {
app.toggle_auto_dj();
}
KeyCode::Char('f') | KeyCode::Char('L') => {
app.toggle_star();
}
+10
View File
@@ -240,4 +240,14 @@ impl SubsonicClient {
Ok(None)
}
/// Get random songs from entire library (Auto-DJ / Radio mode)
pub async fn get_random_songs(&self, size: u32) -> Result<Vec<ChildSong>> {
let size_str = size.to_string();
let resp = self.get_json("getRandomSongs", &[("size", size_str.as_str())]).await?;
resp.response
.random_songs
.map(|r| r.song)
.context("Random songs missing in getRandomSongs response")
}
}
+8
View File
@@ -28,6 +28,8 @@ pub struct SubsonicResponseBody {
pub lyrics: Option<LyricsData>,
#[serde(rename = "lyricsList")]
pub lyrics_list: Option<LyricsList>,
#[serde(rename = "randomSongs")]
pub random_songs: Option<RandomSongs>,
#[serde(rename = "searchResult3")]
pub search_result3: Option<SearchResult3>,
}
@@ -154,6 +156,12 @@ pub struct ChildSong {
pub starred: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RandomSongs {
#[serde(default)]
pub song: Vec<ChildSong>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult3 {
#[serde(default)]
+42 -1
View File
@@ -1,9 +1,11 @@
use bytes::Bytes;
use image::imageops::FilterType;
use image::{GenericImageView, Rgba};
use ratatui::style::{Color, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::ui::theme::Theme;
/// Convert image bytes (JPEG / PNG) into Unicode Half-block terminal spans
pub fn render_cover_thumbnail(bytes: &Bytes, width: u32, height: u32) -> Option<Vec<Line<'static>>> {
let img = image::load_from_memory(bytes).ok()?;
@@ -41,3 +43,42 @@ pub fn render_cover_thumbnail(bytes: &Bytes, width: u32, height: u32) -> Option<
Some(lines)
}
/// Fallback vinyl / cassette art when no cover image is downloaded yet
pub fn render_vinyl_fallback(theme: &Theme, is_playing: bool, phase: usize) -> Vec<Line<'static>> {
let spinner = if is_playing {
match (phase / 3) % 4 {
0 => "◜◝",
1 => "◝◞",
2 => "◞◟",
_ => "◟◜",
}
} else {
"●●"
};
vec![
Line::from(""),
Line::from(vec![
Span::styled(" .───. ", Style::default().fg(theme.primary)),
]),
Line::from(vec![
Span::styled(" / ", Style::default().fg(theme.primary)),
Span::styled(spinner, Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
Span::styled(" \\ ", Style::default().fg(theme.primary)),
]),
Line::from(vec![
Span::styled(" | ( O ) | ", Style::default().fg(theme.secondary).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" \\ / ", Style::default().fg(theme.primary)),
]),
Line::from(vec![
Span::styled(" '───' ", Style::default().fg(theme.primary)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" 🎵 Vinyl Record ", Style::default().fg(theme.text_muted)),
]),
]
}
+41 -9
View File
@@ -1,20 +1,28 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::{App, FocusPane};
use crate::audio::PlaybackState;
use crate::ui::cover::render_vinyl_fallback;
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
// Split horizontally: Left (Artists), Right (Albums + Tracks)
// Split horizontally: Left (Artists + Cover Art), Right (Albums + Tracks)
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.constraints([Constraint::Percentage(32), Constraint::Percentage(68)])
.split(area);
// Split left column vertically: Top (Artists List), Bottom (Now Playing & Cover Art)
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(chunks[0]);
// Split right column vertically: Top (Albums), Bottom (Tracks)
let right_chunks = Layout::default()
.direction(Direction::Vertical)
@@ -22,7 +30,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
.split(chunks[1]);
// Cache hitboxes for mouse interaction
app.layout.artists_pane = chunks[0];
app.layout.artists_pane = left_chunks[0];
app.layout.albums_pane = right_chunks[0];
app.layout.tracks_pane = right_chunks[1];
@@ -60,9 +68,34 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
.highlight_style(theme.item_selected_style())
.highlight_symbol("▶ ");
frame.render_stateful_widget(artists_list, chunks[0], &mut app.artists_state);
frame.render_stateful_widget(artists_list, left_chunks[0], &mut app.artists_state);
// 2. Albums List
// 2. Cover Art & Now Playing Sidebar Pane
let progress = app.player.get_progress();
let is_playing = progress.state == PlaybackState::Playing;
let cover_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.border_focused))
.title(Span::styled(" 🖼️ Now Playing & Cover ", theme.title_style()))
.title_alignment(Alignment::Center);
let cover_inner = cover_block.inner(left_chunks[1]);
frame.render_widget(cover_block, left_chunks[1]);
if let Some(ref cover_lines) = app.cover_thumbnail {
// Display full TrueColor Unicode Half-block album cover
let cover_widget = Paragraph::new(cover_lines.clone()).alignment(Alignment::Center);
frame.render_widget(cover_widget, cover_inner);
} else {
// Fallback vinyl turntable animation
let vinyl_lines = render_vinyl_fallback(&app.theme, is_playing, app.marquee_tick);
let vinyl_widget = Paragraph::new(vinyl_lines).alignment(Alignment::Center);
frame.render_widget(vinyl_widget, cover_inner);
}
// 3. Albums List
let is_albums_focused = app.focus == FocusPane::Albums;
let albums_border_style = if is_albums_focused {
theme.focused_border_style()
@@ -101,7 +134,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
frame.render_stateful_widget(albums_list, right_chunks[0], &mut app.albums_state);
// 3. Tracks List
// 4. Tracks List
let is_tracks_focused = app.focus == FocusPane::Tracks;
let tracks_border_style = if is_tracks_focused {
theme.focused_border_style()
@@ -109,7 +142,6 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
theme.normal_border_style()
};
let progress = app.player.get_progress();
let current_playing_id = progress.current_track.as_ref().map(|t| &t.id);
let track_items: Vec<ListItem> = app
+2 -1
View File
@@ -40,7 +40,8 @@ pub fn render_help(app: &App, frame: &mut Frame, area: Rect) {
("n / p", "Next / Previous track in queue"),
("s", "Toggle Shuffle mode"),
("r", "Toggle Repeat mode (Off / Track / All)"),
("l", "Star / Favorite track (Like)"),
("a", "Toggle Auto-DJ (Infinite Library Radio)"),
("f / L", "Star / Favorite track (Like)"),
("y", "Toggle Lyrics (Text/Synced)"),
("u", "Toggle Queue view"),
("+ / = / ]", "Increase volume (+5%)"),
+10 -2
View File
@@ -39,7 +39,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
])
.split(rows[0]);
// 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁]
// 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁] [📻 DJ]
let ctrl_x = top_cols[1].x;
let ctrl_y = top_cols[1].y;
@@ -48,6 +48,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
app.layout.btn_next = Rect::new(ctrl_x + 8, ctrl_y, 4, 1);
app.layout.btn_shuffle = Rect::new(ctrl_x + 13, ctrl_y, 4, 1);
app.layout.btn_repeat = Rect::new(ctrl_x + 17, ctrl_y, 4, 1);
app.layout.btn_auto_dj = Rect::new(ctrl_x + 22, ctrl_y, 7, 1);
// 2. Track Info with Marquee
let is_starred = progress
@@ -103,6 +104,12 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
Style::default().fg(theme.text_muted)
};
let auto_dj_style = if app.auto_dj {
Style::default().bg(theme.accent).fg(theme.surface).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted)
};
let mut center_spans = vec![
Span::styled("⏮ ", Style::default().fg(theme.primary)),
Span::styled(format!("{play_icon} "), Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)),
@@ -110,10 +117,11 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
Span::styled("│ ", Style::default().fg(theme.border)),
Span::styled("🔀 ", shuffle_style),
Span::styled(format!("{repeat_icon} "), repeat_style),
Span::styled("[📻 DJ] ", auto_dj_style),
Span::styled("│ ", Style::default().fg(theme.border)),
];
let used_center_width = 22;
let used_center_width = 30;
let vis_width = (top_cols[1].width as usize).saturating_sub(used_center_width);
if app.config.enable_visualizer && vis_width > 4 {
let mut vis_spans = app.visualizer.render_colored_spans(vis_width);