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:
socatlolmeow 2026-10-17 21:04:28 +02:00
commit 8f650f8820
6 changed files with 497 additions and 43 deletions

View file

@ -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);

View file

@ -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
View 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);
}
}

View file

@ -1,6 +1,7 @@
mod app;
mod command;
mod config;
mod lyrics;
mod model;
mod player;
mod session;

220
src/ui.rs
View file

@ -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)", ""),
];