diff --git a/CHANGELOG.md b/CHANGELOG.md index c0baaca..0a23715 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/src/app.rs b/src/app.rs index 078d9ca..7c1b53c 100644 --- a/src/app.rs +++ b/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, }, + Lyrics { + id: String, + result: Result, 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, pub help_scroll: usize, + pub lyrics: LyricsState, + pub lyrics_panel: bool, + pub lyrics_scroll: usize, + pub lyrics_id: Option, history: Vec, hist_pos: Option, pub count: Option, @@ -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); diff --git a/src/command.rs b/src/command.rs index c2881be..9237286 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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, 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] diff --git a/src/lyrics.rs b/src/lyrics.rs new file mode 100644 index 0000000..9b29d69 --- /dev/null +++ b/src/lyrics.rs @@ -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, + pub synced: bool, +} + +impl Lyrics { + pub fn active(&self, position: f64) -> Option { + 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 { + 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 { + 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) -> Option { + 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, 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::>() + .map_err(|e| e.to_string()) +} + +pub fn fetch(track: &Track) -> Result, 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); + } +} diff --git a/src/main.rs b/src/main.rs index f9af212..aca55f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod command; mod config; +mod lyrics; mod model; mod player; mod session; diff --git a/src/ui.rs b/src/ui.rs index c00d787..26f2878 100644 --- a/src/ui.rs +++ b/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> { 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 = 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>, Option) { + let active = lyrics.active(position); + let mut rows: Vec> = 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> = 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 = 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)", ""), ];