diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a23715..c0baaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,29 @@ 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 -- 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. +- **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. ### Changed -- 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. +- 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. diff --git a/README.md b/README.md index d847798..57ca13a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ [![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 YouTube Music player for your terminal, with Vim keybinds. +### A music streaming client using Vim keybinds built in Rust. -Search, queue and play music with no account, no login and no browser. +Stream your favourite music from YouTube Music — all in one client, in your terminal. @@ -22,28 +21,26 @@ Search, queue and play music with no account, no login and no browser. ## Features -- **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 +- **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 + +--- ## 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, or build it by hand: +Any AUR helper works (`paru -S driftmusic-git`), or build it by hand: ```bash git clone https://aur.archlinux.org/driftmusic-git.git @@ -53,127 +50,37 @@ makepkg -si `mpv` and `yt-dlp` are installed automatically. -### Install script +### Other distros -On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo: +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: ```bash git clone https://github.com/socatlolmeow/drift.git cd drift -./install.sh +cargo build --release +sudo cp target/release/drift /usr/local/bin/ ``` -### From source - -Install the dependencies and [Rust](https://rustup.rs/), then: +Or install directly with cargo: ```bash -git clone https://github.com/socatlolmeow/drift.git -cd drift cargo install --path . ``` -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. +This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/). ## 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 free software, 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 FOSS, 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 adab71d..6e01c3e 100644 Binary files a/examples/showcase.gif and b/examples/showcase.gif differ diff --git a/src/app.rs b/src/app.rs index 7c1b53c..078d9ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,6 @@ 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; @@ -26,10 +25,6 @@ pub enum AppEvent { generation: u64, result: Result, }, - Lyrics { - id: String, - result: Result, String>, - }, } const FIRST_PAGE: usize = 20; @@ -65,7 +60,6 @@ pub enum Mode { Help, Menu, Info, - Lyrics, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -76,14 +70,6 @@ pub enum PlayState { Paused, } -pub enum LyricsState { - Idle, - Loading, - Ready(Lyrics), - Missing, - Failed(String), -} - #[derive(Clone, Copy, PartialEq, Eq)] pub enum Level { Info, @@ -168,10 +154,6 @@ 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, @@ -250,10 +232,6 @@ 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, @@ -357,7 +335,6 @@ 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, @@ -463,7 +440,6 @@ 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), } } @@ -576,8 +552,6 @@ 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, @@ -792,58 +766,6 @@ 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; @@ -1078,7 +1000,6 @@ 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(), } } @@ -1268,16 +1189,6 @@ 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 9237286..c2881be 100644 --- a/src/command.rs +++ b/src/command.rs @@ -36,7 +36,6 @@ pub enum Command { AddTo(String), Import(String), Artist, - Lyrics, Help, } @@ -69,7 +68,6 @@ pub const COMMAND_NAMES: &[&str] = &[ "addto", "import", "artist", - "lyrics", "help", ]; @@ -177,7 +175,6 @@ 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)")), }; @@ -241,7 +238,6 @@ 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 deleted file mode 100644 index 9b29d69..0000000 --- a/src/lyrics.rs +++ /dev/null @@ -1,200 +0,0 @@ -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 aca55f5..f9af212 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ 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 26f2878..c00d787 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,11 +1,10 @@ -use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs, Wrap}; +use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs}; use ratatui::Frame; -use crate::app::{App, Level, LyricsState, MenuAction, Mode, PlayState, View, NO_KEY}; -use crate::lyrics::Lyrics; +use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY}; use crate::model::{fmt_time, Track}; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -34,24 +33,11 @@ pub fn draw(f: &mut Frame, app: &mut App) { ]) .areas(area); - 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_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), } draw_now_playing(f, app, playing); if app.mode == Mode::Command { @@ -93,42 +79,30 @@ 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(10), + Constraint::Length(9), Constraint::Min(10), - Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)), + Constraint::Length(34), ]) .areas(area); f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ♪ ", Style::new().fg(app.accent)), - Span::styled("Drift", Style::new().fg(app.accent).bold()), - ])), + Paragraph::new(" Drift").style(Style::new().fg(app.accent).bold()), name, ); let titles = [ - ("Results", app.results.len()), - ("Queue", app.queue.len()), - ("Playlists", app.store.playlists.len()), + format!(" Results {} ", app.results.len()), + format!(" Queue {} ", app.queue.len()), + format!(" Playlists {} ", app.store.playlists.len()), ]; - 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); + 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, + ); if let Some(busy) = &app.busy { let spin = SPINNER[app.tick % SPINNER.len()]; @@ -533,153 +507,6 @@ 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; @@ -801,7 +628,6 @@ 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"), ]; @@ -1070,7 +896,6 @@ 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", @@ -1116,7 +941,6 @@ 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)", ""), ];