diff --git a/README.md b/README.md index bbd18cd..86a7c70 100644 --- a/README.md +++ b/README.md @@ -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 a 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** (u) and **Lyrics Modal** (y). - ❤️ **Favorites & Scrobbling**: - Star / Like tracks (f / `❤️`) synced with your Navidrome server. @@ -36,7 +39,6 @@ - **Nord** - **Gruvbox Dark** - *(Press t 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 (/). @@ -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: |:---|:---|:---| | Space | Play / Pause toggle | Click `[ ▶ / ⏸ ]` | | n / p | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` | -| Enter | Play selected track / Expand album | Double-click track | -| j / ↓ | Move down | Scroll wheel down | -| k / ↑ | Move up | Scroll wheel up | -| h / l / Tab | Switch between Artists, Albums, and Tracks | Click pane | +| a | Toggle **Auto-DJ** (Infinite Library Radio) | Click `[ 📻 DJ ]` | | s | Toggle Shuffle mode | Click `[ 🔀 ]` | | r | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` | | f / L | Star / Favorite track (Like) | Click `[ ❤️ ]` | | y | Toggle Lyrics screen | Click `[y] Lyrics` | | u | Toggle Queue screen | Click `[u] Queue` | +| Enter | Play selected track / Expand album | Double-click track | +| j / ↓ | Move down | Scroll wheel down | +| k / ↑ | Move up | Scroll wheel up | +| h / l / Tab | Switch between Artists, Albums, and Tracks | Click pane | | + / = / ] | Volume Up (+5%) | Click / Drag Volume slider | | - / _ / [ | Volume Down (-5%) | Click / Drag Volume slider | | t | Cycle Color Theme | Click `[t] Theme` | diff --git a/src/app.rs b/src/app.rs index 62b9bcf..223fd10 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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), LoadedAlbums(Vec), LoadedTracks(Vec), + AutoDjLoaded { + songs: Vec, + start_playing: bool, + }, AudioBuffered { bytes: Bytes, meta: TrackMetadata, @@ -164,11 +169,13 @@ pub struct App { pub tracks: Vec, pub tracks_state: ListState, - // Queue & Playback modes + // Queue, Auto-DJ & Playback modes pub queue: Vec, 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,22 +396,34 @@ 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; - match self.repeat_mode { - RepeatMode::Track => { - self.play_queue_index(self.queue_index); - } - RepeatMode::All => { - if !self.queue.is_empty() { - let next_idx = (self.queue_index + 1) % self.queue.len(); - self.play_queue_index(next_idx); + if self.auto_dj { + self.next_track(); + } else { + match self.repeat_mode { + RepeatMode::Track => { + self.play_queue_index(self.queue_index); } - } - RepeatMode::Off => { - if self.queue_index + 1 < self.queue.len() { - self.play_queue_index(self.queue_index + 1); + RepeatMode::All => { + if !self.queue.is_empty() { + let next_idx = (self.queue_index + 1) % self.queue.len(); + self.play_queue_index(next_idx); + } + } + RepeatMode::Off => { + if self.queue_index + 1 < self.queue.len() { + self.play_queue_index(self.queue_index + 1); + } } } } @@ -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)); } } diff --git a/src/main.rs b/src/main.rs index 64a52d6..3db38ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(); } diff --git a/src/subsonic/client.rs b/src/subsonic/client.rs index 250dbe4..6ddd2ae 100644 --- a/src/subsonic/client.rs +++ b/src/subsonic/client.rs @@ -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> { + 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") + } } diff --git a/src/subsonic/models.rs b/src/subsonic/models.rs index 3284cad..8105fa9 100644 --- a/src/subsonic/models.rs +++ b/src/subsonic/models.rs @@ -28,6 +28,8 @@ pub struct SubsonicResponseBody { pub lyrics: Option, #[serde(rename = "lyricsList")] pub lyrics_list: Option, + #[serde(rename = "randomSongs")] + pub random_songs: Option, #[serde(rename = "searchResult3")] pub search_result3: Option, } @@ -154,6 +156,12 @@ pub struct ChildSong { pub starred: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RandomSongs { + #[serde(default)] + pub song: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResult3 { #[serde(default)] diff --git a/src/ui/cover.rs b/src/ui/cover.rs index c1d1b06..6fb1aaa 100644 --- a/src/ui/cover.rs +++ b/src/ui/cover.rs @@ -1,13 +1,15 @@ 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>> { let img = image::load_from_memory(bytes).ok()?; - + // Terminal cells are approx 1:2 aspect ratio, so height in cells corresponds to 2x pixel rows let pixel_height = height * 2; let resized = img.resize_exact(width, pixel_height, FilterType::Triangle); @@ -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> { + 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)), + ]), + ] +} diff --git a/src/ui/widgets/library.rs b/src/ui/widgets/library.rs index 2141033..ce3347e 100644 --- a/src/ui/widgets/library.rs +++ b/src/ui/widgets/library.rs @@ -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 = app diff --git a/src/ui/widgets/modals.rs b/src/ui/widgets/modals.rs index bbb3232..72b5151 100644 --- a/src/ui/widgets/modals.rs +++ b/src/ui/widgets/modals.rs @@ -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%)"), diff --git a/src/ui/widgets/player_bar.rs b/src/ui/widgets/player_bar.rs index 1635c43..3d11078 100644 --- a/src/ui/widgets/player_bar.rs +++ b/src/ui/widgets/player_bar.rs @@ -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);