mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 11:20:20 +00:00
feat: add synced lyrics panel and fullscreen view
- fetch lyrics from lrclib in the background, only while visible - L toggles side panel, F opens fullscreen, :lyrics as a command - highlight current line with a progress fill, fade surrounding lines - fall back to scrollable plain lyrics when no synced version exists
This commit is contained in:
parent
bf11685a03
commit
8f650f8820
6 changed files with 497 additions and 43 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -4,29 +4,13 @@ All notable changes to Drift are documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows support.** Drift now runs natively on Windows. It talks to `mpv` over named pipes instead of Unix sockets.
|
||||
- **Song context menu.** Press `G` on a song or playlist for quick actions: play, play next, add to queue, add to playlist, show artist, remove, and song info.
|
||||
- **Song info popup.** "Song info" in the `G` menu opens a centered popup with the title, artist, album, length, and ID. Press any key to close it.
|
||||
- **Redesigned `:` command menu.**
|
||||
- **Audio visualizer** in the bottomleft corner.
|
||||
- **Keybind hints** shown next to the volume control.
|
||||
- **Status toasts.** Messages such as "Queued…" and errors now appear as a small floating toast above the Now Playing bar. Errors are red and info messages are green.
|
||||
- `DRIFT_CONFIG_DIR` and `DRIFT_STATE_DIR` environment variables to override where Drift stores its config, playlists, and session.
|
||||
- Install hints for Scoop and winget when `mpv` or `yt-dlp` is missing.
|
||||
- Synced lyrics via LRCLIB. `L` or `:lyrics` toggles a side panel, `F` opens fullscreen.
|
||||
- Current lyric line is highlighted with a progress fill; nearby lines fade out.
|
||||
- Plain lyrics fallback (scroll with `j`/`k`) when no synced version exists.
|
||||
|
||||
### Changed
|
||||
|
||||
- Status messages no longer share the bottom border of the Now Playing bar. That border now only shows pending key presses.
|
||||
- On Windows, `mpv` and `yt-dlp` start without flashing a console window.
|
||||
- On Windows, `mpv` is stopped when Drift exits, including after a crash. This matches the existing Linux behavior.
|
||||
- Saving playlists and the session retries a few times if the file is briefly locked, which can happen on Windows with antivirus software.
|
||||
- Ctrl+Alt is no longer treated as Ctrl. On Windows, AltGr is reported as Ctrl+Alt, so characters like `@`, `\`, `{`, `[`, and `~` can now be typed in search and `:` commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Song info no longer appears as a cramped line at the bottom of the screen.
|
||||
- Tests no longer write to your real config folder on Windows.
|
||||
- Tab bar redesigned as pills with dimmed counts on inactive tabs.
|
||||
- Tab bar only reserves spinner space while busy, so tabs fit on narrow terminals.
|
||||
|
|
|
|||
89
src/app.rs
89
src/app.rs
|
|
@ -8,6 +8,7 @@ use ratatui::widgets::TableState;
|
|||
|
||||
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
||||
use crate::config::{self, Config};
|
||||
use crate::lyrics::{self, Lyrics};
|
||||
use crate::model::{fmt_time, split_artists, RepeatMode, Track};
|
||||
use crate::player::{Player, PlayerEvent};
|
||||
use crate::session::Session;
|
||||
|
|
@ -25,6 +26,10 @@ pub enum AppEvent {
|
|||
generation: u64,
|
||||
result: Result<Page, String>,
|
||||
},
|
||||
Lyrics {
|
||||
id: String,
|
||||
result: Result<Option<Lyrics>, String>,
|
||||
},
|
||||
}
|
||||
|
||||
const FIRST_PAGE: usize = 20;
|
||||
|
|
@ -60,6 +65,7 @@ pub enum Mode {
|
|||
Help,
|
||||
Menu,
|
||||
Info,
|
||||
Lyrics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -70,6 +76,14 @@ pub enum PlayState {
|
|||
Paused,
|
||||
}
|
||||
|
||||
pub enum LyricsState {
|
||||
Idle,
|
||||
Loading,
|
||||
Ready(Lyrics),
|
||||
Missing,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Info,
|
||||
|
|
@ -154,6 +168,10 @@ pub struct App {
|
|||
pub artist_sel: usize,
|
||||
pub pending_delete: Option<String>,
|
||||
pub help_scroll: usize,
|
||||
pub lyrics: LyricsState,
|
||||
pub lyrics_panel: bool,
|
||||
pub lyrics_scroll: usize,
|
||||
pub lyrics_id: Option<String>,
|
||||
history: Vec<String>,
|
||||
hist_pos: Option<usize>,
|
||||
pub count: Option<usize>,
|
||||
|
|
@ -232,6 +250,10 @@ impl App {
|
|||
artist_sel: 0,
|
||||
pending_delete: None,
|
||||
help_scroll: 0,
|
||||
lyrics: LyricsState::Idle,
|
||||
lyrics_panel: false,
|
||||
lyrics_scroll: 0,
|
||||
lyrics_id: None,
|
||||
history: Vec::new(),
|
||||
hist_pos: None,
|
||||
count: None,
|
||||
|
|
@ -335,6 +357,7 @@ impl App {
|
|||
pub fn on_tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.load_more();
|
||||
self.sync_lyrics();
|
||||
if self.tick.is_multiple_of(50) {
|
||||
match self.save_session(false) {
|
||||
Ok(()) => self.save_failed = false,
|
||||
|
|
@ -440,6 +463,7 @@ impl App {
|
|||
Mode::ConfirmDelete => self.key_confirm_delete(key),
|
||||
Mode::Menu => self.key_menu(key),
|
||||
Mode::Info => self.key_info(key),
|
||||
Mode::Lyrics => self.key_lyrics(key),
|
||||
Mode::Normal => self.key_normal(key),
|
||||
}
|
||||
}
|
||||
|
|
@ -552,6 +576,8 @@ impl App {
|
|||
self.cmd_search = false;
|
||||
self.enter_command_mode();
|
||||
}
|
||||
KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel,
|
||||
KeyCode::Char('F') => self.open_lyrics(),
|
||||
KeyCode::Char('?') => self.open_help(),
|
||||
KeyCode::Char('q') => self.should_quit = true,
|
||||
KeyCode::Esc => self.status = None,
|
||||
|
|
@ -766,6 +792,58 @@ impl App {
|
|||
self.info_track = None;
|
||||
}
|
||||
|
||||
fn open_lyrics(&mut self) {
|
||||
self.mode = Mode::Lyrics;
|
||||
}
|
||||
|
||||
fn key_lyrics(&mut self, key: KeyEvent) {
|
||||
let scroll = self.lyrics_scroll;
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q' | 'F' | 'L') => self.mode = Mode::Normal,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.lyrics_scroll = scroll.saturating_add(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.lyrics_scroll = scroll.saturating_sub(1),
|
||||
KeyCode::Char(' ') => self.toggle_pause(),
|
||||
KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
|
||||
KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
|
||||
KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step)),
|
||||
KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step)),
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
self.set_volume(Amount::Delta(self.volume_step))
|
||||
}
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => {
|
||||
self.set_volume(Amount::Delta(-self.volume_step))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_lyrics(&mut self) {
|
||||
if !self.lyrics_panel && self.mode != Mode::Lyrics {
|
||||
return;
|
||||
}
|
||||
let Some(track) = self
|
||||
.now_track()
|
||||
.filter(|_| self.play_state != PlayState::Stopped)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.lyrics_id.as_deref() == Some(track.id.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.lyrics_id = Some(track.id.clone());
|
||||
self.lyrics = LyricsState::Loading;
|
||||
self.lyrics_scroll = 0;
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = lyrics::fetch(&track);
|
||||
let _ = tx.send(AppEvent::Lyrics {
|
||||
id: track.id,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn key_help(&mut self, key: KeyEvent) {
|
||||
let ctrl = is_ctrl(&key);
|
||||
let s = self.help_scroll;
|
||||
|
|
@ -1000,6 +1078,7 @@ impl App {
|
|||
Command::AddTo(name) => self.add_to_playlist(&name),
|
||||
Command::Import(url) => self.start_import(url),
|
||||
Command::Artist => self.go_to_artist(),
|
||||
Command::Lyrics => self.lyrics_panel = !self.lyrics_panel,
|
||||
Command::Help => self.open_help(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1189,6 +1268,16 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
AppEvent::Lyrics { id, result } => {
|
||||
if self.lyrics_id.as_deref() != Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.lyrics = match result {
|
||||
Ok(Some(l)) => LyricsState::Ready(l),
|
||||
Ok(None) => LyricsState::Missing,
|
||||
Err(e) => LyricsState::Failed(e),
|
||||
};
|
||||
}
|
||||
AppEvent::Player(generation, pe) => {
|
||||
if generation == self.player_gen {
|
||||
self.on_player(pe);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub enum Command {
|
|||
AddTo(String),
|
||||
Import(String),
|
||||
Artist,
|
||||
Lyrics,
|
||||
Help,
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ pub const COMMAND_NAMES: &[&str] = &[
|
|||
"addto",
|
||||
"import",
|
||||
"artist",
|
||||
"lyrics",
|
||||
"help",
|
||||
];
|
||||
|
||||
|
|
@ -175,6 +177,7 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
|||
Import(arg.to_string())
|
||||
}
|
||||
"artist" | "ar" => Artist,
|
||||
"lyrics" | "ly" => Lyrics,
|
||||
"help" | "h" => Help,
|
||||
other => return Err(format!("unknown command: :{other} (try :help)")),
|
||||
};
|
||||
|
|
@ -238,6 +241,7 @@ mod tests {
|
|||
fn artist_command() {
|
||||
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
||||
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
||||
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
200
src/lyrics.rs
Normal file
200
src/lyrics.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model::{parse_clock, split_artists, Track};
|
||||
|
||||
const SEARCH: &str = "https://lrclib.net/api/search";
|
||||
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
|
||||
const LEAD: f64 = 0.25;
|
||||
const MAX_DRIFT: f64 = 8.0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Line {
|
||||
pub at: f64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Lyrics {
|
||||
pub lines: Vec<Line>,
|
||||
pub synced: bool,
|
||||
}
|
||||
|
||||
impl Lyrics {
|
||||
pub fn active(&self, position: f64) -> Option<usize> {
|
||||
if !self.synced {
|
||||
return None;
|
||||
}
|
||||
let n = self.lines.partition_point(|l| l.at <= position + LEAD);
|
||||
n.checked_sub(1)
|
||||
}
|
||||
|
||||
pub fn progress(&self, i: usize, position: f64) -> f64 {
|
||||
let Some(line) = self.lines.get(i) else {
|
||||
return 0.0;
|
||||
};
|
||||
let sing = line.text.chars().count() as f64 * 0.1 + 1.0;
|
||||
let span = match self.lines.get(i + 1) {
|
||||
Some(next) => (next.at - line.at).min(sing),
|
||||
None => sing,
|
||||
};
|
||||
((position - line.at) / span.max(0.1)).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_lrc(text: &str) -> Vec<Line> {
|
||||
let mut out = Vec::new();
|
||||
for raw in text.lines() {
|
||||
let mut rest = raw.trim();
|
||||
let mut stamps = Vec::new();
|
||||
while let Some(tail) = rest.strip_prefix('[') {
|
||||
let Some((tag, after)) = tail.split_once(']') else {
|
||||
break;
|
||||
};
|
||||
let Some(t) = parse_clock(tag) else {
|
||||
break;
|
||||
};
|
||||
stamps.push(t);
|
||||
rest = after;
|
||||
}
|
||||
let text = rest.trim();
|
||||
for at in stamps {
|
||||
out.push(Line {
|
||||
at,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.at.total_cmp(&b.at));
|
||||
out
|
||||
}
|
||||
|
||||
fn from_entry(entry: &Value) -> Option<Lyrics> {
|
||||
let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty());
|
||||
if let Some(s) = text("syncedLyrics") {
|
||||
let lines = parse_lrc(s);
|
||||
if !lines.is_empty() {
|
||||
return Some(Lyrics { lines, synced: true });
|
||||
}
|
||||
}
|
||||
let plain = text("plainLyrics")?;
|
||||
let lines = plain
|
||||
.lines()
|
||||
.map(|l| Line {
|
||||
at: 0.0,
|
||||
text: l.trim().to_string(),
|
||||
})
|
||||
.collect();
|
||||
Some(Lyrics {
|
||||
lines,
|
||||
synced: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
|
||||
let drift = |e: &Value| match (duration, e["duration"].as_f64()) {
|
||||
(Some(want), Some(got)) => (got - f64::from(want)).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
let mut found: Vec<(bool, f64, Lyrics)> = entries
|
||||
.iter()
|
||||
.filter(|e| drift(e) <= MAX_DRIFT)
|
||||
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
|
||||
.collect();
|
||||
found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap());
|
||||
found.into_iter().next().map(|(_, _, l)| l)
|
||||
}
|
||||
|
||||
fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(UA)
|
||||
.build();
|
||||
let mut req = agent.get(SEARCH);
|
||||
for (k, v) in params {
|
||||
req = req.query(k, v);
|
||||
}
|
||||
req.call()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_json::<Vec<Value>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
|
||||
let artist = split_artists(&track.artist)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?;
|
||||
if let Some(l) = pick(&exact, track.duration) {
|
||||
return Ok(Some(l));
|
||||
}
|
||||
let loose = query(&[("q", &format!("{} {}", track.title, artist))])?;
|
||||
Ok(pick(&loose, track.duration))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_timestamps_and_skips_metadata() {
|
||||
let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]");
|
||||
let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
(12.5, "first"),
|
||||
(30.25, "shared"),
|
||||
(45.0, ""),
|
||||
(62.0, "shared")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_active_line() {
|
||||
let l = Lyrics {
|
||||
lines: parse_lrc("[00:10.00]a\n[00:20.00]b"),
|
||||
synced: true,
|
||||
};
|
||||
assert_eq!(l.active(5.0), None);
|
||||
assert_eq!(l.active(10.0), Some(0));
|
||||
assert_eq!(l.active(19.9), Some(1));
|
||||
assert_eq!(l.active(99.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_stays_in_range() {
|
||||
let l = Lyrics {
|
||||
lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"),
|
||||
synced: true,
|
||||
};
|
||||
assert_eq!(l.progress(0, 9.0), 0.0);
|
||||
assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6);
|
||||
assert_eq!(l.progress(0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_synced_and_close_duration() {
|
||||
let entries = [
|
||||
json!({"duration": 200.0, "plainLyrics": "plain"}),
|
||||
json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}),
|
||||
json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}),
|
||||
json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}),
|
||||
];
|
||||
let l = pick(&entries, Some(200)).unwrap();
|
||||
assert!(l.synced);
|
||||
assert_eq!(l.lines[0].text, "nearest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_plain_lyrics() {
|
||||
let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})];
|
||||
let l = pick(&entries, Some(200)).unwrap();
|
||||
assert!(!l.synced);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod app;
|
||||
mod command;
|
||||
mod config;
|
||||
mod lyrics;
|
||||
mod model;
|
||||
mod player;
|
||||
mod session;
|
||||
|
|
|
|||
220
src/ui.rs
220
src/ui.rs
|
|
@ -1,10 +1,11 @@
|
|||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs};
|
||||
use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY};
|
||||
use crate::app::{App, Level, LyricsState, MenuAction, Mode, PlayState, View, NO_KEY};
|
||||
use crate::lyrics::Lyrics;
|
||||
use crate::model::{fmt_time, Track};
|
||||
|
||||
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
|
@ -33,11 +34,24 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
|||
])
|
||||
.areas(area);
|
||||
|
||||
draw_tabs(f, app, top);
|
||||
match app.view {
|
||||
View::Results => draw_tracks(f, app, body, View::Results),
|
||||
View::Queue => draw_tracks(f, app, body, View::Queue),
|
||||
View::Playlists => draw_playlists(f, app, body),
|
||||
if app.mode == Mode::Lyrics {
|
||||
draw_lyrics_header(f, app, top);
|
||||
draw_lyrics(f, app, body, true);
|
||||
} else {
|
||||
draw_tabs(f, app, top);
|
||||
let list = if app.lyrics_panel && body.width >= 70 {
|
||||
let [list, side] =
|
||||
Layout::horizontal([Constraint::Min(40), Constraint::Percentage(40)]).areas(body);
|
||||
draw_lyrics(f, app, side, false);
|
||||
list
|
||||
} else {
|
||||
body
|
||||
};
|
||||
match app.view {
|
||||
View::Results => draw_tracks(f, app, list, View::Results),
|
||||
View::Queue => draw_tracks(f, app, list, View::Queue),
|
||||
View::Playlists => draw_playlists(f, app, list),
|
||||
}
|
||||
}
|
||||
draw_now_playing(f, app, playing);
|
||||
if app.mode == Mode::Command {
|
||||
|
|
@ -79,30 +93,42 @@ fn hints(items: &[(&str, &str)], width: u16) -> Option<Line<'static>> {
|
|||
|
||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
||||
let [name, tabs, right] = Layout::horizontal([
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(34),
|
||||
Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(" Drift").style(Style::new().fg(app.accent).bold()),
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ♪ ", Style::new().fg(app.accent)),
|
||||
Span::styled("Drift", Style::new().fg(app.accent).bold()),
|
||||
])),
|
||||
name,
|
||||
);
|
||||
|
||||
let titles = [
|
||||
format!(" Results {} ", app.results.len()),
|
||||
format!(" Queue {} ", app.queue.len()),
|
||||
format!(" Playlists {} ", app.store.playlists.len()),
|
||||
("Results", app.results.len()),
|
||||
("Queue", app.queue.len()),
|
||||
("Playlists", app.store.playlists.len()),
|
||||
];
|
||||
f.render_widget(
|
||||
Tabs::new(titles)
|
||||
.select(app.view.idx())
|
||||
.style(Style::new().fg(Color::Gray))
|
||||
.highlight_style(Style::new().fg(Color::Black).bg(app.accent).bold())
|
||||
.divider(" "),
|
||||
tabs,
|
||||
);
|
||||
let pill = Style::new().fg(Color::Black).bg(app.accent);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
for (i, (title, count)) in titles.into_iter().enumerate() {
|
||||
if i == app.view.idx() {
|
||||
spans.push(Span::styled("▐", Style::new().fg(app.accent)));
|
||||
spans.push(Span::styled(format!(" {title} "), pill.bold()));
|
||||
spans.push(Span::styled(format!("{count} "), pill));
|
||||
spans.push(Span::styled("▌", Style::new().fg(app.accent)));
|
||||
} else {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!(" {title} "), Style::new().fg(Color::Gray)));
|
||||
spans.push(Span::styled(format!("{count} "), dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), tabs);
|
||||
|
||||
if let Some(busy) = &app.busy {
|
||||
let spin = SPINNER[app.tick % SPINNER.len()];
|
||||
|
|
@ -507,6 +533,153 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
|||
f.render_widget(Paragraph::new(Line::from(line)), l2);
|
||||
}
|
||||
|
||||
fn draw_lyrics_header(f: &mut Frame, app: &App, area: Rect) {
|
||||
let unsynced = matches!(&app.lyrics, LyricsState::Ready(l) if !l.synced);
|
||||
let mut title = vec![
|
||||
Span::styled(" Drift", Style::new().fg(app.accent).bold()),
|
||||
Span::styled(" · Lyrics", dim()),
|
||||
];
|
||||
if unsynced {
|
||||
title.push(Span::styled(" · not synced", dim()));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(title)), area);
|
||||
|
||||
let mut keys = vec![("space", "pause"), ("h l", "seek"), ("n N", "skip")];
|
||||
if unsynced {
|
||||
keys.insert(0, ("j k", "scroll"));
|
||||
}
|
||||
keys.push(("Esc", "close"));
|
||||
if let Some(h) = hints(&keys, area.width.saturating_sub(30)) {
|
||||
f.render_widget(Paragraph::new(h), area);
|
||||
}
|
||||
}
|
||||
|
||||
fn lyric_rows(
|
||||
lyrics: &Lyrics,
|
||||
position: f64,
|
||||
width: usize,
|
||||
roomy: bool,
|
||||
accent: Color,
|
||||
) -> (Vec<Line<'static>>, Option<usize>) {
|
||||
let active = lyrics.active(position);
|
||||
let mut rows: Vec<Line<'static>> = Vec::new();
|
||||
let mut focus = None;
|
||||
for (i, line) in lyrics.lines.iter().enumerate() {
|
||||
let parts = wrap_words(&line.text, width);
|
||||
let is_active = active == Some(i);
|
||||
if is_active {
|
||||
focus = Some(rows.len() + parts.len().max(1) / 2);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
rows.push(if is_active {
|
||||
Line::styled("♪", Style::new().fg(accent).bold())
|
||||
} else {
|
||||
Line::raw("")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if is_active {
|
||||
let total: usize = parts.iter().map(|p| p.chars().count()).sum();
|
||||
let mut fill = (lyrics.progress(i, position) * total as f64).round() as usize;
|
||||
for part in parts {
|
||||
let done = fill.min(part.chars().count());
|
||||
fill -= done;
|
||||
let sung: String = part.chars().take(done).collect();
|
||||
let rest: String = part.chars().skip(done).collect();
|
||||
rows.push(Line::from(vec![
|
||||
Span::styled(sung, Style::new().fg(accent).bold()),
|
||||
Span::styled(rest, Style::new().fg(Color::White).bold()),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
let ahead = active.map_or(i as isize + 1, |a| i as isize - a as isize);
|
||||
let color = match (lyrics.synced, ahead) {
|
||||
(false, _) | (_, 1..=2) => Color::Gray,
|
||||
_ => Color::DarkGray,
|
||||
};
|
||||
rows.extend(parts.into_iter().map(|p| Line::styled(p, Style::new().fg(color))));
|
||||
}
|
||||
if roomy {
|
||||
rows.push(Line::raw(""));
|
||||
}
|
||||
}
|
||||
(rows, focus)
|
||||
}
|
||||
|
||||
fn draw_lyrics(f: &mut Frame, app: &mut App, area: Rect, full: bool) {
|
||||
let inner = if full {
|
||||
let w = area.width.saturating_sub(4).min(76);
|
||||
Rect {
|
||||
x: area.x + (area.width - w) / 2,
|
||||
width: w,
|
||||
..area
|
||||
}
|
||||
} else {
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(dim())
|
||||
.title(Span::styled(" ♪ Lyrics ", Style::new().fg(app.accent).bold()));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
inner.inner(Margin::new(1, 0))
|
||||
};
|
||||
if inner.width < 4 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = app.now_track().map(|t| t.id.clone());
|
||||
let playing = now.is_some() && app.play_state != PlayState::Stopped;
|
||||
let fresh = playing && app.lyrics_id == now;
|
||||
let notice: Option<Vec<Line>> = match &app.lyrics {
|
||||
_ if !playing => Some(vec![Line::styled("Nothing playing", dim())]),
|
||||
LyricsState::Ready(_) if fresh => None,
|
||||
LyricsState::Missing if fresh => Some(vec![
|
||||
Line::styled("No lyrics found", Style::new().fg(Color::Gray)),
|
||||
Line::styled("not every song has them", dim()),
|
||||
]),
|
||||
LyricsState::Failed(e) if fresh => Some(vec![
|
||||
Line::styled("Couldn't load lyrics", Style::new().fg(Color::Red)),
|
||||
Line::styled(e.clone(), dim()),
|
||||
]),
|
||||
_ => Some(vec![Line::styled(
|
||||
format!("{} Searching lyrics…", SPINNER[app.tick % SPINNER.len()]),
|
||||
Style::new().fg(Color::Yellow),
|
||||
)]),
|
||||
};
|
||||
if let Some(lines) = notice {
|
||||
let h = (lines.len() as u16 + 1).min(inner.height);
|
||||
let rect = Rect {
|
||||
y: inner.y + (inner.height - h) / 2,
|
||||
height: h,
|
||||
..inner
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
rect,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let LyricsState::Ready(lyrics) = &app.lyrics else {
|
||||
return;
|
||||
};
|
||||
let (rows, focus) = lyric_rows(lyrics, app.position, inner.width as usize, full, app.accent);
|
||||
let height = inner.height as usize;
|
||||
let max_start = rows.len().saturating_sub(height);
|
||||
let start = if lyrics.synced {
|
||||
focus.map_or(0, |r| r.saturating_sub(height / 2))
|
||||
} else {
|
||||
app.lyrics_scroll
|
||||
}
|
||||
.min(max_start);
|
||||
app.lyrics_scroll = start;
|
||||
let visible: Vec<Line> = rows.into_iter().skip(start).take(height).collect();
|
||||
let align = if full { Alignment::Center } else { Alignment::Left };
|
||||
f.render_widget(Paragraph::new(visible).alignment(align), inner);
|
||||
}
|
||||
|
||||
fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
|
||||
let Some(s) = app.visible_status() else {
|
||||
return;
|
||||
|
|
@ -628,6 +801,7 @@ const CMD_INFO: &[(&str, &str)] = &[
|
|||
("addto", "add the selected track to a playlist"),
|
||||
("import", "import a YouTube / YT Music playlist URL"),
|
||||
("artist", "songs by the selected artist"),
|
||||
("lyrics", "show or hide the lyrics panel"),
|
||||
("help", "show key bindings"),
|
||||
];
|
||||
|
||||
|
|
@ -896,6 +1070,7 @@ const HELP: &[(&str, &str)] = &[
|
|||
("h l / ← →", "seek -5s / +5s (counts: 6l)"),
|
||||
("+ -", "volume up / down"),
|
||||
("s r", "toggle shuffle / cycle repeat"),
|
||||
("L F", "lyrics side panel / fullscreen lyrics (Esc closes)"),
|
||||
(
|
||||
"ga or :artist",
|
||||
"songs by the artist of the selected song; a popup asks which one when there are several",
|
||||
|
|
@ -941,6 +1116,7 @@ const HELP: &[(&str, &str)] = &[
|
|||
(":addto n", "add the selected track to playlist n"),
|
||||
(":import URL", "load a YouTube / YT Music playlist"),
|
||||
(":artist", "same as ga"),
|
||||
(":lyrics", "same as L"),
|
||||
(":quit (or q, Ctrl-c)", ""),
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue