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/README.md b/README.md index 57ca13a..d847798 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ [![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) ![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square) +![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) -### A music streaming client using Vim keybinds built in Rust. +### A YouTube Music player for your terminal, with Vim keybinds. -Stream your favourite music from YouTube Music — all in one client, in your terminal. +Search, queue and play music with no account, no login and no browser. @@ -21,26 +22,28 @@ Stream your favourite music from YouTube Music — all in one client, in your te ## Features -- **YouTube Music streaming** - search and play tracks straight from YouTube Music, with no account or login -- **Vim-style keybinds** - move with `j`/`k`, jump with `gg`/`ge`, use counts like `5j`, and open the command line with `:` -- **Song context menu** - press `G` on any song for quick actions: play, queue, add to playlist, show artist, and song info -- **Command palette** - a redesigned `:` prompt with the full command set (`:search`, `:seek`, `:vol`, `:import`, and more) -- **Audio visualizer** - a live visualizer in the bottom-left corner while music plays -- **Playlists & queue** - save, load, and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- **Artist browsing** - press `ga` to jump to an artist's songs -- **Session restore** - your queue and playback position come back the next time you launch - ---- +- **No login** - search and stream straight from YouTube Music +- **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line +- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung +- **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL +- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info +- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history +- **Artist browsing** - press `ga` to see an artist's songs +- **Audio visualizer** - a live visualizer while music plays +- **Session restore** - your queue and playback position come back on the next launch +- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file ## Installation +Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream extraction. Run `drift --check` at any time to verify both are installed. + ### Arch Linux (AUR) ```bash yay -S driftmusic-git ``` -Any AUR helper works (`paru -S driftmusic-git`), or build it by hand: +Any AUR helper works, or build it by hand: ```bash git clone https://aur.archlinux.org/driftmusic-git.git @@ -50,37 +53,127 @@ makepkg -si `mpv` and `yt-dlp` are installed automatically. -### Other distros +### Install script -Install the dependencies: - -- [mpv](https://mpv.io/) — audio playback -- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube stream extraction -- [Rust](https://rustup.rs/) (build only) - -Then build from source: +On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo: ```bash git clone https://github.com/socatlolmeow/drift.git cd drift -cargo build --release -sudo cp target/release/drift /usr/local/bin/ +./install.sh ``` -Or install directly with cargo: +### From source + +Install the dependencies and [Rust](https://rustup.rs/), then: ```bash +git clone https://github.com/socatlolmeow/drift.git +cd drift cargo install --path . ``` -This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/). +This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. + +
+Installing mpv and yt-dlp manually + +| Platform | Command | +| --- | --- | +| Debian / Ubuntu | `sudo apt install mpv` and `pipx install yt-dlp` | +| macOS | `brew install mpv yt-dlp` | +| Arch | `sudo pacman -S mpv yt-dlp` | +| Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` | + +
+ +## Usage + +```bash +drift # start the player +drift "daft punk" # start with a search +drift --check # verify mpv and yt-dlp are installed +``` + +Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of keybinds. + +## Keybinds + +| Key | Action | +| --- | --- | +| `j` `k` / `↓` `↑` | move (counts work: `5j`) | +| `gg` `ge` | jump to top / bottom (`5gg` goes to row 5) | +| `Tab` `Shift-Tab` | cycle Results / Queue / Playlists | +| `gr` `gq` `gp` | jump to Results / Queue / Playlists | +| `Enter` | play the selected song | +| `Space` | pause / resume | +| `n` `N` | next / previous track | +| `h` `l` / `←` `→` | seek back / forward (counts: `6l`) | +| `+` `-` | volume up / down | +| `s` `r` | toggle shuffle / cycle repeat | +| `a` `A` `P` | queue the song / queue all results / play all results | +| `dd` `x` | remove from queue or playlist | +| `J` `K` | move a queue or playlist item down / up | +| `G` | song menu | +| `ga` | go to the artist | +| `L` | toggle the lyrics panel | +| `F` | fullscreen lyrics | +| `/` `:` | search / command line | +| `?` | help | +| `q` | quit | + +### Commands + +| Command | Action | +| --- | --- | +| `:search "query"` | search YouTube Music | +| `:play [N]` | play or resume, or play row `N` | +| `:seek 90` `1:30` `+10` | absolute or relative seek | +| `:vol 50` `+5` | set or change the volume | +| `:shuffle` `:repeat [off\|all\|one]` | playback modes | +| `:add [N]` `:addall` `:playall` | queue results | +| `:save name` `:load name` `:append name` | work with playlists | +| `:addto name` `:delpl name` | add a song to / delete a playlist | +| `:import URL` | import a YouTube / YouTube Music playlist | +| `:lyrics` | toggle the lyrics panel | + +## Lyrics + +Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text you can scroll with `j`/`k`. + +Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open. + +## Configuration + +Settings live in `config.toml` inside Drift's config directory (`~/.config/drift/` on Linux). Drift only reads the file and never rewrites it, so your comments are safe. Restart Drift after editing. + +```toml +[playback] +volume = 80 +shuffle = false +repeat = "off" # off, all or one +autoplay = false +restore_session = true +seek_step = 5.0 +volume_step = 5.0 + +[artist] +popular_first = false + +[ui] +accent = "cyan" # a color name or a hex value like "#FF8800" +``` + +Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. + +## Contributing + +Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) before you start. ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift) ---- - ## License -[Drift](https://github.com/socatlolmeow/drift) is FOSS, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html). +[Drift](https://github.com/socatlolmeow/drift) is free software, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html). diff --git a/examples/showcase.gif b/examples/showcase.gif index 6e01c3e..adab71d 100644 Binary files a/examples/showcase.gif and b/examples/showcase.gif differ 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)", ""), ];