mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-04 19:57:18 +00:00
Compare commits
8 changed files with 70 additions and 617 deletions
26
CHANGELOG.md
26
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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Synced lyrics via LRCLIB. `L` or `:lyrics` toggles a side panel, `F` opens fullscreen.
|
- **Windows support.** Drift now runs natively on Windows. It talks to `mpv` over named pipes instead of Unix sockets.
|
||||||
- Current lyric line is highlighted with a progress fill; nearby lines fade out.
|
- **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.
|
||||||
- Plain lyrics fallback (scroll with `j`/`k`) when no synced version exists.
|
- **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
|
### Changed
|
||||||
|
|
||||||
- Tab bar redesigned as pills with dimmed counts on inactive tabs.
|
- Status messages no longer share the bottom border of the Now Playing bar. That border now only shows pending key presses.
|
||||||
- Tab bar only reserves spinner space while busy, so tabs fit on narrow terminals.
|
- 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.
|
||||||
|
|
|
||||||
149
README.md
149
README.md
|
|
@ -4,11 +4,10 @@
|
||||||
|
|
||||||
[](https://github.com/socatlolmeow/drift/stargazers)
|
[](https://github.com/socatlolmeow/drift/stargazers)
|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -22,28 +21,26 @@ Search, queue and play music with no account, no login and no browser.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **No login** - search and stream straight from YouTube Music
|
- **YouTube Music streaming** - search and play tracks straight from YouTube Music, with no account or login
|
||||||
- **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line
|
- **Vim-style keybinds** - move with `j`/`k`, jump with `gg`/`ge`, use counts like `5j`, and open the command line with `:`
|
||||||
- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung
|
- **Song context menu** - press `G` on any song for quick actions: play, queue, add to playlist, show artist, and song info
|
||||||
- **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL
|
- **Command palette** - a redesigned `:` prompt with the full command set (`:search`, `:seek`, `:vol`, `:import`, and more)
|
||||||
- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info
|
- **Audio visualizer** - a live visualizer in the bottom-left corner while music plays
|
||||||
- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history
|
- **Playlists & queue** - save, load, and reorder playlists, or import any YouTube / YouTube Music playlist by URL
|
||||||
- **Artist browsing** - press `ga` to see an artist's songs
|
- **Artist browsing** - press `ga` to jump to an artist's songs
|
||||||
- **Audio visualizer** - a live visualizer while music plays
|
- **Session restore** - your queue and playback position come back the next time you launch
|
||||||
- **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
|
## 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)
|
### Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S driftmusic-git
|
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
|
```bash
|
||||||
git clone https://aur.archlinux.org/driftmusic-git.git
|
git clone https://aur.archlinux.org/driftmusic-git.git
|
||||||
|
|
@ -53,127 +50,37 @@ makepkg -si
|
||||||
|
|
||||||
`mpv` and `yt-dlp` are installed automatically.
|
`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
|
```bash
|
||||||
git clone https://github.com/socatlolmeow/drift.git
|
git clone https://github.com/socatlolmeow/drift.git
|
||||||
cd drift
|
cd drift
|
||||||
./install.sh
|
cargo build --release
|
||||||
|
sudo cp target/release/drift /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
### From source
|
Or install directly with cargo:
|
||||||
|
|
||||||
Install the dependencies and [Rust](https://rustup.rs/), then:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/socatlolmeow/drift.git
|
|
||||||
cd drift
|
|
||||||
cargo install --path .
|
cargo install --path .
|
||||||
```
|
```
|
||||||
|
|
||||||
This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup.
|
This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/).
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Installing mpv and yt-dlp manually</summary>
|
|
||||||
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
[](https://star-history.com/#socatlolmeow/drift)
|
[](https://star-history.com/#socatlolmeow/drift)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## 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).
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 5 MiB |
89
src/app.rs
89
src/app.rs
|
|
@ -8,7 +8,6 @@ use ratatui::widgets::TableState;
|
||||||
|
|
||||||
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
||||||
use crate::config::{self, Config};
|
use crate::config::{self, Config};
|
||||||
use crate::lyrics::{self, Lyrics};
|
|
||||||
use crate::model::{fmt_time, split_artists, RepeatMode, Track};
|
use crate::model::{fmt_time, split_artists, RepeatMode, Track};
|
||||||
use crate::player::{Player, PlayerEvent};
|
use crate::player::{Player, PlayerEvent};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
@ -26,10 +25,6 @@ pub enum AppEvent {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
result: Result<Page, String>,
|
result: Result<Page, String>,
|
||||||
},
|
},
|
||||||
Lyrics {
|
|
||||||
id: String,
|
|
||||||
result: Result<Option<Lyrics>, String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIRST_PAGE: usize = 20;
|
const FIRST_PAGE: usize = 20;
|
||||||
|
|
@ -65,7 +60,6 @@ pub enum Mode {
|
||||||
Help,
|
Help,
|
||||||
Menu,
|
Menu,
|
||||||
Info,
|
Info,
|
||||||
Lyrics,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -76,14 +70,6 @@ pub enum PlayState {
|
||||||
Paused,
|
Paused,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum LyricsState {
|
|
||||||
Idle,
|
|
||||||
Loading,
|
|
||||||
Ready(Lyrics),
|
|
||||||
Missing,
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Level {
|
pub enum Level {
|
||||||
Info,
|
Info,
|
||||||
|
|
@ -168,10 +154,6 @@ pub struct App {
|
||||||
pub artist_sel: usize,
|
pub artist_sel: usize,
|
||||||
pub pending_delete: Option<String>,
|
pub pending_delete: Option<String>,
|
||||||
pub help_scroll: usize,
|
pub help_scroll: usize,
|
||||||
pub lyrics: LyricsState,
|
|
||||||
pub lyrics_panel: bool,
|
|
||||||
pub lyrics_scroll: usize,
|
|
||||||
pub lyrics_id: Option<String>,
|
|
||||||
history: Vec<String>,
|
history: Vec<String>,
|
||||||
hist_pos: Option<usize>,
|
hist_pos: Option<usize>,
|
||||||
pub count: Option<usize>,
|
pub count: Option<usize>,
|
||||||
|
|
@ -250,10 +232,6 @@ impl App {
|
||||||
artist_sel: 0,
|
artist_sel: 0,
|
||||||
pending_delete: None,
|
pending_delete: None,
|
||||||
help_scroll: 0,
|
help_scroll: 0,
|
||||||
lyrics: LyricsState::Idle,
|
|
||||||
lyrics_panel: false,
|
|
||||||
lyrics_scroll: 0,
|
|
||||||
lyrics_id: None,
|
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
hist_pos: None,
|
hist_pos: None,
|
||||||
count: None,
|
count: None,
|
||||||
|
|
@ -357,7 +335,6 @@ impl App {
|
||||||
pub fn on_tick(&mut self) {
|
pub fn on_tick(&mut self) {
|
||||||
self.tick = self.tick.wrapping_add(1);
|
self.tick = self.tick.wrapping_add(1);
|
||||||
self.load_more();
|
self.load_more();
|
||||||
self.sync_lyrics();
|
|
||||||
if self.tick.is_multiple_of(50) {
|
if self.tick.is_multiple_of(50) {
|
||||||
match self.save_session(false) {
|
match self.save_session(false) {
|
||||||
Ok(()) => self.save_failed = false,
|
Ok(()) => self.save_failed = false,
|
||||||
|
|
@ -463,7 +440,6 @@ impl App {
|
||||||
Mode::ConfirmDelete => self.key_confirm_delete(key),
|
Mode::ConfirmDelete => self.key_confirm_delete(key),
|
||||||
Mode::Menu => self.key_menu(key),
|
Mode::Menu => self.key_menu(key),
|
||||||
Mode::Info => self.key_info(key),
|
Mode::Info => self.key_info(key),
|
||||||
Mode::Lyrics => self.key_lyrics(key),
|
|
||||||
Mode::Normal => self.key_normal(key),
|
Mode::Normal => self.key_normal(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -576,8 +552,6 @@ impl App {
|
||||||
self.cmd_search = false;
|
self.cmd_search = false;
|
||||||
self.enter_command_mode();
|
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('?') => self.open_help(),
|
||||||
KeyCode::Char('q') => self.should_quit = true,
|
KeyCode::Char('q') => self.should_quit = true,
|
||||||
KeyCode::Esc => self.status = None,
|
KeyCode::Esc => self.status = None,
|
||||||
|
|
@ -792,58 +766,6 @@ impl App {
|
||||||
self.info_track = None;
|
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) {
|
fn key_help(&mut self, key: KeyEvent) {
|
||||||
let ctrl = is_ctrl(&key);
|
let ctrl = is_ctrl(&key);
|
||||||
let s = self.help_scroll;
|
let s = self.help_scroll;
|
||||||
|
|
@ -1078,7 +1000,6 @@ impl App {
|
||||||
Command::AddTo(name) => self.add_to_playlist(&name),
|
Command::AddTo(name) => self.add_to_playlist(&name),
|
||||||
Command::Import(url) => self.start_import(url),
|
Command::Import(url) => self.start_import(url),
|
||||||
Command::Artist => self.go_to_artist(),
|
Command::Artist => self.go_to_artist(),
|
||||||
Command::Lyrics => self.lyrics_panel = !self.lyrics_panel,
|
|
||||||
Command::Help => self.open_help(),
|
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) => {
|
AppEvent::Player(generation, pe) => {
|
||||||
if generation == self.player_gen {
|
if generation == self.player_gen {
|
||||||
self.on_player(pe);
|
self.on_player(pe);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ pub enum Command {
|
||||||
AddTo(String),
|
AddTo(String),
|
||||||
Import(String),
|
Import(String),
|
||||||
Artist,
|
Artist,
|
||||||
Lyrics,
|
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +68,6 @@ pub const COMMAND_NAMES: &[&str] = &[
|
||||||
"addto",
|
"addto",
|
||||||
"import",
|
"import",
|
||||||
"artist",
|
"artist",
|
||||||
"lyrics",
|
|
||||||
"help",
|
"help",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -177,7 +175,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
||||||
Import(arg.to_string())
|
Import(arg.to_string())
|
||||||
}
|
}
|
||||||
"artist" | "ar" => Artist,
|
"artist" | "ar" => Artist,
|
||||||
"lyrics" | "ly" => Lyrics,
|
|
||||||
"help" | "h" => Help,
|
"help" | "h" => Help,
|
||||||
other => return Err(format!("unknown command: :{other} (try :help)")),
|
other => return Err(format!("unknown command: :{other} (try :help)")),
|
||||||
};
|
};
|
||||||
|
|
@ -241,7 +238,6 @@ mod tests {
|
||||||
fn artist_command() {
|
fn artist_command() {
|
||||||
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
||||||
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
||||||
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
200
src/lyrics.rs
200
src/lyrics.rs
|
|
@ -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<Line>,
|
|
||||||
pub synced: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Lyrics {
|
|
||||||
pub fn active(&self, position: f64) -> Option<usize> {
|
|
||||||
if !self.synced {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let n = self.lines.partition_point(|l| l.at <= position + LEAD);
|
|
||||||
n.checked_sub(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self, i: usize, position: f64) -> f64 {
|
|
||||||
let Some(line) = self.lines.get(i) else {
|
|
||||||
return 0.0;
|
|
||||||
};
|
|
||||||
let sing = line.text.chars().count() as f64 * 0.1 + 1.0;
|
|
||||||
let span = match self.lines.get(i + 1) {
|
|
||||||
Some(next) => (next.at - line.at).min(sing),
|
|
||||||
None => sing,
|
|
||||||
};
|
|
||||||
((position - line.at) / span.max(0.1)).clamp(0.0, 1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_lrc(text: &str) -> Vec<Line> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for raw in text.lines() {
|
|
||||||
let mut rest = raw.trim();
|
|
||||||
let mut stamps = Vec::new();
|
|
||||||
while let Some(tail) = rest.strip_prefix('[') {
|
|
||||||
let Some((tag, after)) = tail.split_once(']') else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let Some(t) = parse_clock(tag) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
stamps.push(t);
|
|
||||||
rest = after;
|
|
||||||
}
|
|
||||||
let text = rest.trim();
|
|
||||||
for at in stamps {
|
|
||||||
out.push(Line {
|
|
||||||
at,
|
|
||||||
text: text.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.at.total_cmp(&b.at));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_entry(entry: &Value) -> Option<Lyrics> {
|
|
||||||
let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty());
|
|
||||||
if let Some(s) = text("syncedLyrics") {
|
|
||||||
let lines = parse_lrc(s);
|
|
||||||
if !lines.is_empty() {
|
|
||||||
return Some(Lyrics { lines, synced: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let plain = text("plainLyrics")?;
|
|
||||||
let lines = plain
|
|
||||||
.lines()
|
|
||||||
.map(|l| Line {
|
|
||||||
at: 0.0,
|
|
||||||
text: l.trim().to_string(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Some(Lyrics {
|
|
||||||
lines,
|
|
||||||
synced: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
|
|
||||||
let drift = |e: &Value| match (duration, e["duration"].as_f64()) {
|
|
||||||
(Some(want), Some(got)) => (got - f64::from(want)).abs(),
|
|
||||||
_ => 0.0,
|
|
||||||
};
|
|
||||||
let mut found: Vec<(bool, f64, Lyrics)> = entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| drift(e) <= MAX_DRIFT)
|
|
||||||
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
|
|
||||||
.collect();
|
|
||||||
found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap());
|
|
||||||
found.into_iter().next().map(|(_, _, l)| l)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
|
|
||||||
let agent = ureq::AgentBuilder::new()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.user_agent(UA)
|
|
||||||
.build();
|
|
||||||
let mut req = agent.get(SEARCH);
|
|
||||||
for (k, v) in params {
|
|
||||||
req = req.query(k, v);
|
|
||||||
}
|
|
||||||
req.call()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.into_json::<Vec<Value>>()
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
|
|
||||||
let artist = split_artists(&track.artist)
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?;
|
|
||||||
if let Some(l) = pick(&exact, track.duration) {
|
|
||||||
return Ok(Some(l));
|
|
||||||
}
|
|
||||||
let loose = query(&[("q", &format!("{} {}", track.title, artist))])?;
|
|
||||||
Ok(pick(&loose, track.duration))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_timestamps_and_skips_metadata() {
|
|
||||||
let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]");
|
|
||||||
let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect();
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
[
|
|
||||||
(12.5, "first"),
|
|
||||||
(30.25, "shared"),
|
|
||||||
(45.0, ""),
|
|
||||||
(62.0, "shared")
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finds_active_line() {
|
|
||||||
let l = Lyrics {
|
|
||||||
lines: parse_lrc("[00:10.00]a\n[00:20.00]b"),
|
|
||||||
synced: true,
|
|
||||||
};
|
|
||||||
assert_eq!(l.active(5.0), None);
|
|
||||||
assert_eq!(l.active(10.0), Some(0));
|
|
||||||
assert_eq!(l.active(19.9), Some(1));
|
|
||||||
assert_eq!(l.active(99.0), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_stays_in_range() {
|
|
||||||
let l = Lyrics {
|
|
||||||
lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"),
|
|
||||||
synced: true,
|
|
||||||
};
|
|
||||||
assert_eq!(l.progress(0, 9.0), 0.0);
|
|
||||||
assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6);
|
|
||||||
assert_eq!(l.progress(0, 30.0), 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefers_synced_and_close_duration() {
|
|
||||||
let entries = [
|
|
||||||
json!({"duration": 200.0, "plainLyrics": "plain"}),
|
|
||||||
json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}),
|
|
||||||
json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}),
|
|
||||||
json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}),
|
|
||||||
];
|
|
||||||
let l = pick(&entries, Some(200)).unwrap();
|
|
||||||
assert!(l.synced);
|
|
||||||
assert_eq!(l.lines[0].text, "nearest");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_plain_lyrics() {
|
|
||||||
let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})];
|
|
||||||
let l = pick(&entries, Some(200)).unwrap();
|
|
||||||
assert!(!l.synced);
|
|
||||||
assert_eq!(l.lines.len(), 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
mod app;
|
mod app;
|
||||||
mod command;
|
mod command;
|
||||||
mod config;
|
mod config;
|
||||||
mod lyrics;
|
|
||||||
mod model;
|
mod model;
|
||||||
mod player;
|
mod player;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
|
||||||
216
src/ui.rs
216
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::style::{Color, Modifier, Style, Stylize};
|
||||||
use ratatui::text::{Line, Span};
|
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 ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Level, LyricsState, MenuAction, Mode, PlayState, View, NO_KEY};
|
use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY};
|
||||||
use crate::lyrics::Lyrics;
|
|
||||||
use crate::model::{fmt_time, Track};
|
use crate::model::{fmt_time, Track};
|
||||||
|
|
||||||
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
@ -34,24 +33,11 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
])
|
])
|
||||||
.areas(area);
|
.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);
|
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 {
|
match app.view {
|
||||||
View::Results => draw_tracks(f, app, list, View::Results),
|
View::Results => draw_tracks(f, app, body, View::Results),
|
||||||
View::Queue => draw_tracks(f, app, list, View::Queue),
|
View::Queue => draw_tracks(f, app, body, View::Queue),
|
||||||
View::Playlists => draw_playlists(f, app, list),
|
View::Playlists => draw_playlists(f, app, body),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
draw_now_playing(f, app, playing);
|
draw_now_playing(f, app, playing);
|
||||||
if app.mode == Mode::Command {
|
if app.mode == Mode::Command {
|
||||||
|
|
@ -93,42 +79,30 @@ fn hints(items: &[(&str, &str)], width: u16) -> Option<Line<'static>> {
|
||||||
|
|
||||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let [name, tabs, right] = Layout::horizontal([
|
let [name, tabs, right] = Layout::horizontal([
|
||||||
Constraint::Length(10),
|
Constraint::Length(9),
|
||||||
Constraint::Min(10),
|
Constraint::Min(10),
|
||||||
Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)),
|
Constraint::Length(34),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(" Drift").style(Style::new().fg(app.accent).bold()),
|
||||||
Span::styled(" ♪ ", Style::new().fg(app.accent)),
|
|
||||||
Span::styled("Drift", Style::new().fg(app.accent).bold()),
|
|
||||||
])),
|
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|
||||||
let titles = [
|
let titles = [
|
||||||
("Results", app.results.len()),
|
format!(" Results {} ", app.results.len()),
|
||||||
("Queue", app.queue.len()),
|
format!(" Queue {} ", app.queue.len()),
|
||||||
("Playlists", app.store.playlists.len()),
|
format!(" Playlists {} ", app.store.playlists.len()),
|
||||||
];
|
];
|
||||||
let pill = Style::new().fg(Color::Black).bg(app.accent);
|
f.render_widget(
|
||||||
let mut spans: Vec<Span> = Vec::new();
|
Tabs::new(titles)
|
||||||
for (i, (title, count)) in titles.into_iter().enumerate() {
|
.select(app.view.idx())
|
||||||
if i == app.view.idx() {
|
.style(Style::new().fg(Color::Gray))
|
||||||
spans.push(Span::styled("▐", Style::new().fg(app.accent)));
|
.highlight_style(Style::new().fg(Color::Black).bg(app.accent).bold())
|
||||||
spans.push(Span::styled(format!(" {title} "), pill.bold()));
|
.divider(" "),
|
||||||
spans.push(Span::styled(format!("{count} "), pill));
|
tabs,
|
||||||
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 {
|
if let Some(busy) = &app.busy {
|
||||||
let spin = SPINNER[app.tick % SPINNER.len()];
|
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);
|
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) {
|
fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
|
||||||
let Some(s) = app.visible_status() else {
|
let Some(s) = app.visible_status() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -801,7 +628,6 @@ const CMD_INFO: &[(&str, &str)] = &[
|
||||||
("addto", "add the selected track to a playlist"),
|
("addto", "add the selected track to a playlist"),
|
||||||
("import", "import a YouTube / YT Music playlist URL"),
|
("import", "import a YouTube / YT Music playlist URL"),
|
||||||
("artist", "songs by the selected artist"),
|
("artist", "songs by the selected artist"),
|
||||||
("lyrics", "show or hide the lyrics panel"),
|
|
||||||
("help", "show key bindings"),
|
("help", "show key bindings"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1070,7 +896,6 @@ const HELP: &[(&str, &str)] = &[
|
||||||
("h l / ← →", "seek -5s / +5s (counts: 6l)"),
|
("h l / ← →", "seek -5s / +5s (counts: 6l)"),
|
||||||
("+ -", "volume up / down"),
|
("+ -", "volume up / down"),
|
||||||
("s r", "toggle shuffle / cycle repeat"),
|
("s r", "toggle shuffle / cycle repeat"),
|
||||||
("L F", "lyrics side panel / fullscreen lyrics (Esc closes)"),
|
|
||||||
(
|
(
|
||||||
"ga or :artist",
|
"ga or :artist",
|
||||||
"songs by the artist of the selected song; a popup asks which one when there are several",
|
"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"),
|
(":addto n", "add the selected track to playlist n"),
|
||||||
(":import URL", "load a YouTube / YT Music playlist"),
|
(":import URL", "load a YouTube / YT Music playlist"),
|
||||||
(":artist", "same as ga"),
|
(":artist", "same as ga"),
|
||||||
(":lyrics", "same as L"),
|
|
||||||
(":quit (or q, Ctrl-c)", ""),
|
(":quit (or q, Ctrl-c)", ""),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue