mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae399991a0 |
|||
| 11e0b42b50 | |||
| 8f650f8820 |
8 changed files with 617 additions and 70 deletions
26
CHANGELOG.md
26
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.
|
||||
|
|
|
|||
149
README.md
149
README.md
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
[](https://github.com/socatlolmeow/drift/stargazers)
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
<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
|
||||
|
||||
[](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).
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 5 MiB After Width: | Height: | Size: 3.1 MiB |
89
src/app.rs
89
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<Page, String>,
|
||||
},
|
||||
Lyrics {
|
||||
id: String,
|
||||
result: Result<Option<Lyrics>, String>,
|
||||
},
|
||||
}
|
||||
|
||||
const FIRST_PAGE: usize = 20;
|
||||
|
|
@ -60,6 +65,7 @@ pub enum Mode {
|
|||
Help,
|
||||
Menu,
|
||||
Info,
|
||||
Lyrics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -70,6 +76,14 @@ pub enum PlayState {
|
|||
Paused,
|
||||
}
|
||||
|
||||
pub enum LyricsState {
|
||||
Idle,
|
||||
Loading,
|
||||
Ready(Lyrics),
|
||||
Missing,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Info,
|
||||
|
|
@ -154,6 +168,10 @@ pub struct App {
|
|||
pub artist_sel: usize,
|
||||
pub pending_delete: Option<String>,
|
||||
pub help_scroll: usize,
|
||||
pub lyrics: LyricsState,
|
||||
pub lyrics_panel: bool,
|
||||
pub lyrics_scroll: usize,
|
||||
pub lyrics_id: Option<String>,
|
||||
history: Vec<String>,
|
||||
hist_pos: Option<usize>,
|
||||
pub count: Option<usize>,
|
||||
|
|
@ -232,6 +250,10 @@ impl App {
|
|||
artist_sel: 0,
|
||||
pending_delete: None,
|
||||
help_scroll: 0,
|
||||
lyrics: LyricsState::Idle,
|
||||
lyrics_panel: false,
|
||||
lyrics_scroll: 0,
|
||||
lyrics_id: None,
|
||||
history: Vec::new(),
|
||||
hist_pos: None,
|
||||
count: None,
|
||||
|
|
@ -335,6 +357,7 @@ impl App {
|
|||
pub fn on_tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.load_more();
|
||||
self.sync_lyrics();
|
||||
if self.tick.is_multiple_of(50) {
|
||||
match self.save_session(false) {
|
||||
Ok(()) => self.save_failed = false,
|
||||
|
|
@ -440,6 +463,7 @@ impl App {
|
|||
Mode::ConfirmDelete => self.key_confirm_delete(key),
|
||||
Mode::Menu => self.key_menu(key),
|
||||
Mode::Info => self.key_info(key),
|
||||
Mode::Lyrics => self.key_lyrics(key),
|
||||
Mode::Normal => self.key_normal(key),
|
||||
}
|
||||
}
|
||||
|
|
@ -552,6 +576,8 @@ impl App {
|
|||
self.cmd_search = false;
|
||||
self.enter_command_mode();
|
||||
}
|
||||
KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel,
|
||||
KeyCode::Char('F') => self.open_lyrics(),
|
||||
KeyCode::Char('?') => self.open_help(),
|
||||
KeyCode::Char('q') => self.should_quit = true,
|
||||
KeyCode::Esc => self.status = None,
|
||||
|
|
@ -766,6 +792,58 @@ impl App {
|
|||
self.info_track = None;
|
||||
}
|
||||
|
||||
fn open_lyrics(&mut self) {
|
||||
self.mode = Mode::Lyrics;
|
||||
}
|
||||
|
||||
fn key_lyrics(&mut self, key: KeyEvent) {
|
||||
let scroll = self.lyrics_scroll;
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q' | 'F' | 'L') => self.mode = Mode::Normal,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.lyrics_scroll = scroll.saturating_add(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.lyrics_scroll = scroll.saturating_sub(1),
|
||||
KeyCode::Char(' ') => self.toggle_pause(),
|
||||
KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
|
||||
KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
|
||||
KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step)),
|
||||
KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step)),
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
self.set_volume(Amount::Delta(self.volume_step))
|
||||
}
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => {
|
||||
self.set_volume(Amount::Delta(-self.volume_step))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_lyrics(&mut self) {
|
||||
if !self.lyrics_panel && self.mode != Mode::Lyrics {
|
||||
return;
|
||||
}
|
||||
let Some(track) = self
|
||||
.now_track()
|
||||
.filter(|_| self.play_state != PlayState::Stopped)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.lyrics_id.as_deref() == Some(track.id.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.lyrics_id = Some(track.id.clone());
|
||||
self.lyrics = LyricsState::Loading;
|
||||
self.lyrics_scroll = 0;
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = lyrics::fetch(&track);
|
||||
let _ = tx.send(AppEvent::Lyrics {
|
||||
id: track.id,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn key_help(&mut self, key: KeyEvent) {
|
||||
let ctrl = is_ctrl(&key);
|
||||
let s = self.help_scroll;
|
||||
|
|
@ -1000,6 +1078,7 @@ impl App {
|
|||
Command::AddTo(name) => self.add_to_playlist(&name),
|
||||
Command::Import(url) => self.start_import(url),
|
||||
Command::Artist => self.go_to_artist(),
|
||||
Command::Lyrics => self.lyrics_panel = !self.lyrics_panel,
|
||||
Command::Help => self.open_help(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1189,6 +1268,16 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
AppEvent::Lyrics { id, result } => {
|
||||
if self.lyrics_id.as_deref() != Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.lyrics = match result {
|
||||
Ok(Some(l)) => LyricsState::Ready(l),
|
||||
Ok(None) => LyricsState::Missing,
|
||||
Err(e) => LyricsState::Failed(e),
|
||||
};
|
||||
}
|
||||
AppEvent::Player(generation, pe) => {
|
||||
if generation == self.player_gen {
|
||||
self.on_player(pe);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub enum Command {
|
|||
AddTo(String),
|
||||
Import(String),
|
||||
Artist,
|
||||
Lyrics,
|
||||
Help,
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ pub const COMMAND_NAMES: &[&str] = &[
|
|||
"addto",
|
||||
"import",
|
||||
"artist",
|
||||
"lyrics",
|
||||
"help",
|
||||
];
|
||||
|
||||
|
|
@ -175,6 +177,7 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
|||
Import(arg.to_string())
|
||||
}
|
||||
"artist" | "ar" => Artist,
|
||||
"lyrics" | "ly" => Lyrics,
|
||||
"help" | "h" => Help,
|
||||
other => return Err(format!("unknown command: :{other} (try :help)")),
|
||||
};
|
||||
|
|
@ -238,6 +241,7 @@ mod tests {
|
|||
fn artist_command() {
|
||||
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
||||
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
||||
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
200
src/lyrics.rs
Normal file
200
src/lyrics.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model::{parse_clock, split_artists, Track};
|
||||
|
||||
const SEARCH: &str = "https://lrclib.net/api/search";
|
||||
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
|
||||
const LEAD: f64 = 0.25;
|
||||
const MAX_DRIFT: f64 = 8.0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Line {
|
||||
pub at: f64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Lyrics {
|
||||
pub lines: Vec<Line>,
|
||||
pub synced: bool,
|
||||
}
|
||||
|
||||
impl Lyrics {
|
||||
pub fn active(&self, position: f64) -> Option<usize> {
|
||||
if !self.synced {
|
||||
return None;
|
||||
}
|
||||
let n = self.lines.partition_point(|l| l.at <= position + LEAD);
|
||||
n.checked_sub(1)
|
||||
}
|
||||
|
||||
pub fn progress(&self, i: usize, position: f64) -> f64 {
|
||||
let Some(line) = self.lines.get(i) else {
|
||||
return 0.0;
|
||||
};
|
||||
let sing = line.text.chars().count() as f64 * 0.1 + 1.0;
|
||||
let span = match self.lines.get(i + 1) {
|
||||
Some(next) => (next.at - line.at).min(sing),
|
||||
None => sing,
|
||||
};
|
||||
((position - line.at) / span.max(0.1)).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_lrc(text: &str) -> Vec<Line> {
|
||||
let mut out = Vec::new();
|
||||
for raw in text.lines() {
|
||||
let mut rest = raw.trim();
|
||||
let mut stamps = Vec::new();
|
||||
while let Some(tail) = rest.strip_prefix('[') {
|
||||
let Some((tag, after)) = tail.split_once(']') else {
|
||||
break;
|
||||
};
|
||||
let Some(t) = parse_clock(tag) else {
|
||||
break;
|
||||
};
|
||||
stamps.push(t);
|
||||
rest = after;
|
||||
}
|
||||
let text = rest.trim();
|
||||
for at in stamps {
|
||||
out.push(Line {
|
||||
at,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.at.total_cmp(&b.at));
|
||||
out
|
||||
}
|
||||
|
||||
fn from_entry(entry: &Value) -> Option<Lyrics> {
|
||||
let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty());
|
||||
if let Some(s) = text("syncedLyrics") {
|
||||
let lines = parse_lrc(s);
|
||||
if !lines.is_empty() {
|
||||
return Some(Lyrics { lines, synced: true });
|
||||
}
|
||||
}
|
||||
let plain = text("plainLyrics")?;
|
||||
let lines = plain
|
||||
.lines()
|
||||
.map(|l| Line {
|
||||
at: 0.0,
|
||||
text: l.trim().to_string(),
|
||||
})
|
||||
.collect();
|
||||
Some(Lyrics {
|
||||
lines,
|
||||
synced: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
|
||||
let drift = |e: &Value| match (duration, e["duration"].as_f64()) {
|
||||
(Some(want), Some(got)) => (got - f64::from(want)).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
let mut found: Vec<(bool, f64, Lyrics)> = entries
|
||||
.iter()
|
||||
.filter(|e| drift(e) <= MAX_DRIFT)
|
||||
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
|
||||
.collect();
|
||||
found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap());
|
||||
found.into_iter().next().map(|(_, _, l)| l)
|
||||
}
|
||||
|
||||
fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(UA)
|
||||
.build();
|
||||
let mut req = agent.get(SEARCH);
|
||||
for (k, v) in params {
|
||||
req = req.query(k, v);
|
||||
}
|
||||
req.call()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_json::<Vec<Value>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
|
||||
let artist = split_artists(&track.artist)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?;
|
||||
if let Some(l) = pick(&exact, track.duration) {
|
||||
return Ok(Some(l));
|
||||
}
|
||||
let loose = query(&[("q", &format!("{} {}", track.title, artist))])?;
|
||||
Ok(pick(&loose, track.duration))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_timestamps_and_skips_metadata() {
|
||||
let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]");
|
||||
let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
(12.5, "first"),
|
||||
(30.25, "shared"),
|
||||
(45.0, ""),
|
||||
(62.0, "shared")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_active_line() {
|
||||
let l = Lyrics {
|
||||
lines: parse_lrc("[00:10.00]a\n[00:20.00]b"),
|
||||
synced: true,
|
||||
};
|
||||
assert_eq!(l.active(5.0), None);
|
||||
assert_eq!(l.active(10.0), Some(0));
|
||||
assert_eq!(l.active(19.9), Some(1));
|
||||
assert_eq!(l.active(99.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_stays_in_range() {
|
||||
let l = Lyrics {
|
||||
lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"),
|
||||
synced: true,
|
||||
};
|
||||
assert_eq!(l.progress(0, 9.0), 0.0);
|
||||
assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6);
|
||||
assert_eq!(l.progress(0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_synced_and_close_duration() {
|
||||
let entries = [
|
||||
json!({"duration": 200.0, "plainLyrics": "plain"}),
|
||||
json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}),
|
||||
json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}),
|
||||
json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}),
|
||||
];
|
||||
let l = pick(&entries, Some(200)).unwrap();
|
||||
assert!(l.synced);
|
||||
assert_eq!(l.lines[0].text, "nearest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_plain_lyrics() {
|
||||
let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})];
|
||||
let l = pick(&entries, Some(200)).unwrap();
|
||||
assert!(!l.synced);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod app;
|
||||
mod command;
|
||||
mod config;
|
||||
mod lyrics;
|
||||
mod model;
|
||||
mod player;
|
||||
mod session;
|
||||
|
|
|
|||
220
src/ui.rs
220
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<Line<'static>> {
|
|||
|
||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
||||
let [name, tabs, right] = Layout::horizontal([
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(34),
|
||||
Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(" Drift").style(Style::new().fg(app.accent).bold()),
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ♪ ", Style::new().fg(app.accent)),
|
||||
Span::styled("Drift", Style::new().fg(app.accent).bold()),
|
||||
])),
|
||||
name,
|
||||
);
|
||||
|
||||
let titles = [
|
||||
format!(" Results {} ", app.results.len()),
|
||||
format!(" Queue {} ", app.queue.len()),
|
||||
format!(" Playlists {} ", app.store.playlists.len()),
|
||||
("Results", app.results.len()),
|
||||
("Queue", app.queue.len()),
|
||||
("Playlists", app.store.playlists.len()),
|
||||
];
|
||||
f.render_widget(
|
||||
Tabs::new(titles)
|
||||
.select(app.view.idx())
|
||||
.style(Style::new().fg(Color::Gray))
|
||||
.highlight_style(Style::new().fg(Color::Black).bg(app.accent).bold())
|
||||
.divider(" "),
|
||||
tabs,
|
||||
);
|
||||
let pill = Style::new().fg(Color::Black).bg(app.accent);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
for (i, (title, count)) in titles.into_iter().enumerate() {
|
||||
if i == app.view.idx() {
|
||||
spans.push(Span::styled("▐", Style::new().fg(app.accent)));
|
||||
spans.push(Span::styled(format!(" {title} "), pill.bold()));
|
||||
spans.push(Span::styled(format!("{count} "), pill));
|
||||
spans.push(Span::styled("▌", Style::new().fg(app.accent)));
|
||||
} else {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!(" {title} "), Style::new().fg(Color::Gray)));
|
||||
spans.push(Span::styled(format!("{count} "), dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), tabs);
|
||||
|
||||
if let Some(busy) = &app.busy {
|
||||
let spin = SPINNER[app.tick % SPINNER.len()];
|
||||
|
|
@ -507,6 +533,153 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
|||
f.render_widget(Paragraph::new(Line::from(line)), l2);
|
||||
}
|
||||
|
||||
fn draw_lyrics_header(f: &mut Frame, app: &App, area: Rect) {
|
||||
let unsynced = matches!(&app.lyrics, LyricsState::Ready(l) if !l.synced);
|
||||
let mut title = vec![
|
||||
Span::styled(" Drift", Style::new().fg(app.accent).bold()),
|
||||
Span::styled(" · Lyrics", dim()),
|
||||
];
|
||||
if unsynced {
|
||||
title.push(Span::styled(" · not synced", dim()));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(title)), area);
|
||||
|
||||
let mut keys = vec![("space", "pause"), ("h l", "seek"), ("n N", "skip")];
|
||||
if unsynced {
|
||||
keys.insert(0, ("j k", "scroll"));
|
||||
}
|
||||
keys.push(("Esc", "close"));
|
||||
if let Some(h) = hints(&keys, area.width.saturating_sub(30)) {
|
||||
f.render_widget(Paragraph::new(h), area);
|
||||
}
|
||||
}
|
||||
|
||||
fn lyric_rows(
|
||||
lyrics: &Lyrics,
|
||||
position: f64,
|
||||
width: usize,
|
||||
roomy: bool,
|
||||
accent: Color,
|
||||
) -> (Vec<Line<'static>>, Option<usize>) {
|
||||
let active = lyrics.active(position);
|
||||
let mut rows: Vec<Line<'static>> = Vec::new();
|
||||
let mut focus = None;
|
||||
for (i, line) in lyrics.lines.iter().enumerate() {
|
||||
let parts = wrap_words(&line.text, width);
|
||||
let is_active = active == Some(i);
|
||||
if is_active {
|
||||
focus = Some(rows.len() + parts.len().max(1) / 2);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
rows.push(if is_active {
|
||||
Line::styled("♪", Style::new().fg(accent).bold())
|
||||
} else {
|
||||
Line::raw("")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if is_active {
|
||||
let total: usize = parts.iter().map(|p| p.chars().count()).sum();
|
||||
let mut fill = (lyrics.progress(i, position) * total as f64).round() as usize;
|
||||
for part in parts {
|
||||
let done = fill.min(part.chars().count());
|
||||
fill -= done;
|
||||
let sung: String = part.chars().take(done).collect();
|
||||
let rest: String = part.chars().skip(done).collect();
|
||||
rows.push(Line::from(vec![
|
||||
Span::styled(sung, Style::new().fg(accent).bold()),
|
||||
Span::styled(rest, Style::new().fg(Color::White).bold()),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
let ahead = active.map_or(i as isize + 1, |a| i as isize - a as isize);
|
||||
let color = match (lyrics.synced, ahead) {
|
||||
(false, _) | (_, 1..=2) => Color::Gray,
|
||||
_ => Color::DarkGray,
|
||||
};
|
||||
rows.extend(parts.into_iter().map(|p| Line::styled(p, Style::new().fg(color))));
|
||||
}
|
||||
if roomy {
|
||||
rows.push(Line::raw(""));
|
||||
}
|
||||
}
|
||||
(rows, focus)
|
||||
}
|
||||
|
||||
fn draw_lyrics(f: &mut Frame, app: &mut App, area: Rect, full: bool) {
|
||||
let inner = if full {
|
||||
let w = area.width.saturating_sub(4).min(76);
|
||||
Rect {
|
||||
x: area.x + (area.width - w) / 2,
|
||||
width: w,
|
||||
..area
|
||||
}
|
||||
} else {
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(dim())
|
||||
.title(Span::styled(" ♪ Lyrics ", Style::new().fg(app.accent).bold()));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
inner.inner(Margin::new(1, 0))
|
||||
};
|
||||
if inner.width < 4 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = app.now_track().map(|t| t.id.clone());
|
||||
let playing = now.is_some() && app.play_state != PlayState::Stopped;
|
||||
let fresh = playing && app.lyrics_id == now;
|
||||
let notice: Option<Vec<Line>> = match &app.lyrics {
|
||||
_ if !playing => Some(vec![Line::styled("Nothing playing", dim())]),
|
||||
LyricsState::Ready(_) if fresh => None,
|
||||
LyricsState::Missing if fresh => Some(vec![
|
||||
Line::styled("No lyrics found", Style::new().fg(Color::Gray)),
|
||||
Line::styled("not every song has them", dim()),
|
||||
]),
|
||||
LyricsState::Failed(e) if fresh => Some(vec![
|
||||
Line::styled("Couldn't load lyrics", Style::new().fg(Color::Red)),
|
||||
Line::styled(e.clone(), dim()),
|
||||
]),
|
||||
_ => Some(vec![Line::styled(
|
||||
format!("{} Searching lyrics…", SPINNER[app.tick % SPINNER.len()]),
|
||||
Style::new().fg(Color::Yellow),
|
||||
)]),
|
||||
};
|
||||
if let Some(lines) = notice {
|
||||
let h = (lines.len() as u16 + 1).min(inner.height);
|
||||
let rect = Rect {
|
||||
y: inner.y + (inner.height - h) / 2,
|
||||
height: h,
|
||||
..inner
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
rect,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let LyricsState::Ready(lyrics) = &app.lyrics else {
|
||||
return;
|
||||
};
|
||||
let (rows, focus) = lyric_rows(lyrics, app.position, inner.width as usize, full, app.accent);
|
||||
let height = inner.height as usize;
|
||||
let max_start = rows.len().saturating_sub(height);
|
||||
let start = if lyrics.synced {
|
||||
focus.map_or(0, |r| r.saturating_sub(height / 2))
|
||||
} else {
|
||||
app.lyrics_scroll
|
||||
}
|
||||
.min(max_start);
|
||||
app.lyrics_scroll = start;
|
||||
let visible: Vec<Line> = rows.into_iter().skip(start).take(height).collect();
|
||||
let align = if full { Alignment::Center } else { Alignment::Left };
|
||||
f.render_widget(Paragraph::new(visible).alignment(align), inner);
|
||||
}
|
||||
|
||||
fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
|
||||
let Some(s) = app.visible_status() else {
|
||||
return;
|
||||
|
|
@ -628,6 +801,7 @@ const CMD_INFO: &[(&str, &str)] = &[
|
|||
("addto", "add the selected track to a playlist"),
|
||||
("import", "import a YouTube / YT Music playlist URL"),
|
||||
("artist", "songs by the selected artist"),
|
||||
("lyrics", "show or hide the lyrics panel"),
|
||||
("help", "show key bindings"),
|
||||
];
|
||||
|
||||
|
|
@ -896,6 +1070,7 @@ const HELP: &[(&str, &str)] = &[
|
|||
("h l / ← →", "seek -5s / +5s (counts: 6l)"),
|
||||
("+ -", "volume up / down"),
|
||||
("s r", "toggle shuffle / cycle repeat"),
|
||||
("L F", "lyrics side panel / fullscreen lyrics (Esc closes)"),
|
||||
(
|
||||
"ga or :artist",
|
||||
"songs by the artist of the selected song; a popup asks which one when there are several",
|
||||
|
|
@ -941,6 +1116,7 @@ const HELP: &[(&str, &str)] = &[
|
|||
(":addto n", "add the selected track to playlist n"),
|
||||
(":import URL", "load a YouTube / YT Music playlist"),
|
||||
(":artist", "same as ga"),
|
||||
(":lyrics", "same as L"),
|
||||
(":quit (or q, Ctrl-c)", ""),
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue