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.
|
||||
- **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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%)"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user