Compare commits

...
Author SHA1 Message Date
8d24596cbd
update(readme): update readme with table of contents
Added a table of contents to the README for user friendlyness.
2026-11-03 14:18:23 +01:00
a1a3106926
change(cargo.toml): change license
Changed the license in Cargo.toml from MIT to GPL v3 or later :3
2026-11-03 14:14:49 +01:00
4570de2da7 feat(config): configurable keybinds via [keys] in config.toml, add
cookies_from_browser option

Mainview keys can now be remapped per action. vim keys remain the
default and anything left uset keeps its default. Supports ctrl+
keys, named keys and multi-key sequences like "gg". Invalid entries show
a warning on start :3
You're now able to use cookies from your browser to fetch the songs. See
config.toml.
2026-11-03 14:10:46 +01:00
8da1876835
Add GitHub Actions workflow for Rust project 2026-11-02 19:01:40 +01:00
5237a6bae8 feat(player): stream audio by default, add [audio] config for cookies and downloads
Long tracks (15 min to 1 hour+) didnt playbecause every song was fully
downloaded with yt-dlp into the cache before mpv was started.

Resolve the direct media URL with yt-dlp -g and hand it to mpv, so
playback starts within a couple of seconds. Cached files are still
used when present.

Add an [audio] section to config.toml:
- download: download songs to the cache before playing (default false,
  i.e. stream). Prefetch of the next track only runs in this mode.
- download_max_minutes: in download mode, songs longer than this (or of
  unknown length) are still streamed so 0 means no limit. default is le 15.
- cookies_from_browser: passed to yt-dlp as --cookies-from-browser for
  playback, for when YouTube asks to sign in or blocks a song.
2026-11-02 17:57:31 +01:00
f25b96d5fd feat(discord): show song name instead of application name 2026-10-30 14:02:04 +01:00
27353d3358
docs(readme): update features
meow >w<
2026-10-30 02:31:11 +01:00
43289e1c42
Merge pull request #2 from olivr2346:pr/save-album-as-playlist
feat: add native support for saving albums and results as playlists
2026-10-29 19:09:12 +01:00
0867ebe682
Merge branch 'main' into pr/save-album-as-playlist 2026-10-29 19:08:59 +01:00
fc21fbffd8
docs(readme): add install script distros 2026-10-29 18:47:25 +01:00
olivr
ad2a1462e8 fix(app): restore split_artists import after rebase onto upstream 1.4 2026-10-29 18:32:14 +01:00
olivr
d594184ff7 fix(ui): add savealbum and saveresults to command palette descriptions 2026-10-29 18:30:23 +01:00
olivr
1cd329d112 feat: add native support for saving albums and results as playlists 2026-10-29 18:30:23 +01:00
11 changed files with 1118 additions and 144 deletions

22
.github/workflows/rust.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

View file

@ -3,7 +3,7 @@ name = "drift"
version = "0.1.0"
edition = "2021"
description = "A Vim-style terminal music player for YouTube Music (no login required)"
license = "MIT"
license = "GPL-3.0-or-later"
[workspace]

207
README.md
View file

@ -1,41 +1,61 @@
<div align="center">
<h1>Drift</h1>
[![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)
# Drift
### A YouTube Music player for your terminal, with Vim keybinds.
Search, queue and play music with no account, no login and no browser.
[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers)
[![Build](https://img.shields.io/github/actions/workflow/status/socatlolmeow/drift/rust.yml?branch=main&style=flat-square&label=build)](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml)
[![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square)](LICENSE)
![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust)
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
</div>
---
<div align="center">
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
</div>
## Table of contents
- [Features](#features)
- [Installation](#installation)
- [Arch Linux (AUR)](#arch-linux-aur)
- [Install script](#install-script)
- [From source](#from-source)
- [Nix](#nix)
- [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually)
- [Usage](#usage)
- [Keybinds](#keybinds)
- [Commands](#commands)
- [Lyrics](#lyrics)
- [Configuration](#configuration)
- [Streaming or downloading](#streaming-or-downloading)
- [Discord rich presence](#discord-rich-presence)
- [Custom keys](#custom-keys)
- [Environment variables](#environment-variables)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
- [License](#license)
---
## 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
- **Similar songs** - press `gs` to see similar songs
- **Audio visualizer** - a live visualizer while music plays
- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file
- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line
- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung
- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL
- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists
- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info
- A command palette with completion and history
- Your queue and position are restored the next time you start Drift
- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence
- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds
## 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.
Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) to get the audio streams. Run `drift --check` at any time to see if both are installed.
### Arch Linux (AUR)
@ -43,7 +63,7 @@ Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/
yay -S driftmusic-git
```
Any AUR helper works, or build it by hand:
Any AUR helper works. You can also build it by hand:
```bash
git clone https://aur.archlinux.org/driftmusic-git.git
@ -51,11 +71,11 @@ cd driftmusic-git
makepkg -si
```
`mpv` and `yt-dlp` are installed automatically.
`mpv` and `yt-dlp` are pulled in automatically.
### Install script
On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo:
Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo.
```bash
git clone https://github.com/socatlolmeow/drift.git
@ -75,8 +95,15 @@ cargo install --path .
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>
### Nix
The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you:
```bash
nix run github:socatlolmeow/drift
```
### Installing mpv and yt-dlp manually
| Platform | Command |
| --- | --- |
@ -85,20 +112,22 @@ This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed
| 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
drift --check # check that mpv and yt-dlp are installed
drift --help
drift --version
```
Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of keybinds.
Press `/` to search, `Enter` to play, `a` to add a song to the queue and `?` for the full list of keybinds. Inside the player, `:help` lists the commands.
## Keybinds
These are the defaults. You can change them, see [Custom keys](#custom-keys).
| Key | Action |
| --- | --- |
| `j` `k` / `↓` `↑` | move (counts work: `5j`) |
@ -117,34 +146,47 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of
| `G` | song menu |
| `ga` | go to the artist |
| `gs` | see similar songs |
| `gb` | go to the album |
| `gB` | save the album as a playlist |
| `L` | toggle the lyrics panel |
| `F` | fullscreen lyrics |
| `/` `:` | search / command line |
| `?` | help |
| `q` | quit |
### Commands
## Commands
Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on).
| Command | Action |
| --- | --- |
| `:search "query"` | search YouTube Music |
| `:play [N]` | play or resume, or play row `N` |
| `:pause` `:resume` `:toggle` `:stop` | control playback |
| `:next` `:prev` | next / previous track |
| `: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 |
| `:remove [N]` `:clear` | remove from the queue / clear it |
| `:save name` `:load name` `:append name` | work with playlists |
| `:save! name` | save and overwrite an existing playlist |
| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist |
| `:addto name` `:delpl name` | add a song to / delete a playlist |
| `:import URL` | import a YouTube / YouTube Music playlist |
| `:album [query]` | open the selected song's album, or search albums |
| `:playlist query` | search YouTube Music playlists |
| `:similar` | radio: songs similar to the selected one (also `:radio`) |
| `:similar` | songs similar to the selected one (also `:radio`) |
| `:artist` | songs by the selected song's artist |
| `:results` `:queue` `:playlists` | switch view |
| `:lyrics` | toggle the lyrics panel |
| `:quit` | quit |
`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it.
## 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`.
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 that 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.
@ -169,18 +211,111 @@ popular_first = false
accent = "cyan" # a color name or a hex value like "#FF8800"
[discord]
client_id = "" # change this for Discord RPC to work in Drift
client_id = "" # set this to enable Discord rich presence
[audio]
download = false # false: stream songs; true: download to the cache first
download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit)
cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback
[keys]
quit = "Q"
move_down = ["j", "down", "ctrl+n"]
move_up = ["k", "up", "ctrl+p"]
go_top = ["gg", "home"]
shuffle = [] # an empty list unbinds the action
```
Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session.
If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file.
### Streaming or downloading
By default Drift streams songs, so playback starts right away, even for hour-long tracks. With `download = true` songs are cached on disk first, and the next song in the queue is fetched in the background while the current one plays.
### Discord rich presence
This is off by default.
1. Create an application at https://discord.com/developers/applications. Its name is what shows up after "Listening to".
2. Copy the Application ID into `client_id` under `[discord]` in `config.toml`.
3. Restart Drift.
Your status then shows the current song with cover art, album, a progress bar and a "Listen Along" button. If Discord isn't running, Drift keeps retrying quietly in the background.
### Custom keys
To change keys, add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys. Everything you leave out keeps its default.
```toml
[keys]
quit = "Q"
move_down = ["j", "down", "ctrl+n"]
move_up = ["k", "up", "ctrl+p"]
go_top = ["gg", "home"]
shuffle = [] # an empty list unbinds the action
```
A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys.
| Action | Default keys |
| --- | --- |
| `move_down` `move_up` | `j` `down` / `k` `up` |
| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` |
| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` |
| `go_top` `go_bottom` | `gg` `home` / `ge` `end` |
| `next_view` `prev_view` | `tab` / `backtab` |
| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` |
| `activate` | `enter` |
| `pause` | `space` |
| `next` `prev` | `n` `>` / `N` `<` |
| `seek_back` `seek_forward` | `h` `left` / `l` `right` |
| `volume_up` `volume_down` | `+` `=` / `-` `_` |
| `add` `add_all` `play_all` | `a` / `A` / `P` |
| `delete` | `x` `delete` `dd` |
| `move_item_down` `move_item_up` | `J` / `K` |
| `shuffle` `repeat` | `s` / `r` |
| `menu` | `G` `menu` |
| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` |
| `lyrics_panel` `lyrics` | `L` / `F` |
| `search` `command` | `/` / `:` |
| `help` | `?` |
| `quit` | `q` |
| `back` | `backspace` `esc` |
In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable. Popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults.
### Environment variables
| Variable | What it does |
| --- | --- |
| `DRIFT_CONFIG_DIR` | where Drift keeps `config.toml` and your playlists |
| `DRIFT_STATE_DIR` | where Drift keeps the saved session |
| `DRIFT_MPV_ARGS` | extra arguments passed to mpv |
## Troubleshooting
**Drift says mpv or yt-dlp is missing.** Run `drift --check` to see which one, and install it with the table in [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually).
**Playback fails with "Sign in to confirm you're not a bot".** YouTube wants a login. Set `cookies_from_browser` under `[audio]` to a browser you're logged into YouTube with (for example `"firefox"`), then restart Drift. Updating yt-dlp also helps, since YouTube changes things often.
**The lyrics panel doesn't show up.** It needs a terminal at least 70 columns wide. Make the window wider or use `F` for fullscreen lyrics.
**`drift: command not found`.** Make sure `~/.cargo/bin` is in your `$PATH`.
**`gb` opens the wrong album.** Drift looks albums up by album and artist name, so it can guess wrong, and the song needs album info to begin with. `:album <query>` lets you search for it yourself.
You can run several Drift windows at once. Only the first one restores and saves the session, and playlists saved from different windows are merged instead of overwritten.
## Contributing
Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) before you start.
Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests:
## Star History
```bash
cargo build
cargo test
```
[![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift)
Recent changes are listed in [CHANGELOG.md](CHANGELOG.md).
## License

View file

@ -7,10 +7,11 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Config};
use crate::config::{self, Audio, Config};
use crate::lyrics::{self, Lyrics};
use crate::model::{Collection, RepeatMode, Track};
use crate::model::{split_artists, Collection, RepeatMode, Track};
use crate::discord::Discord;
use crate::keys::{Action, Key, Keymap, Lookup};
use crate::mpris::{Cmd, Mpris};
use crate::player::{self, Player, PlayerEvent};
use crate::session::Session;
@ -37,11 +38,19 @@ pub enum AppEvent {
title: String,
result: Result<Vec<Collection>, String>,
},
SavePlaylist {
name: String,
result: Result<Vec<Track>, String>,
},
Media(Cmd),
Audio {
id: String,
result: Result<std::path::PathBuf, String>,
},
Stream {
id: String,
result: Result<String, String>,
},
}
const FIRST_PAGE: usize = 20;
@ -114,6 +123,9 @@ pub enum MenuAction {
Artist,
Similar,
Album,
SaveTrackAlbum,
SaveAlbum,
SaveResults,
PlaylistMenu,
PickPlaylist(String),
NewPlaylist,
@ -193,7 +205,7 @@ pub struct App {
history: Vec<String>,
hist_pos: Option<usize>,
pub count: Option<usize>,
pub pending: Option<char>,
pub pending: Vec<Key>,
pub play_state: PlayState,
pub position: f64,
@ -208,6 +220,8 @@ pub struct App {
fail_streak: u8,
want: Option<String>,
fetching: Option<String>,
resolving: Option<String>,
audio: Audio,
result_cursor: Option<usize>,
buffered: Vec<Track>,
next_page: Option<String>,
@ -219,6 +233,7 @@ pub struct App {
mpris: Option<Mpris>,
discord: Option<Discord>,
owns_session: bool,
keymap: Keymap,
seek_step: f64,
volume_step: f64,
autoplay: bool,
@ -294,7 +309,8 @@ impl App {
history: Vec::new(),
hist_pos: None,
count: None,
pending: None,
pending: Vec::new(),
keymap: cfg.keymap.clone(),
play_state: PlayState::Stopped,
position: 0.0,
duration: 0.0,
@ -308,6 +324,8 @@ impl App {
fail_streak: 0,
want: None,
fetching: None,
resolving: None,
audio: cfg.audio.clone(),
result_cursor: None,
buffered: Vec::new(),
next_page: None,
@ -526,121 +544,103 @@ impl App {
}
fn key_normal(&mut self, key: KeyEvent) {
let ctrl = is_ctrl(&key);
let key = Key::from(key);
if let Some(p) = self.pending.take() {
let n = self.count.take();
if let KeyCode::Char(c) = key.code {
match (p, c) {
('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))),
('g', 'e') => {
self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1)))
}
('g', 'r') => self.set_view(View::Results),
('g', 'q') => self.set_view(View::Queue),
('g', 'p') => self.set_view(View::Playlists),
('d', 'd') => self.delete_selected(),
('g', 'a') => self.go_to_artist(),
('g', 's') => self.go_similar(),
('g', 'b') => self.go_album(),
_ => {}
}
}
return;
}
if let KeyCode::Char(c @ '0'..='9') = key.code {
if !ctrl && !(c == '0' && self.count.is_none()) {
let d = c as usize - '0' as usize;
if self.pending.is_empty() {
if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) {
self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999));
return;
}
}
let n = self.count.take();
self.pending.push(key);
match self.keymap.lookup(&self.pending) {
Lookup::Pending => {}
Lookup::Run(action) => {
self.pending.clear();
let n = self.count.take();
self.run_action(action, n);
}
Lookup::Nothing => {
self.pending.clear();
self.count = None;
}
}
}
fn run_action(&mut self, action: Action, n: Option<usize>) {
let times = n.unwrap_or(1);
if self.view == View::Playlists {
match key.code {
KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => {
match action {
Action::SeekForward if !self.in_playlist() => {
return self.open_selected_playlist();
}
KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc
if self.in_playlist() && !ctrl =>
{
Action::SeekBack | Action::Back if self.in_playlist() => {
return self.close_playlist();
}
_ => {}
}
}
match key.code {
KeyCode::Char('d') if ctrl => self.move_sel(10),
KeyCode::Char('u') if ctrl => self.move_sel(-10),
KeyCode::Char('f') if ctrl => self.move_sel(20),
KeyCode::Char('b') if ctrl => self.move_sel(-20),
KeyCode::PageDown => self.move_sel(20),
KeyCode::PageUp => self.move_sel(-20),
KeyCode::Home => self.set_selected(0),
KeyCode::End => self.set_selected(usize::MAX),
match action {
Action::HalfPageDown => self.move_sel(10),
Action::HalfPageUp => self.move_sel(-10),
Action::PageDown => self.move_sel(20),
Action::PageUp => self.move_sel(-20),
Action::MoveDown => self.move_sel(times as isize),
Action::MoveUp => self.move_sel(-(times as isize)),
Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))),
Action::GoBottom => {
self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1)))
}
Action::Menu => self.open_menu(),
Action::Delete => self.delete_selected(),
KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize),
KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)),
KeyCode::Char('g') => {
self.pending = Some('g');
self.count = n;
}
KeyCode::Char('G') | KeyCode::Menu => self.open_menu(),
KeyCode::Char('d') => {
if matches!(self.view, View::Queue | View::Playlists) {
self.pending = Some('d');
}
}
KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(),
Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)),
Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)),
Action::ViewResults => self.set_view(View::Results),
Action::ViewQueue => self.set_view(View::Queue),
Action::ViewPlaylists => self.set_view(View::Playlists),
KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)),
KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)),
KeyCode::Enter => self.activate(),
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 * times as f64))
}
KeyCode::Char('l') | KeyCode::Right => {
self.seek(Amount::Delta(self.seek_step * times as f64))
}
KeyCode::Char('+') | KeyCode::Char('=') => {
self.set_volume(Amount::Delta(self.volume_step * times as f64))
}
KeyCode::Char('-') | KeyCode::Char('_') => {
Action::Activate => self.activate(),
Action::Pause => self.toggle_pause(),
Action::Next => self.next(),
Action::Prev => self.prev(),
Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)),
Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)),
Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)),
Action::VolumeDown => {
self.set_volume(Amount::Delta(-self.volume_step * times as f64))
}
KeyCode::Char('a') => self.add_selected(),
KeyCode::Char('A') => self.add_all_results(),
KeyCode::Char('P') => self.play_all_results(),
KeyCode::Char('J') => self.move_item(1),
KeyCode::Char('K') => self.move_item(-1),
KeyCode::Char('s') => self.toggle_shuffle(),
KeyCode::Char('r') => self.cycle_repeat(),
Action::Add => self.add_selected(),
Action::AddAll => self.add_all_results(),
Action::PlayAll => self.play_all_results(),
Action::MoveItemDown => self.move_item(1),
Action::MoveItemUp => self.move_item(-1),
Action::Shuffle => self.toggle_shuffle(),
Action::Repeat => self.cycle_repeat(),
KeyCode::Char('/') => {
Action::Search => {
self.cmd_buf.clear();
self.cmd_search = true;
self.enter_command_mode();
}
KeyCode::Char(':') => {
Action::Command => {
self.cmd_buf.clear();
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,
_ => {}
Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel,
Action::Lyrics => self.open_lyrics(),
Action::GoArtist => self.go_to_artist(),
Action::GoSimilar => self.go_similar(),
Action::GoAlbum => self.go_album(),
Action::SaveAlbum => self.save_album(None),
Action::Help => self.open_help(),
Action::Quit => self.should_quit = true,
Action::Back => self.status = None,
}
}
@ -688,6 +688,7 @@ impl App {
items.push(menu_item('s', "Similar songs", MenuAction::Similar));
if track.album.is_some() {
items.push(menu_item('b', "Show album", MenuAction::Album));
items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum));
}
items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu));
if in_queue {
@ -699,6 +700,11 @@ impl App {
if self.view == View::Results {
items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll));
items.push(menu_item('P', "Play all results", MenuAction::PlayAll));
if self.results_title.starts_with("Album: ") {
items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum));
} else {
items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults));
}
}
if in_queue {
items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue));
@ -806,6 +812,21 @@ impl App {
MenuAction::Artist => self.go_to_artist(),
MenuAction::Similar => self.go_similar(),
MenuAction::Album => self.go_album(),
MenuAction::SaveTrackAlbum => {
if let Some(t) = track {
let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string());
self.save_album_background(t, album_title);
}
}
MenuAction::SaveAlbum => {
let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim();
if !default_name.is_empty() {
self.prefill_command(&format!("savealbum \"{default_name}\""));
} else {
self.prefill_command("savealbum ");
}
}
MenuAction::SaveResults => self.prefill_command("saveresults "),
MenuAction::PlaylistMenu => {}
MenuAction::PickPlaylist(name) => self.add_to_playlist(&name),
MenuAction::NewPlaylist => self.prefill_command("addto "),
@ -1134,6 +1155,8 @@ impl App {
}
Command::Save(name) => self.save_playlist(&name, false),
Command::SaveForce(name) => self.save_playlist(&name, true),
Command::SaveAlbum(name) => self.save_album(name),
Command::SaveResults(name) => self.save_results_as_playlist(name),
Command::Load(name) => match self.store.resolve(&name) {
Some(n) => self.load_playlist(&n, true),
None => self.error(format!("no playlist named '{name}'")),
@ -1283,6 +1306,56 @@ impl App {
});
}
fn save_album(&mut self, name: Option<String>) {
if self.view == View::Results
&& self.results_title.starts_with("Album: ")
&& !self.results.is_empty()
{
return self.save_results_as_playlist(name);
}
if let Some(track) = self.artist_target() {
if let Some(album) = track.album.clone() {
let target_name = name.unwrap_or(album);
return self.save_album_background(track, target_name);
}
}
if self.view == View::Results && !self.results.is_empty() {
return self.save_results_as_playlist(name);
}
self.error("No album to save — open an album or select a song with an album");
}
fn save_album_background(&mut self, track: Track, target_name: String) {
let Some(album) = track.album else {
return self.error("No album known for this song");
};
let artist = split_artists(&track.artist)
.into_iter()
.next()
.unwrap_or_default();
self.busy = Some(format!("Saving album \u{201c}{album}\u{201d}"));
let tx = self.tx.clone();
std::thread::spawn(move || {
let result = (|| -> Result<Vec<Track>, String> {
let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?;
let want = album.to_lowercase();
let pick = found
.iter()
.find(|c| c.title.to_lowercase() == want)
.or(found.first())
.ok_or_else(|| "album not found".to_string())?;
ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist)
})();
let _ = tx.send(AppEvent::SavePlaylist {
name: target_name,
result,
});
});
}
fn start_collection_search(&mut self, query: String, kind: Kind) {
let (what, title) = match kind {
Kind::Album => ("albums", format!("Albums: {query}")),
@ -1306,6 +1379,7 @@ impl App {
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(),
KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(),
KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1),
KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1),
KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0,
@ -1340,6 +1414,23 @@ impl App {
});
}
fn save_picked_collection(&mut self) {
let c = self.picks.get(self.pick_sel).cloned();
self.close_collection_pick();
let Some(c) = c else { return };
let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" };
let target_name = c.title.clone();
self.busy = Some(format!("Saving {kind} \u{201c}{target_name}\u{201d}"));
let tx = self.tx.clone();
std::thread::spawn(move || {
let result = ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist);
let _ = tx.send(AppEvent::SavePlaylist {
name: target_name,
result,
});
});
}
fn on_media(&mut self, cmd: Cmd) {
match cmd {
Cmd::Play => match self.play_state {
@ -1492,6 +1583,29 @@ impl App {
}
}
AppEvent::Media(cmd) => self.on_media(cmd),
AppEvent::SavePlaylist { name, result } => {
self.busy = None;
match result {
Ok(tracks) if tracks.is_empty() => {
self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks"));
}
Ok(tracks) => {
let n = tracks.len();
let key = self.store.resolve_exact(&name).unwrap_or(name);
self.store.playlists.insert(key.clone(), tracks);
if self.persist() {
self.info(format!(
"Saved {n} tracks to playlist \u{201c}{key}\u{201d}"
));
let sel = self.selected();
self.set_selected(sel);
}
}
Err(e) => {
self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}"))
}
}
}
AppEvent::Lyrics { id, result } => {
if self.lyrics_id.as_deref() != Some(id.as_str()) {
return;
@ -1503,6 +1617,7 @@ impl App {
};
}
AppEvent::Audio { id, result } => self.on_audio(id, result),
AppEvent::Stream { id, result } => self.on_stream(id, result),
AppEvent::Player(generation, pe) => {
if generation == self.player_gen {
self.on_player(pe);
@ -1632,17 +1747,53 @@ impl App {
self.fetching = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
let audio = self.audio.clone();
std::thread::spawn(move || {
let result = player::fetch_audio(&id);
let result = player::fetch_audio(&id, &audio);
let _ = tx.send(AppEvent::Audio { id, result });
});
}
fn load_track(&mut self, id: &str) {
fn load_track(&mut self, id: &str, duration: Option<u32>) {
self.want = Some(id.to_string());
match player::cached_audio(id) {
Some(path) => self.on_audio(id.to_string(), Ok(path)),
None => self.fetch_audio(id),
None if self.audio.should_download(duration) => self.fetch_audio(id),
None => self.resolve_stream(id),
}
}
/* Start playing from the network right away; no full download first. */
fn resolve_stream(&mut self, id: &str) {
if self.resolving.as_deref() == Some(id) {
return;
}
self.resolving = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
let audio = self.audio.clone();
std::thread::spawn(move || {
let result = player::resolve_stream(&id, &audio);
let _ = tx.send(AppEvent::Stream { id, result });
});
}
fn on_stream(&mut self, id: String, result: Result<String, String>) {
if self.resolving.as_deref() == Some(id.as_str()) {
self.resolving = None;
}
if self.want.as_deref() != Some(id.as_str()) {
return;
}
self.want = None;
match result {
Ok(url) => {
if let Err(e) = self.with_player(|p| p.load(&url)) {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
Err(msg) => self.on_player(PlayerEvent::LoadError(msg)),
}
}
@ -1673,6 +1824,10 @@ impl App {
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
return;
};
/* streamed tracks (the default, or too long to download) aren't prefetched */
if !self.audio.should_download(next.duration) {
return;
}
let id = next.id.clone();
if player::cached_audio(&id).is_none() {
self.fetch_audio(&id);
@ -1700,7 +1855,7 @@ impl App {
};
self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
self.load_track(&track.id, track.duration);
}
fn play_default(&mut self) {
@ -1792,7 +1947,7 @@ impl App {
self.position = 0.0;
self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
self.load_track(&track.id, track.duration);
}
fn set_pause(&mut self, pause: bool) {
@ -2179,6 +2334,40 @@ impl App {
}
}
fn save_results_as_playlist(&mut self, name: Option<String>) {
if self.results.is_empty() {
return self.error("No results to save — search or open an album first");
}
let target_name = match name {
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
_ => {
if let Some(rest) = self.results_title.strip_prefix("Album: ") {
rest.trim().to_string()
} else if let Some(rest) = self.results_title.strip_prefix("Playlist: ") {
rest.trim().to_string()
} else if let Some(album) = self.results.iter().find_map(|t| t.album.as_deref()) {
album.to_string()
} else {
return self.error(
"No album name found; usage: :savealbum <name> or :saveresults <name>",
);
}
}
};
let n = self.results.len();
let key = self.store.resolve_exact(&target_name).unwrap_or(target_name);
self.store
.playlists
.insert(key.clone(), self.results.clone());
if self.persist() {
self.info(format!(
"Saved {n} tracks to playlist \u{201c}{key}\u{201d}"
));
let sel = self.selected();
self.set_selected(sel);
}
}
fn add_to_playlist(&mut self, name: &str) {
let i = self.selected();
let track = match self.view {
@ -2331,6 +2520,7 @@ impl App {
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::Keys;
use std::sync::mpsc;
fn track(id: &str, title: &str) -> Track {
@ -2447,6 +2637,40 @@ mod tests {
app.shuffle = false;
}
fn check_custom_keys(app: &mut App) {
let custom = [
("move_down", Keys::Many(vec!["e".into()])),
("go_top", Keys::One("zz".into())),
("quit", Keys::One("Q".into())),
];
let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect();
app.keymap = Keymap::build(&custom).unwrap();
app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect();
app.view = View::Results;
app.states[View::Results.idx()].select(Some(0));
press(app, KeyCode::Char('j'));
assert_eq!(app.selected(), 0);
press(app, KeyCode::Char('e'));
assert_eq!(app.selected(), 1);
press(app, KeyCode::Char('2'));
press(app, KeyCode::Char('e'));
assert_eq!(app.selected(), 3);
press(app, KeyCode::Char('z'));
assert!(app.count.is_none() && !app.pending.is_empty());
press(app, KeyCode::Char('z'));
assert_eq!(app.selected(), 0);
assert!(app.pending.is_empty());
press(app, KeyCode::Char('q'));
assert!(!app.should_quit);
press(app, KeyCode::Char('Q'));
assert!(app.should_quit);
app.should_quit = false;
app.keymap = Keymap::default();
}
#[test]
fn playlist_drill_in() {
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
@ -2537,12 +2761,53 @@ mod tests {
check_artist_popup(&mut app);
check_paging(&mut app);
check_custom_keys(&mut app);
app.run_command("remove");
assert_eq!(app.queue.len(), 1);
check_playlist_playback(&mut app);
// Test savealbum and saveresults
let mut t1 = track("song1111111", "Dreams");
t1.album = Some("Rumours".into());
let mut t2 = track("song2222222", "Go Your Own Way");
t2.album = Some("Rumours".into());
app.results = vec![t1.clone(), t2.clone()];
app.results_title = "Album: Rumours".into();
app.view = View::Results;
// :savealbum with no args uses album name from results_title
app.run_command("savealbum");
assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2));
// :savealbum with custom name
app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#);
assert_eq!(
app.store
.playlists
.get("Fleetwood Mac - Rumours")
.map(Vec::len),
Some(2)
);
// :saveresults with custom name
app.run_command(r#"saveresults "Classic Hits""#);
assert_eq!(
app.store.playlists.get("Classic Hits").map(Vec::len),
Some(2)
);
// Test AppEvent::SavePlaylist background event
app.on_event(AppEvent::SavePlaylist {
name: "Background Album".into(),
result: Ok(vec![track("bg111111111", "BG Track")]),
});
assert_eq!(
app.store.playlists.get("Background Album").map(Vec::len),
Some(1)
);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -31,6 +31,8 @@ pub enum Command {
Repeat(Option<RepeatMode>),
Save(String),
SaveForce(String),
SaveAlbum(Option<String>),
SaveResults(Option<String>),
Load(String),
Append(String),
DeletePlaylist(String),
@ -67,6 +69,8 @@ pub const COMMAND_NAMES: &[&str] = &[
"shuffle",
"repeat",
"save",
"savealbum",
"saveresults",
"load",
"append",
"delpl",
@ -174,6 +178,8 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
},
"save" | "w" => Save(name_arg(arg, "save")?),
"save!" | "w!" => SaveForce(name_arg(arg, "save!")?),
"savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())),
"saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())),
"load" | "e" => Load(name_arg(arg, "load")?),
"append" => Append(name_arg(arg, "append")?),
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
@ -284,5 +290,18 @@ mod tests {
parse("save! my mix"),
Ok(Some(Command::SaveForce("my mix".into())))
);
assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None))));
assert_eq!(
parse(r#"savealbum "Abbey Road""#),
Ok(Some(Command::SaveAlbum(Some("Abbey Road".into()))))
);
assert_eq!(
parse("saveresults"),
Ok(Some(Command::SaveResults(None)))
);
assert_eq!(
parse(r#"saveresults "My Hits""#),
Ok(Some(Command::SaveResults(Some("My Hits".into()))))
);
}
}

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::str::FromStr;
@ -7,6 +8,7 @@ use ratatui::style::Color;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer};
use crate::keys::{Keymap, Keys};
use crate::model::{RepeatMode, Track};
use crate::session::Session;
use crate::store::{write_atomic, Store};
@ -80,6 +82,45 @@ pub struct Discord {
pub client_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Audio {
pub download: bool,
pub download_max_minutes: u32,
pub cookies_from_browser: String,
}
impl Default for Audio {
fn default() -> Self {
Audio {
download: false,
download_max_minutes: 15,
cookies_from_browser: String::new(),
}
}
}
impl Audio {
/// Browser name for yt-dlp's --cookies-from-browser, if one is set.
pub fn cookies(&self) -> Option<&str> {
let c = self.cookies_from_browser.trim();
(!c.is_empty()).then_some(c)
}
/// Whether a track should be downloaded to the cache before playing,
/// as opposed to streamed. Unknown lengths are streamed when a limit is set.
pub fn should_download(&self, duration: Option<u32>) -> bool {
if !self.download {
return false;
}
match (self.download_max_minutes, duration) {
(0, _) => true,
(max, Some(d)) => d <= max.saturating_mul(60),
(_, None) => false,
}
}
}
fn default_accent() -> Color {
Color::Cyan
}
@ -104,6 +145,10 @@ pub struct Config {
pub artist: Artist,
pub ui: Ui,
pub discord: Discord,
pub audio: Audio,
pub keys: HashMap<String, Keys>,
#[serde(skip)]
pub keymap: Keymap,
}
impl Config {
@ -111,7 +156,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml"))
}
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String {
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String {
let accent = match u.accent {
Color::Reset => "reset",
Color::Black => "black",
@ -163,6 +208,30 @@ accent = \"{accent}\"
# https://discord.com/developers/applications (its name is shown after
# \"Listening to\") and paste its Application ID here. Empty means off.
client_id = \"{client_id}\"
[audio]
# false: stream songs straight from YouTube (starts playing right away).
# true: download songs to the cache first, then play (and prefetch the next one).
download = {download}
# With download = true, songs longer than this many minutes are still streamed
# instead of downloaded. 0 means download everything, however long.
download_max_minutes = {dl_max}
# Use your browser's cookies with yt-dlp when YouTube refuses to play something
# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...).
# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\",
# \"firefox:my-profile\". Empty means off. Close the browser if reading fails.
cookies_from_browser = \"{cookies}\"
[keys]
# Remap keys here. Each line sets an action to one key or a list of keys and
# replaces that action's default keys. Everything you leave out keeps its vim
# default. See the README for the list of actions.
# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab,
# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12),
# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds.
# quit = \"Q\"
# move_down = [\"j\", \"down\"]
# go_top = [\"gg\", \"home\"]
",
volume = p.volume,
shuffle = p.shuffle,
@ -174,6 +243,9 @@ client_id = \"{client_id}\"
popular = a.popular_first,
accent = accent,
client_id = d.client_id,
download = au.download,
dl_max = au.download_max_minutes,
cookies = au.cookies_from_browser.replace(['\\', '"'], ""),
)
}
@ -188,7 +260,7 @@ client_id = \"{client_id}\"
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default()));
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
return (Config::default(), None);
}
Err(e) => {
@ -201,7 +273,14 @@ client_id = \"{client_id}\"
match toml::from_str::<Config>(&text) {
Ok(mut c) => {
c.playback.sanitize();
(c, None)
let warning = match Keymap::build(&c.keys) {
Ok(keymap) => {
c.keymap = keymap;
None
}
Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")),
};
(c, warning)
}
Err(e) => {
let bak = path.with_extension("toml.bak");
@ -267,7 +346,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s);
}
}
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default()));
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
}
#[cfg(test)]
@ -276,7 +355,7 @@ mod tests {
#[test]
fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default());
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default());
let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default();
assert_eq!(c.playback.volume, d.volume);
@ -343,8 +422,52 @@ mod tests {
// Roundtrip: render then parse
let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default());
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default());
let c: Config = toml::from_str(&text).unwrap();
assert_eq!(c.ui.accent, Color::Blue);
}
#[test]
fn keys_section() {
let c: Config = toml::from_str("").unwrap();
assert!(c.keys.is_empty());
let c: Config =
toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap();
assert!(Keymap::build(&c.keys).is_ok());
assert_eq!(c.keys.len(), 2);
}
#[test]
fn audio_section() {
let c: Config = toml::from_str("").unwrap();
assert!(!c.audio.download);
assert_eq!(c.audio.cookies(), None);
let c: Config = toml::from_str(
"[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"",
)
.unwrap();
assert_eq!(c.audio.cookies(), Some("firefox"));
assert!(c.audio.should_download(Some(600)));
assert!(!c.audio.should_download(Some(601)));
assert!(!c.audio.should_download(None));
let mut a = c.audio.clone();
a.download_max_minutes = 0;
assert!(a.should_download(Some(10_000)));
a.download = false;
assert!(!a.should_download(Some(1)));
}
#[test]
fn audio_roundtrip() {
let a = Audio {
download: true,
download_max_minutes: 30,
cookies_from_browser: "chrome".into(),
};
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a);
let c: Config = toml::from_str(&text).unwrap();
assert!(c.audio.download);
assert_eq!(c.audio.download_max_minutes, 30);
assert_eq!(c.audio.cookies(), Some("chrome"));
}
}

View file

@ -2,7 +2,9 @@ use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps};
use discord_rich_presence::activity::{
Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps,
};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use crate::app::PlayState;
@ -158,6 +160,7 @@ fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool {
};
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.status_display_type(StatusDisplayType::Details)
.details(&p.title)
.state(&artist)
.assets(assets)

353
src/keys.rs Normal file
View file

@ -0,0 +1,353 @@
use std::collections::HashMap;
use std::fmt;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
MoveDown,
MoveUp,
HalfPageDown,
HalfPageUp,
PageDown,
PageUp,
GoTop,
GoBottom,
Menu,
Delete,
NextView,
PrevView,
ViewResults,
ViewQueue,
ViewPlaylists,
Activate,
Pause,
Next,
Prev,
SeekBack,
SeekForward,
VolumeUp,
VolumeDown,
Add,
AddAll,
PlayAll,
MoveItemDown,
MoveItemUp,
Shuffle,
Repeat,
Search,
Command,
LyricsPanel,
Lyrics,
GoArtist,
GoSimilar,
GoAlbum,
SaveAlbum,
Help,
Quit,
Back,
}
const DEFAULTS: &[(&str, Action, &[&str])] = &[
("move_down", Action::MoveDown, &["j", "down"]),
("move_up", Action::MoveUp, &["k", "up"]),
("half_page_down", Action::HalfPageDown, &["ctrl+d"]),
("half_page_up", Action::HalfPageUp, &["ctrl+u"]),
("page_down", Action::PageDown, &["ctrl+f", "pagedown"]),
("page_up", Action::PageUp, &["ctrl+b", "pageup"]),
("go_top", Action::GoTop, &["gg", "home"]),
("go_bottom", Action::GoBottom, &["ge", "end"]),
("menu", Action::Menu, &["G", "menu"]),
("delete", Action::Delete, &["x", "delete", "dd"]),
("next_view", Action::NextView, &["tab"]),
("prev_view", Action::PrevView, &["backtab"]),
("view_results", Action::ViewResults, &["gr"]),
("view_queue", Action::ViewQueue, &["gq"]),
("view_playlists", Action::ViewPlaylists, &["gp"]),
("activate", Action::Activate, &["enter"]),
("pause", Action::Pause, &["space"]),
("next", Action::Next, &["n", ">"]),
("prev", Action::Prev, &["N", "<"]),
("seek_back", Action::SeekBack, &["h", "left"]),
("seek_forward", Action::SeekForward, &["l", "right"]),
("volume_up", Action::VolumeUp, &["+", "="]),
("volume_down", Action::VolumeDown, &["-", "_"]),
("add", Action::Add, &["a"]),
("add_all", Action::AddAll, &["A"]),
("play_all", Action::PlayAll, &["P"]),
("move_item_down", Action::MoveItemDown, &["J"]),
("move_item_up", Action::MoveItemUp, &["K"]),
("shuffle", Action::Shuffle, &["s"]),
("repeat", Action::Repeat, &["r"]),
("search", Action::Search, &["/"]),
("command", Action::Command, &[":"]),
("lyrics_panel", Action::LyricsPanel, &["L"]),
("lyrics", Action::Lyrics, &["F"]),
("go_artist", Action::GoArtist, &["ga"]),
("go_similar", Action::GoSimilar, &["gs"]),
("go_album", Action::GoAlbum, &["gb"]),
("save_album", Action::SaveAlbum, &["gB"]),
("help", Action::Help, &["?"]),
("quit", Action::Quit, &["q"]),
("back", Action::Back, &["backspace", "esc"]),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Key {
code: KeyCode,
ctrl: bool,
}
impl Key {
pub fn digit(&self) -> Option<usize> {
match self.code {
KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize),
_ => None,
}
}
}
impl From<KeyEvent> for Key {
fn from(e: KeyEvent) -> Key {
Key {
code: e.code,
ctrl: e.modifiers.contains(KeyModifiers::CONTROL)
&& !e.modifiers.contains(KeyModifiers::ALT),
}
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.ctrl {
f.write_str("^")?;
}
match self.code {
KeyCode::Char(c) => write!(f, "{c}"),
other => write!(f, "{other}"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Keys {
One(String),
Many(Vec<String>),
}
impl Keys {
fn as_slice(&self) -> &[String] {
match self {
Keys::One(s) => std::slice::from_ref(s),
Keys::Many(v) => v,
}
}
}
pub enum Lookup {
Run(Action),
Pending,
Nothing,
}
#[derive(Debug, Clone)]
pub struct Keymap(Vec<(Vec<Key>, Action)>);
impl Default for Keymap {
fn default() -> Keymap {
Keymap::build(&HashMap::new()).expect("default keys are valid")
}
}
impl Keymap {
pub fn build(custom: &HashMap<String, Keys>) -> Result<Keymap, String> {
if let Some(name) = custom
.keys()
.find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str()))
{
return Err(format!("unknown action \"{name}\""));
}
let mut map: Vec<(Vec<Key>, Action)> = Vec::new();
for (name, action, _) in DEFAULTS {
let Some(keys) = custom.get(*name) else {
continue;
};
for text in keys.as_slice() {
let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?;
if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) {
if other != action {
return Err(format!("\"{text}\" is bound to two actions"));
}
continue;
}
map.push((seq, *action));
}
}
for (name, action, keys) in DEFAULTS {
if custom.contains_key(*name) {
continue;
}
for text in *keys {
let seq = parse_sequence(text).expect("default keys are valid");
if !map.iter().any(|(s, _)| *s == seq) {
map.push((seq, *action));
}
}
}
Ok(Keymap(map))
}
pub fn lookup(&self, seq: &[Key]) -> Lookup {
if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) {
Lookup::Run(*action)
} else if self.0.iter().any(|(s, _)| s.starts_with(seq)) {
Lookup::Pending
} else {
Lookup::Nothing
}
}
}
fn parse_sequence(text: &str) -> Result<Vec<Key>, String> {
let mut keys = Vec::new();
for token in text.split_whitespace() {
match parse_key(token) {
Some(key) => keys.push(key),
None if !token.contains('+') => keys.extend(token.chars().map(|c| Key {
code: KeyCode::Char(c),
ctrl: false,
})),
None => return Err(format!("unknown key \"{token}\"")),
}
}
if keys.is_empty() {
return Err("empty key".into());
}
Ok(keys)
}
fn parse_key(token: &str) -> Option<Key> {
let (ctrl, name) = match token.get(..5) {
Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]),
_ => (false, token),
};
let mut chars = name.chars();
let code = match (chars.next()?, chars.next()) {
(c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()),
(c, None) => KeyCode::Char(c),
_ => match name.to_ascii_lowercase().as_str() {
"space" => KeyCode::Char(' '),
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"backspace" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"insert" => KeyCode::Insert,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"menu" => KeyCode::Menu,
f => match f.strip_prefix('f')?.parse::<u8>() {
Ok(n @ 1..=12) => KeyCode::F(n),
_ => return None,
},
},
};
Some(Key { code, ctrl })
}
#[cfg(test)]
mod tests {
use super::*;
fn custom(entries: &[(&str, &[&str])]) -> HashMap<String, Keys> {
entries
.iter()
.map(|(name, keys)| {
let keys = keys.iter().map(|k| k.to_string()).collect();
(name.to_string(), Keys::Many(keys))
})
.collect()
}
fn seq(text: &str) -> Vec<Key> {
parse_sequence(text).unwrap()
}
fn run(map: &Keymap, text: &str) -> Option<Action> {
match map.lookup(&seq(text)) {
Lookup::Run(a) => Some(a),
_ => None,
}
}
#[test]
fn defaults_are_vim() {
let map = Keymap::default();
assert_eq!(run(&map, "j"), Some(Action::MoveDown));
assert_eq!(run(&map, "down"), Some(Action::MoveDown));
assert_eq!(run(&map, "gg"), Some(Action::GoTop));
assert_eq!(run(&map, "dd"), Some(Action::Delete));
assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown));
assert_eq!(run(&map, "G"), Some(Action::Menu));
assert_eq!(run(&map, "space"), Some(Action::Pause));
assert!(matches!(map.lookup(&seq("g")), Lookup::Pending));
assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing));
}
#[test]
fn parsing() {
assert_eq!(seq("ctrl+D"), seq("ctrl+d"));
assert_eq!(seq("gg"), seq("g g"));
assert_eq!(seq("+").len(), 1);
assert_eq!(seq("ctrl+x g").len(), 2);
assert_eq!(seq("f5")[0].code, KeyCode::F(5));
assert!(parse_sequence("").is_err());
assert!(parse_sequence("ctrl+nope").is_err());
assert!(parse_key("f13").is_none());
}
#[test]
fn custom_replaces_default_keys() {
let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap();
assert_eq!(run(&map, "Q"), Some(Action::Quit));
assert_eq!(run(&map, "q"), None);
assert_eq!(run(&map, "e"), Some(Action::MoveDown));
assert_eq!(run(&map, "j"), None);
assert_eq!(run(&map, "k"), Some(Action::MoveUp));
}
#[test]
fn custom_wins_over_other_defaults() {
let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap();
assert_eq!(run(&map, "x"), Some(Action::Quit));
assert_eq!(run(&map, "dd"), Some(Action::Delete));
}
#[test]
fn single_string_and_disable() {
let toml = "quit = \"Q\"\nshuffle = []";
let keys: HashMap<String, Keys> = toml::from_str(toml).unwrap();
let map = Keymap::build(&keys).unwrap();
assert_eq!(run(&map, "Q"), Some(Action::Quit));
assert_eq!(run(&map, "s"), None);
}
#[test]
fn bad_config_is_rejected() {
assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err());
assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err());
assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err());
assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok());
}
}

View file

@ -2,6 +2,7 @@ mod app;
mod command;
mod config;
mod discord;
mod keys;
mod lyrics;
mod model;
mod mpris;

View file

@ -6,6 +6,8 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
use crate::config::Audio;
/// Platform-specific transport for mpv's JSON IPC.
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
#[cfg(unix)]
@ -311,6 +313,15 @@ impl Drop for Player {
const KEEP_FILES: usize = 40;
/// Base yt-dlp command with the user's cookie setting applied.
fn ytdlp(audio: &Audio) -> Command {
let mut cmd = Command::new("yt-dlp");
if let Some(browser) = audio.cookies() {
cmd.arg("--cookies-from-browser").arg(browser);
}
cmd
}
fn audio_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
@ -326,13 +337,13 @@ pub fn cached_audio(id: &str) -> Option<PathBuf> {
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
}
pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
pub fn fetch_audio(id: &str, audio: &Audio) -> Result<PathBuf, String> {
if let Some(path) = cached_audio(id) {
return Ok(path);
}
let dir = audio_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut cmd = Command::new("yt-dlp");
let mut cmd = ytdlp(audio);
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
.arg("-P")
.arg(&dir)
@ -357,6 +368,35 @@ pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
.unwrap_or_else(|| "yt-dlp downloaded nothing".into()))
}
/// Ask yt-dlp for the direct media URL so mpv can start playing right away
/// instead of waiting for the whole file to download.
pub fn resolve_stream(id: &str, audio: &Audio) -> Result<String, String> {
let mut cmd = ytdlp(audio);
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"])
.arg(format!("https://music.youtube.com/watch?v={id}"))
.stdin(Stdio::null());
crate::sys::no_window(&mut cmd);
let out = cmd
.output()
.map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if let Some(url) = stdout
.lines()
.map(str::trim)
.find(|l| l.starts_with("http"))
.filter(|_| out.status.success())
{
return Ok(url.to_string());
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(stderr
.lines()
.rev()
.find_map(|l| l.strip_prefix("ERROR: "))
.map(ytdl_reason)
.unwrap_or_else(|| "yt-dlp returned no stream URL".into()))
}
fn prune(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;

View file

@ -432,8 +432,8 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
if let Some(c) = app.count {
pending.push_str(&c.to_string());
}
if let Some(p) = app.pending {
pending.push(p);
for key in &app.pending {
pending.push_str(&key.to_string());
}
if !pending.is_empty() {
block = block.title_bottom(
@ -806,6 +806,8 @@ const CMD_INFO: &[(&str, &str)] = &[
("shuffle", "toggle shuffle"),
("repeat", "repeat · off | all | one"),
("save", "save the queue as a playlist"),
("savealbum", "save album as a playlist · savealbum [name]"),
("saveresults", "save results as a playlist · saveresults [name]"),
("load", "load a playlist into the queue"),
("append", "append a playlist to the queue"),
("delpl", "delete a playlist"),
@ -1132,6 +1134,14 @@ const HELP: &[(&str, &str)] = &[
":save n :load n",
"save the queue as a playlist / load one (:save! n replaces an existing one)",
),
(
":savealbum [n]",
"save album or current album results as a playlist",
),
(
":saveresults [n]",
"save current results as a playlist",
),
(
":append n :delpl n",
"append a playlist to the queue / delete it",
@ -1281,7 +1291,10 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
}
})
.collect();
lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim()));
lines.push(Line::styled(
" Enter select · s save playlist · j k move · Esc cancel",
dim(),
));
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(