Compare commits

..
11 changed files with 144 additions and 1118 deletions

View file

@ -1,22 +0,0 @@
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" version = "0.1.0"
edition = "2021" edition = "2021"
description = "A Vim-style terminal music player for YouTube Music (no login required)" description = "A Vim-style terminal music player for YouTube Music (no login required)"
license = "GPL-3.0-or-later" license = "MIT"
[workspace] [workspace]

207
README.md
View file

@ -1,61 +1,41 @@
<div align="center"> <div align="center">
# Drift <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)
### A YouTube Music player for your terminal, with Vim keybinds. ### A YouTube Music player for your terminal, with Vim keybinds.
Search, queue and play music with no account, no login and no browser. 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>
--- ---
## Table of contents <div align="center">
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
- [Features](#features) </div>
- [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 ## Features
- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line - **No login** - search and stream straight from YouTube Music
- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung - **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line
- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL - **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung
- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists - **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL
- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info - **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info
- A command palette with completion and history - **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history
- Your queue and position are restored the next time you start Drift - **Artist browsing** - press `ga` to see an artist's songs
- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence - **Similar songs** - press `gs` to see similar songs
- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds - **Audio visualizer** - a live visualizer while music plays
- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file
## Installation ## Installation
Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) to get the audio streams. Run `drift --check` at any time to see if both are installed. Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream extraction. Run `drift --check` at any time to verify both are installed.
### Arch Linux (AUR) ### Arch Linux (AUR)
@ -63,7 +43,7 @@ Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/
yay -S driftmusic-git yay -S driftmusic-git
``` ```
Any AUR helper works. You can also build it by hand: Any AUR helper works, or build it by hand:
```bash ```bash
git clone https://aur.archlinux.org/driftmusic-git.git git clone https://aur.archlinux.org/driftmusic-git.git
@ -71,11 +51,11 @@ cd driftmusic-git
makepkg -si makepkg -si
``` ```
`mpv` and `yt-dlp` are pulled in automatically. `mpv` and `yt-dlp` are installed automatically.
### Install script ### Install script
Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo. On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo:
```bash ```bash
git clone https://github.com/socatlolmeow/drift.git git clone https://github.com/socatlolmeow/drift.git
@ -95,15 +75,8 @@ cargo install --path .
This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup.
### Nix <details>
<summary>Installing mpv and yt-dlp manually</summary>
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 | | Platform | Command |
| --- | --- | | --- | --- |
@ -112,22 +85,20 @@ nix run github:socatlolmeow/drift
| Arch | `sudo pacman -S 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` | | Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` |
</details>
## Usage ## Usage
```bash ```bash
drift # start the player drift # start the player
drift "daft punk" # start with a search drift "daft punk" # start with a search
drift --check # check that mpv and yt-dlp are installed drift --check # verify mpv and yt-dlp are installed
drift --help
drift --version
``` ```
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. Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of keybinds.
## Keybinds ## Keybinds
These are the defaults. You can change them, see [Custom keys](#custom-keys).
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `j` `k` / `↓` `↑` | move (counts work: `5j`) | | `j` `k` / `↓` `↑` | move (counts work: `5j`) |
@ -146,47 +117,34 @@ These are the defaults. You can change them, see [Custom keys](#custom-keys).
| `G` | song menu | | `G` | song menu |
| `ga` | go to the artist | | `ga` | go to the artist |
| `gs` | see similar songs | | `gs` | see similar songs |
| `gb` | go to the album |
| `gB` | save the album as a playlist |
| `L` | toggle the lyrics panel | | `L` | toggle the lyrics panel |
| `F` | fullscreen lyrics | | `F` | fullscreen lyrics |
| `/` `:` | search / command line | | `/` `:` | search / command line |
| `?` | help | | `?` | help |
| `q` | quit | | `q` | quit |
## Commands ### Commands
Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on).
| Command | Action | | Command | Action |
| --- | --- | | --- | --- |
| `:search "query"` | search YouTube Music | | `:search "query"` | search YouTube Music |
| `:play [N]` | play or resume, or play row `N` | | `: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 | | `:seek 90` `1:30` `+10` | absolute or relative seek |
| `:vol 50` `+5` | set or change the volume | | `:vol 50` `+5` | set or change the volume |
| `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:shuffle` `:repeat [off\|all\|one]` | playback modes |
| `:add [N]` `:addall` `:playall` | queue results | | `: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` `: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 | | `:addto name` `:delpl name` | add a song to / delete a playlist |
| `:import URL` | import a YouTube / YouTube Music playlist | | `:import URL` | import a YouTube / YouTube Music playlist |
| `:album [query]` | open the selected song's album, or search albums | | `:album [query]` | open the selected song's album, or search albums |
| `:playlist query` | search YouTube Music playlists | | `:playlist query` | search YouTube Music playlists |
| `:similar` | songs similar to the selected one (also `:radio`) | | `:similar` | radio: songs similar to the selected one (also `:radio`) |
| `:artist` | songs by the selected song's artist | | `:artist` | songs by the selected song's artist |
| `:results` `:queue` `:playlists` | switch view |
| `:lyrics` | toggle the lyrics panel | | `: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 ## 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 that 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 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. Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open.
@ -211,111 +169,18 @@ popular_first = false
accent = "cyan" # a color name or a hex value like "#FF8800" accent = "cyan" # a color name or a hex value like "#FF8800"
[discord] [discord]
client_id = "" # set this to enable Discord rich presence client_id = "" # change this for Discord RPC to work in Drift
[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
``` ```
If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file. Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session.
### 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 ## Contributing
Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests: Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) before you start.
```bash ## Star History
cargo build
cargo test
```
Recent changes are listed in [CHANGELOG.md](CHANGELOG.md). [![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift)
## License ## License

View file

@ -7,11 +7,10 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Audio, Config}; use crate::config::{self, Config};
use crate::lyrics::{self, Lyrics}; use crate::lyrics::{self, Lyrics};
use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::model::{Collection, RepeatMode, Track};
use crate::discord::Discord; use crate::discord::Discord;
use crate::keys::{Action, Key, Keymap, Lookup};
use crate::mpris::{Cmd, Mpris}; use crate::mpris::{Cmd, Mpris};
use crate::player::{self, Player, PlayerEvent}; use crate::player::{self, Player, PlayerEvent};
use crate::session::Session; use crate::session::Session;
@ -38,19 +37,11 @@ pub enum AppEvent {
title: String, title: String,
result: Result<Vec<Collection>, String>, result: Result<Vec<Collection>, String>,
}, },
SavePlaylist {
name: String,
result: Result<Vec<Track>, String>,
},
Media(Cmd), Media(Cmd),
Audio { Audio {
id: String, id: String,
result: Result<std::path::PathBuf, String>, result: Result<std::path::PathBuf, String>,
}, },
Stream {
id: String,
result: Result<String, String>,
},
} }
const FIRST_PAGE: usize = 20; const FIRST_PAGE: usize = 20;
@ -123,9 +114,6 @@ pub enum MenuAction {
Artist, Artist,
Similar, Similar,
Album, Album,
SaveTrackAlbum,
SaveAlbum,
SaveResults,
PlaylistMenu, PlaylistMenu,
PickPlaylist(String), PickPlaylist(String),
NewPlaylist, NewPlaylist,
@ -205,7 +193,7 @@ pub struct App {
history: Vec<String>, history: Vec<String>,
hist_pos: Option<usize>, hist_pos: Option<usize>,
pub count: Option<usize>, pub count: Option<usize>,
pub pending: Vec<Key>, pub pending: Option<char>,
pub play_state: PlayState, pub play_state: PlayState,
pub position: f64, pub position: f64,
@ -220,8 +208,6 @@ pub struct App {
fail_streak: u8, fail_streak: u8,
want: Option<String>, want: Option<String>,
fetching: Option<String>, fetching: Option<String>,
resolving: Option<String>,
audio: Audio,
result_cursor: Option<usize>, result_cursor: Option<usize>,
buffered: Vec<Track>, buffered: Vec<Track>,
next_page: Option<String>, next_page: Option<String>,
@ -233,7 +219,6 @@ pub struct App {
mpris: Option<Mpris>, mpris: Option<Mpris>,
discord: Option<Discord>, discord: Option<Discord>,
owns_session: bool, owns_session: bool,
keymap: Keymap,
seek_step: f64, seek_step: f64,
volume_step: f64, volume_step: f64,
autoplay: bool, autoplay: bool,
@ -309,8 +294,7 @@ impl App {
history: Vec::new(), history: Vec::new(),
hist_pos: None, hist_pos: None,
count: None, count: None,
pending: Vec::new(), pending: None,
keymap: cfg.keymap.clone(),
play_state: PlayState::Stopped, play_state: PlayState::Stopped,
position: 0.0, position: 0.0,
duration: 0.0, duration: 0.0,
@ -324,8 +308,6 @@ impl App {
fail_streak: 0, fail_streak: 0,
want: None, want: None,
fetching: None, fetching: None,
resolving: None,
audio: cfg.audio.clone(),
result_cursor: None, result_cursor: None,
buffered: Vec::new(), buffered: Vec::new(),
next_page: None, next_page: None,
@ -544,103 +526,121 @@ impl App {
} }
fn key_normal(&mut self, key: KeyEvent) { fn key_normal(&mut self, key: KeyEvent) {
let key = Key::from(key); let ctrl = is_ctrl(&key);
if self.pending.is_empty() { if let Some(p) = self.pending.take() {
if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { 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;
self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999));
return; 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); let times = n.unwrap_or(1);
if self.view == View::Playlists { if self.view == View::Playlists {
match action { match key.code {
Action::SeekForward if !self.in_playlist() => { KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => {
return self.open_selected_playlist(); return self.open_selected_playlist();
} }
Action::SeekBack | Action::Back if self.in_playlist() => { KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc
if self.in_playlist() && !ctrl =>
{
return self.close_playlist(); return self.close_playlist();
} }
_ => {} _ => {}
} }
} }
match action { match key.code {
Action::HalfPageDown => self.move_sel(10), KeyCode::Char('d') if ctrl => self.move_sel(10),
Action::HalfPageUp => self.move_sel(-10), KeyCode::Char('u') if ctrl => self.move_sel(-10),
Action::PageDown => self.move_sel(20), KeyCode::Char('f') if ctrl => self.move_sel(20),
Action::PageUp => self.move_sel(-20), KeyCode::Char('b') if ctrl => self.move_sel(-20),
Action::MoveDown => self.move_sel(times as isize), KeyCode::PageDown => self.move_sel(20),
Action::MoveUp => self.move_sel(-(times as isize)), KeyCode::PageUp => self.move_sel(-20),
Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), KeyCode::Home => self.set_selected(0),
Action::GoBottom => { KeyCode::End => self.set_selected(usize::MAX),
self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1)))
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;
} }
Action::Menu => self.open_menu(), KeyCode::Char('G') | KeyCode::Menu => self.open_menu(),
Action::Delete => self.delete_selected(), 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)), KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)),
Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), KeyCode::BackTab => 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),
Action::Activate => self.activate(), KeyCode::Enter => self.activate(),
Action::Pause => self.toggle_pause(), KeyCode::Char(' ') => self.toggle_pause(),
Action::Next => self.next(), KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
Action::Prev => self.prev(), KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), KeyCode::Char('h') | KeyCode::Left => {
Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), 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 => { 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('_') => {
self.set_volume(Amount::Delta(-self.volume_step * times as f64)) self.set_volume(Amount::Delta(-self.volume_step * times as f64))
} }
Action::Add => self.add_selected(), KeyCode::Char('a') => self.add_selected(),
Action::AddAll => self.add_all_results(), KeyCode::Char('A') => self.add_all_results(),
Action::PlayAll => self.play_all_results(), KeyCode::Char('P') => self.play_all_results(),
Action::MoveItemDown => self.move_item(1), KeyCode::Char('J') => self.move_item(1),
Action::MoveItemUp => self.move_item(-1), KeyCode::Char('K') => self.move_item(-1),
Action::Shuffle => self.toggle_shuffle(), KeyCode::Char('s') => self.toggle_shuffle(),
Action::Repeat => self.cycle_repeat(), KeyCode::Char('r') => self.cycle_repeat(),
Action::Search => { KeyCode::Char('/') => {
self.cmd_buf.clear(); self.cmd_buf.clear();
self.cmd_search = true; self.cmd_search = true;
self.enter_command_mode(); self.enter_command_mode();
} }
Action::Command => { KeyCode::Char(':') => {
self.cmd_buf.clear(); self.cmd_buf.clear();
self.cmd_search = false; self.cmd_search = false;
self.enter_command_mode(); self.enter_command_mode();
} }
Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel,
Action::Lyrics => self.open_lyrics(), KeyCode::Char('F') => self.open_lyrics(),
Action::GoArtist => self.go_to_artist(), KeyCode::Char('?') => self.open_help(),
Action::GoSimilar => self.go_similar(), KeyCode::Char('q') => self.should_quit = true,
Action::GoAlbum => self.go_album(), KeyCode::Esc => self.status = None,
Action::SaveAlbum => self.save_album(None), _ => {}
Action::Help => self.open_help(),
Action::Quit => self.should_quit = true,
Action::Back => self.status = None,
} }
} }
@ -688,7 +688,6 @@ impl App {
items.push(menu_item('s', "Similar songs", MenuAction::Similar)); items.push(menu_item('s', "Similar songs", MenuAction::Similar));
if track.album.is_some() { if track.album.is_some() {
items.push(menu_item('b', "Show album", MenuAction::Album)); 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)); items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu));
if in_queue { if in_queue {
@ -700,11 +699,6 @@ impl App {
if self.view == View::Results { if self.view == View::Results {
items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll));
items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); 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 { if in_queue {
items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue));
@ -812,21 +806,6 @@ impl App {
MenuAction::Artist => self.go_to_artist(), MenuAction::Artist => self.go_to_artist(),
MenuAction::Similar => self.go_similar(), MenuAction::Similar => self.go_similar(),
MenuAction::Album => self.go_album(), 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::PlaylistMenu => {}
MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::PickPlaylist(name) => self.add_to_playlist(&name),
MenuAction::NewPlaylist => self.prefill_command("addto "), MenuAction::NewPlaylist => self.prefill_command("addto "),
@ -1155,8 +1134,6 @@ impl App {
} }
Command::Save(name) => self.save_playlist(&name, false), Command::Save(name) => self.save_playlist(&name, false),
Command::SaveForce(name) => self.save_playlist(&name, true), 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) { Command::Load(name) => match self.store.resolve(&name) {
Some(n) => self.load_playlist(&n, true), Some(n) => self.load_playlist(&n, true),
None => self.error(format!("no playlist named '{name}'")), None => self.error(format!("no playlist named '{name}'")),
@ -1306,56 +1283,6 @@ 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) { fn start_collection_search(&mut self, query: String, kind: Kind) {
let (what, title) = match kind { let (what, title) = match kind {
Kind::Album => ("albums", format!("Albums: {query}")), Kind::Album => ("albums", format!("Albums: {query}")),
@ -1379,7 +1306,6 @@ impl App {
} }
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), 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('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('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1),
KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0,
@ -1414,23 +1340,6 @@ 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) { fn on_media(&mut self, cmd: Cmd) {
match cmd { match cmd {
Cmd::Play => match self.play_state { Cmd::Play => match self.play_state {
@ -1583,29 +1492,6 @@ impl App {
} }
} }
AppEvent::Media(cmd) => self.on_media(cmd), 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 } => { AppEvent::Lyrics { id, result } => {
if self.lyrics_id.as_deref() != Some(id.as_str()) { if self.lyrics_id.as_deref() != Some(id.as_str()) {
return; return;
@ -1617,7 +1503,6 @@ impl App {
}; };
} }
AppEvent::Audio { id, result } => self.on_audio(id, result), AppEvent::Audio { id, result } => self.on_audio(id, result),
AppEvent::Stream { id, result } => self.on_stream(id, result),
AppEvent::Player(generation, pe) => { AppEvent::Player(generation, pe) => {
if generation == self.player_gen { if generation == self.player_gen {
self.on_player(pe); self.on_player(pe);
@ -1747,53 +1632,17 @@ impl App {
self.fetching = Some(id.to_string()); self.fetching = Some(id.to_string());
let tx = self.tx.clone(); let tx = self.tx.clone();
let id = id.to_string(); let id = id.to_string();
let audio = self.audio.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = player::fetch_audio(&id, &audio); let result = player::fetch_audio(&id);
let _ = tx.send(AppEvent::Audio { id, result }); let _ = tx.send(AppEvent::Audio { id, result });
}); });
} }
fn load_track(&mut self, id: &str, duration: Option<u32>) { fn load_track(&mut self, id: &str) {
self.want = Some(id.to_string()); self.want = Some(id.to_string());
match player::cached_audio(id) { match player::cached_audio(id) {
Some(path) => self.on_audio(id.to_string(), Ok(path)), Some(path) => self.on_audio(id.to_string(), Ok(path)),
None if self.audio.should_download(duration) => self.fetch_audio(id), None => 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)),
} }
} }
@ -1824,10 +1673,6 @@ impl App {
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else { let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
return; 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(); let id = next.id.clone();
if player::cached_audio(&id).is_none() { if player::cached_audio(&id).is_none() {
self.fetch_audio(&id); self.fetch_audio(&id);
@ -1855,7 +1700,7 @@ impl App {
}; };
self.duration = track.duration.map_or(0.0, f64::from); self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label())); self.info(format!("♪ {}", track.label()));
self.load_track(&track.id, track.duration); self.load_track(&track.id);
} }
fn play_default(&mut self) { fn play_default(&mut self) {
@ -1947,7 +1792,7 @@ impl App {
self.position = 0.0; self.position = 0.0;
self.duration = track.duration.map_or(0.0, f64::from); self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label())); self.info(format!("♪ {}", track.label()));
self.load_track(&track.id, track.duration); self.load_track(&track.id);
} }
fn set_pause(&mut self, pause: bool) { fn set_pause(&mut self, pause: bool) {
@ -2334,40 +2179,6 @@ 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) { fn add_to_playlist(&mut self, name: &str) {
let i = self.selected(); let i = self.selected();
let track = match self.view { let track = match self.view {
@ -2520,7 +2331,6 @@ impl App {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::keys::Keys;
use std::sync::mpsc; use std::sync::mpsc;
fn track(id: &str, title: &str) -> Track { fn track(id: &str, title: &str) -> Track {
@ -2637,40 +2447,6 @@ mod tests {
app.shuffle = false; 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] #[test]
fn playlist_drill_in() { fn playlist_drill_in() {
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
@ -2761,53 +2537,12 @@ mod tests {
check_artist_popup(&mut app); check_artist_popup(&mut app);
check_paging(&mut app); check_paging(&mut app);
check_custom_keys(&mut app);
app.run_command("remove"); app.run_command("remove");
assert_eq!(app.queue.len(), 1); assert_eq!(app.queue.len(), 1);
check_playlist_playback(&mut app); 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); let _ = std::fs::remove_dir_all(&dir);
} }
} }

View file

@ -31,8 +31,6 @@ pub enum Command {
Repeat(Option<RepeatMode>), Repeat(Option<RepeatMode>),
Save(String), Save(String),
SaveForce(String), SaveForce(String),
SaveAlbum(Option<String>),
SaveResults(Option<String>),
Load(String), Load(String),
Append(String), Append(String),
DeletePlaylist(String), DeletePlaylist(String),
@ -69,8 +67,6 @@ pub const COMMAND_NAMES: &[&str] = &[
"shuffle", "shuffle",
"repeat", "repeat",
"save", "save",
"savealbum",
"saveresults",
"load", "load",
"append", "append",
"delpl", "delpl",
@ -178,8 +174,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
}, },
"save" | "w" => Save(name_arg(arg, "save")?), "save" | "w" => Save(name_arg(arg, "save")?),
"save!" | "w!" => SaveForce(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")?), "load" | "e" => Load(name_arg(arg, "load")?),
"append" => Append(name_arg(arg, "append")?), "append" => Append(name_arg(arg, "append")?),
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
@ -290,18 +284,5 @@ mod tests {
parse("save! my mix"), parse("save! my mix"),
Ok(Some(Command::SaveForce("my mix".into()))) 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,4 +1,3 @@
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
@ -8,7 +7,6 @@ use ratatui::style::Color;
use serde::de::{self, Visitor}; use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use crate::keys::{Keymap, Keys};
use crate::model::{RepeatMode, Track}; use crate::model::{RepeatMode, Track};
use crate::session::Session; use crate::session::Session;
use crate::store::{write_atomic, Store}; use crate::store::{write_atomic, Store};
@ -82,45 +80,6 @@ pub struct Discord {
pub client_id: String, 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 { fn default_accent() -> Color {
Color::Cyan Color::Cyan
} }
@ -145,10 +104,6 @@ pub struct Config {
pub artist: Artist, pub artist: Artist,
pub ui: Ui, pub ui: Ui,
pub discord: Discord, pub discord: Discord,
pub audio: Audio,
pub keys: HashMap<String, Keys>,
#[serde(skip)]
pub keymap: Keymap,
} }
impl Config { impl Config {
@ -156,7 +111,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml")) Ok(Store::dir()?.join("config.toml"))
} }
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String { pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String {
let accent = match u.accent { let accent = match u.accent {
Color::Reset => "reset", Color::Reset => "reset",
Color::Black => "black", Color::Black => "black",
@ -208,30 +163,6 @@ accent = \"{accent}\"
# https://discord.com/developers/applications (its name is shown after # https://discord.com/developers/applications (its name is shown after
# \"Listening to\") and paste its Application ID here. Empty means off. # \"Listening to\") and paste its Application ID here. Empty means off.
client_id = \"{client_id}\" 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, volume = p.volume,
shuffle = p.shuffle, shuffle = p.shuffle,
@ -243,9 +174,6 @@ cookies_from_browser = \"{cookies}\"
popular = a.popular_first, popular = a.popular_first,
accent = accent, accent = accent,
client_id = d.client_id, client_id = d.client_id,
download = au.download,
dl_max = au.download_max_minutes,
cookies = au.cookies_from_browser.replace(['\\', '"'], ""),
) )
} }
@ -260,7 +188,7 @@ cookies_from_browser = \"{cookies}\"
let text = match fs::read_to_string(&path) { let text = match fs::read_to_string(&path) {
Ok(t) => t, Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default()));
return (Config::default(), None); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
@ -273,14 +201,7 @@ cookies_from_browser = \"{cookies}\"
match toml::from_str::<Config>(&text) { match toml::from_str::<Config>(&text) {
Ok(mut c) => { Ok(mut c) => {
c.playback.sanitize(); c.playback.sanitize();
let warning = match Keymap::build(&c.keys) { (c, None)
Ok(keymap) => {
c.keymap = keymap;
None
}
Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")),
};
(c, warning)
} }
Err(e) => { Err(e) => {
let bak = path.with_extension("toml.bak"); let bak = path.with_extension("toml.bak");
@ -346,7 +267,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s); let _ = Session::write(&s);
} }
} }
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default()));
} }
#[cfg(test)] #[cfg(test)]
@ -355,7 +276,7 @@ mod tests {
#[test] #[test]
fn default_file_parses_back() { fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()); let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default());
let c: Config = toml::from_str(&text).unwrap(); let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default(); let d = Playback::default();
assert_eq!(c.playback.volume, d.volume); assert_eq!(c.playback.volume, d.volume);
@ -422,52 +343,8 @@ mod tests {
// Roundtrip: render then parse // Roundtrip: render then parse
let u = Ui { accent: Color::Blue }; let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default()); let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default());
let c: Config = toml::from_str(&text).unwrap(); let c: Config = toml::from_str(&text).unwrap();
assert_eq!(c.ui.accent, Color::Blue); 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,9 +2,7 @@ use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread; use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use discord_rich_presence::activity::{ use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps};
Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps,
};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use crate::app::PlayState; use crate::app::PlayState;
@ -160,7 +158,6 @@ fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool {
}; };
let mut activity = Activity::new() let mut activity = Activity::new()
.activity_type(ActivityType::Listening) .activity_type(ActivityType::Listening)
.status_display_type(StatusDisplayType::Details)
.details(&p.title) .details(&p.title)
.state(&artist) .state(&artist)
.assets(assets) .assets(assets)

View file

@ -1,353 +0,0 @@
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,7 +2,6 @@ mod app;
mod command; mod command;
mod config; mod config;
mod discord; mod discord;
mod keys;
mod lyrics; mod lyrics;
mod model; mod model;
mod mpris; mod mpris;

View file

@ -6,8 +6,6 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::config::Audio;
/// Platform-specific transport for mpv's JSON IPC. /// Platform-specific transport for mpv's JSON IPC.
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). /// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
#[cfg(unix)] #[cfg(unix)]
@ -313,15 +311,6 @@ impl Drop for Player {
const KEEP_FILES: usize = 40; 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 { fn audio_dir() -> PathBuf {
dirs::cache_dir() dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir) .unwrap_or_else(std::env::temp_dir)
@ -337,13 +326,13 @@ pub fn cached_audio(id: &str) -> Option<PathBuf> {
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id)) .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
} }
pub fn fetch_audio(id: &str, audio: &Audio) -> Result<PathBuf, String> { pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
if let Some(path) = cached_audio(id) { if let Some(path) = cached_audio(id) {
return Ok(path); return Ok(path);
} }
let dir = audio_dir(); let dir = audio_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut cmd = ytdlp(audio); let mut cmd = Command::new("yt-dlp");
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"]) cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
.arg("-P") .arg("-P")
.arg(&dir) .arg(&dir)
@ -368,35 +357,6 @@ pub fn fetch_audio(id: &str, audio: &Audio) -> Result<PathBuf, String> {
.unwrap_or_else(|| "yt-dlp downloaded nothing".into())) .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) { fn prune(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else { let Ok(entries) = std::fs::read_dir(dir) else {
return; return;

View file

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