feat: add Auto-DJ infinite library radio and dedicated Album Cover Art sidebar pane
This commit is contained in:
@@ -23,9 +23,12 @@
|
|||||||
- **Click to Play**: Select artists, albums, and tracks directly with mouse clicks or double-click.
|
- **Click to Play**: Select artists, albums, and tracks directly with mouse clicks or double-click.
|
||||||
- **Scroll Wheel**: Scroll library panes or adjust volume seamlessly.
|
- **Scroll Wheel**: Scroll library panes or adjust volume seamlessly.
|
||||||
- **Interactive Controls**: Clickable play/pause, next/prev, shuffle, repeat, star, volume, and header buttons.
|
- **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**:
|
- 🔁 **Smart Queue & Playback Modes**:
|
||||||
- Auto-advance to the next song when a track ends.
|
- 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>).
|
- Dedicated **Queue Viewer** (<kbd>u</kbd>) and **Lyrics Modal** (<kbd>y</kbd>).
|
||||||
- ❤️ **Favorites & Scrobbling**:
|
- ❤️ **Favorites & Scrobbling**:
|
||||||
- Star / Like tracks (<kbd>f</kbd> / `❤️`) synced with your Navidrome server.
|
- Star / Like tracks (<kbd>f</kbd> / `❤️`) synced with your Navidrome server.
|
||||||
@@ -36,7 +39,6 @@
|
|||||||
- **Nord**
|
- **Nord**
|
||||||
- **Gruvbox Dark**
|
- **Gruvbox Dark**
|
||||||
- *(Press <kbd>t</kbd> to cycle themes instantly!)*
|
- *(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.
|
- ⚙️ **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>).
|
- 🔍 **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] │
|
│ 🎤 Gorillaz (8) ││ 💿 Random Access Memories (2013) [13 tracks] │
|
||||||
│ 🎤 Radiohead (9) ││ 💿 Alive 2007 (2007) [12 tracks] │
|
│ 🎤 Radiohead (9) ││ 💿 Alive 2007 (2007) [12 tracks] │
|
||||||
│ 🎤 Tame Impala (4) │└────────────────────────────────────────────────────────────────────────────────┘
|
│ 🎤 Tame Impala (4) │└────────────────────────────────────────────────────────────────────────────────┘
|
||||||
│ 🎤 The Weeknd (5) │┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐
|
├─ 🖼️ Now Playing & Cover ──┤┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐
|
||||||
│ 🎤 Pink Floyd (11) ││ 🔊 ❤️ One More Time [05:20] │
|
│ ▄██████████████▄ ││ 🔊 ❤️ One More Time [05:20] │
|
||||||
│ 🎤 Queen (15) ││ 02. Aerodynamic [03:27] │
|
│ ████████████████ ││ 02. Aerodynamic [03:27] │
|
||||||
│ 🎤 Arctic Monkeys (6) ││ 03. Digital Love [04:58] │
|
│ ▀██████████████▀ ││ 03. Digital Love [04:58] │
|
||||||
|
│ Daft Punk - Discovery ││ 04. Harder, Better, Faster, Stronger [03:45] │
|
||||||
└────────────────────────────┘└────────────────────────────────────────────────────────────────────────────────┘
|
└────────────────────────────┘└────────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌─ 🎵 rhyth-rs v0.1.0-beta ────────────────────────────────────────────────────────────────────────────────────┐
|
┌─ 🎵 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) │
|
│ 02:14 ━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 05:20 (Click to Seek) │
|
||||||
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -98,15 +101,16 @@ Configuration is automatically saved to:
|
|||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| <kbd>Space</kbd> | Play / Pause toggle | Click `[ ▶ / ⏸ ]` |
|
| <kbd>Space</kbd> | Play / Pause toggle | Click `[ ▶ / ⏸ ]` |
|
||||||
| <kbd>n</kbd> / <kbd>p</kbd> | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` |
|
| <kbd>n</kbd> / <kbd>p</kbd> | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` |
|
||||||
| <kbd>Enter</kbd> | Play selected track / Expand album | Double-click track |
|
| <kbd>a</kbd> | Toggle **Auto-DJ** (Infinite Library Radio) | Click `[ 📻 DJ ]` |
|
||||||
| <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>s</kbd> | Toggle Shuffle mode | Click `[ 🔀 ]` |
|
| <kbd>s</kbd> | Toggle Shuffle mode | Click `[ 🔀 ]` |
|
||||||
| <kbd>r</kbd> | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` |
|
| <kbd>r</kbd> | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` |
|
||||||
| <kbd>f</kbd> / <kbd>L</kbd> | Star / Favorite track (Like) | Click `[ ❤️ ]` |
|
| <kbd>f</kbd> / <kbd>L</kbd> | Star / Favorite track (Like) | Click `[ ❤️ ]` |
|
||||||
| <kbd>y</kbd> | Toggle Lyrics screen | Click `[y] Lyrics` |
|
| <kbd>y</kbd> | Toggle Lyrics screen | Click `[y] Lyrics` |
|
||||||
| <kbd>u</kbd> | Toggle Queue screen | Click `[u] Queue` |
|
| <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 Up (+5%) | Click / Drag Volume slider |
|
||||||
| <kbd>-</kbd> / <kbd>_</kbd> / <kbd>[</kbd> | Volume Down (-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` |
|
| <kbd>t</kbd> | Cycle Color Theme | Click `[t] Theme` |
|
||||||
|
|||||||
+89
-2
@@ -96,6 +96,7 @@ pub struct LayoutCache {
|
|||||||
pub btn_next: Rect,
|
pub btn_next: Rect,
|
||||||
pub btn_shuffle: Rect,
|
pub btn_shuffle: Rect,
|
||||||
pub btn_repeat: Rect,
|
pub btn_repeat: Rect,
|
||||||
|
pub btn_auto_dj: Rect,
|
||||||
pub btn_star: Rect,
|
pub btn_star: Rect,
|
||||||
pub btn_lyrics: Rect,
|
pub btn_lyrics: Rect,
|
||||||
pub progress_bar: Rect,
|
pub progress_bar: Rect,
|
||||||
@@ -122,6 +123,10 @@ pub enum AppAction {
|
|||||||
LoadedArtists(Vec<ArtistID3>),
|
LoadedArtists(Vec<ArtistID3>),
|
||||||
LoadedAlbums(Vec<AlbumID3>),
|
LoadedAlbums(Vec<AlbumID3>),
|
||||||
LoadedTracks(Vec<ChildSong>),
|
LoadedTracks(Vec<ChildSong>),
|
||||||
|
AutoDjLoaded {
|
||||||
|
songs: Vec<ChildSong>,
|
||||||
|
start_playing: bool,
|
||||||
|
},
|
||||||
AudioBuffered {
|
AudioBuffered {
|
||||||
bytes: Bytes,
|
bytes: Bytes,
|
||||||
meta: TrackMetadata,
|
meta: TrackMetadata,
|
||||||
@@ -164,11 +169,13 @@ pub struct App {
|
|||||||
pub tracks: Vec<ChildSong>,
|
pub tracks: Vec<ChildSong>,
|
||||||
pub tracks_state: ListState,
|
pub tracks_state: ListState,
|
||||||
|
|
||||||
// Queue & Playback modes
|
// Queue, Auto-DJ & Playback modes
|
||||||
pub queue: Vec<ChildSong>,
|
pub queue: Vec<ChildSong>,
|
||||||
pub queue_index: usize,
|
pub queue_index: usize,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub repeat_mode: RepeatMode,
|
pub repeat_mode: RepeatMode,
|
||||||
|
pub auto_dj: bool,
|
||||||
|
pub is_fetching_auto_dj: bool,
|
||||||
pub was_playing_before: bool,
|
pub was_playing_before: bool,
|
||||||
pub scrobble_submitted: bool,
|
pub scrobble_submitted: bool,
|
||||||
|
|
||||||
@@ -245,6 +252,8 @@ impl App {
|
|||||||
queue_index: 0,
|
queue_index: 0,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat_mode: RepeatMode::Off,
|
repeat_mode: RepeatMode::Off,
|
||||||
|
auto_dj: false,
|
||||||
|
is_fetching_auto_dj: false,
|
||||||
was_playing_before: false,
|
was_playing_before: false,
|
||||||
scrobble_submitted: false,
|
scrobble_submitted: false,
|
||||||
cover_thumbnail: None,
|
cover_thumbnail: None,
|
||||||
@@ -307,6 +316,17 @@ impl App {
|
|||||||
self.tracks_state.select(Some(0));
|
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 } => {
|
AppAction::AudioBuffered { bytes, meta } => {
|
||||||
self.is_loading = false;
|
self.is_loading = false;
|
||||||
self.audio_cache.insert(meta.id.clone(), bytes.clone());
|
self.audio_cache.insert(meta.id.clone(), bytes.clone());
|
||||||
@@ -343,10 +363,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppAction::ConnectionFailed(err) => {
|
AppAction::ConnectionFailed(err) => {
|
||||||
self.is_loading = false;
|
self.is_loading = false;
|
||||||
|
self.is_fetching_auto_dj = false;
|
||||||
self.settings_form.status_message = Some((format!("Connection failed: {err}"), true));
|
self.settings_form.status_message = Some((format!("Connection failed: {err}"), true));
|
||||||
}
|
}
|
||||||
AppAction::Error(err) => {
|
AppAction::Error(err) => {
|
||||||
self.is_loading = false;
|
self.is_loading = false;
|
||||||
|
self.is_fetching_auto_dj = false;
|
||||||
self.set_toast(format!("Error: {}", err));
|
self.set_toast(format!("Error: {}", err));
|
||||||
}
|
}
|
||||||
AppAction::Status(msg) => {
|
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
|
// Auto Next Track on track end
|
||||||
if self.was_playing_before && progress.state == PlaybackState::Stopped {
|
if self.was_playing_before && progress.state == PlaybackState::Stopped {
|
||||||
self.was_playing_before = false;
|
self.was_playing_before = false;
|
||||||
|
if self.auto_dj {
|
||||||
|
self.next_track();
|
||||||
|
} else {
|
||||||
match self.repeat_mode {
|
match self.repeat_mode {
|
||||||
RepeatMode::Track => {
|
RepeatMode::Track => {
|
||||||
self.play_queue_index(self.queue_index);
|
self.play_queue_index(self.queue_index);
|
||||||
@@ -394,6 +427,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tick visualizer
|
// Tick visualizer
|
||||||
self.visualizer.tick(is_playing);
|
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) {
|
pub fn handle_mouse_click(&mut self, x: u16, y: u16) {
|
||||||
// Modal mode (Setup / Settings)
|
// Modal mode (Setup / Settings)
|
||||||
if self.mode == AppMode::Setup || self.mode == AppMode::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) {
|
} else if hit(self.layout.btn_repeat, x, y) {
|
||||||
self.toggle_repeat();
|
self.toggle_repeat();
|
||||||
return;
|
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) {
|
} else if hit(self.layout.btn_star, x, y) {
|
||||||
self.toggle_star();
|
self.toggle_star();
|
||||||
return;
|
return;
|
||||||
@@ -638,6 +711,20 @@ impl App {
|
|||||||
let tx = self.action_tx.clone();
|
let tx = self.action_tx.clone();
|
||||||
let album_id = album.id.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 {
|
tokio::spawn(async move {
|
||||||
match client.get_album(&album_id).await {
|
match client.get_album(&album_id).await {
|
||||||
Ok(details) => {
|
Ok(details) => {
|
||||||
@@ -712,7 +799,7 @@ impl App {
|
|||||||
let t = tx.clone();
|
let t = tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Ok(bytes) = c.download_cover_art(&cover_id, Some(100)).await {
|
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));
|
let _ = t.send(AppAction::CoverLoaded(lines));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ fn handle_key(app: &mut App, key: KeyCode) {
|
|||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
app.toggle_repeat();
|
app.toggle_repeat();
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
app.toggle_auto_dj();
|
||||||
|
}
|
||||||
KeyCode::Char('f') | KeyCode::Char('L') => {
|
KeyCode::Char('f') | KeyCode::Char('L') => {
|
||||||
app.toggle_star();
|
app.toggle_star();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,4 +240,14 @@ impl SubsonicClient {
|
|||||||
|
|
||||||
Ok(None)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub struct SubsonicResponseBody {
|
|||||||
pub lyrics: Option<LyricsData>,
|
pub lyrics: Option<LyricsData>,
|
||||||
#[serde(rename = "lyricsList")]
|
#[serde(rename = "lyricsList")]
|
||||||
pub lyrics_list: Option<LyricsList>,
|
pub lyrics_list: Option<LyricsList>,
|
||||||
|
#[serde(rename = "randomSongs")]
|
||||||
|
pub random_songs: Option<RandomSongs>,
|
||||||
#[serde(rename = "searchResult3")]
|
#[serde(rename = "searchResult3")]
|
||||||
pub search_result3: Option<SearchResult3>,
|
pub search_result3: Option<SearchResult3>,
|
||||||
}
|
}
|
||||||
@@ -154,6 +156,12 @@ pub struct ChildSong {
|
|||||||
pub starred: Option<String>,
|
pub starred: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RandomSongs {
|
||||||
|
#[serde(default)]
|
||||||
|
pub song: Vec<ChildSong>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SearchResult3 {
|
pub struct SearchResult3 {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+42
-1
@@ -1,9 +1,11 @@
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::{GenericImageView, Rgba};
|
use image::{GenericImageView, Rgba};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
/// Convert image bytes (JPEG / PNG) into Unicode Half-block terminal spans
|
/// 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>>> {
|
pub fn render_cover_thumbnail(bytes: &Bytes, width: u32, height: u32) -> Option<Vec<Line<'static>>> {
|
||||||
let img = image::load_from_memory(bytes).ok()?;
|
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)
|
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)),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, FocusPane};
|
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) {
|
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
|
||||||
let theme = &app.theme;
|
let theme = &app.theme;
|
||||||
|
|
||||||
// Split horizontally: Left (Artists), Right (Albums + Tracks)
|
// Split horizontally: Left (Artists + Cover Art), Right (Albums + Tracks)
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
.constraints([Constraint::Percentage(32), Constraint::Percentage(68)])
|
||||||
.split(area);
|
.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)
|
// Split right column vertically: Top (Albums), Bottom (Tracks)
|
||||||
let right_chunks = Layout::default()
|
let right_chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -22,7 +30,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
|
|||||||
.split(chunks[1]);
|
.split(chunks[1]);
|
||||||
|
|
||||||
// Cache hitboxes for mouse interaction
|
// 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.albums_pane = right_chunks[0];
|
||||||
app.layout.tracks_pane = right_chunks[1];
|
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_style(theme.item_selected_style())
|
||||||
.highlight_symbol("▶ ");
|
.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 is_albums_focused = app.focus == FocusPane::Albums;
|
||||||
let albums_border_style = if is_albums_focused {
|
let albums_border_style = if is_albums_focused {
|
||||||
theme.focused_border_style()
|
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);
|
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 is_tracks_focused = app.focus == FocusPane::Tracks;
|
||||||
let tracks_border_style = if is_tracks_focused {
|
let tracks_border_style = if is_tracks_focused {
|
||||||
theme.focused_border_style()
|
theme.focused_border_style()
|
||||||
@@ -109,7 +142,6 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
|
|||||||
theme.normal_border_style()
|
theme.normal_border_style()
|
||||||
};
|
};
|
||||||
|
|
||||||
let progress = app.player.get_progress();
|
|
||||||
let current_playing_id = progress.current_track.as_ref().map(|t| &t.id);
|
let current_playing_id = progress.current_track.as_ref().map(|t| &t.id);
|
||||||
|
|
||||||
let track_items: Vec<ListItem> = app
|
let track_items: Vec<ListItem> = app
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ pub fn render_help(app: &App, frame: &mut Frame, area: Rect) {
|
|||||||
("n / p", "Next / Previous track in queue"),
|
("n / p", "Next / Previous track in queue"),
|
||||||
("s", "Toggle Shuffle mode"),
|
("s", "Toggle Shuffle mode"),
|
||||||
("r", "Toggle Repeat mode (Off / Track / All)"),
|
("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)"),
|
("y", "Toggle Lyrics (Text/Synced)"),
|
||||||
("u", "Toggle Queue view"),
|
("u", "Toggle Queue view"),
|
||||||
("+ / = / ]", "Increase volume (+5%)"),
|
("+ / = / ]", "Increase volume (+5%)"),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
|
|||||||
])
|
])
|
||||||
.split(rows[0]);
|
.split(rows[0]);
|
||||||
|
|
||||||
// 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁]
|
// 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁] [📻 DJ]
|
||||||
let ctrl_x = top_cols[1].x;
|
let ctrl_x = top_cols[1].x;
|
||||||
let ctrl_y = top_cols[1].y;
|
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_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_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_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
|
// 2. Track Info with Marquee
|
||||||
let is_starred = progress
|
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)
|
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![
|
let mut center_spans = vec![
|
||||||
Span::styled("⏮ ", Style::default().fg(theme.primary)),
|
Span::styled("⏮ ", Style::default().fg(theme.primary)),
|
||||||
Span::styled(format!("{play_icon} "), Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)),
|
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("│ ", Style::default().fg(theme.border)),
|
||||||
Span::styled("🔀 ", shuffle_style),
|
Span::styled("🔀 ", shuffle_style),
|
||||||
Span::styled(format!("{repeat_icon} "), repeat_style),
|
Span::styled(format!("{repeat_icon} "), repeat_style),
|
||||||
|
Span::styled("[📻 DJ] ", auto_dj_style),
|
||||||
Span::styled("│ ", Style::default().fg(theme.border)),
|
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);
|
let vis_width = (top_cols[1].width as usize).saturating_sub(used_center_width);
|
||||||
if app.config.enable_visualizer && vis_width > 4 {
|
if app.config.enable_visualizer && vis_width > 4 {
|
||||||
let mut vis_spans = app.visualizer.render_colored_spans(vis_width);
|
let mut vis_spans = app.visualizer.render_colored_spans(vis_width);
|
||||||
|
|||||||
Reference in New Issue
Block a user