mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8d24596cbd |
|||
|
a1a3106926 |
|||
| 4570de2da7 | |||
|
8da1876835 |
|||
| 5237a6bae8 | |||
| f25b96d5fd | |||
|
27353d3358 |
|||
|
43289e1c42 |
|||
|
0867ebe682 |
|||
|
fc21fbffd8 |
|||
|
|
ad2a1462e8 | ||
|
|
d594184ff7 | ||
|
|
1cd329d112 |
11 changed files with 1118 additions and 144 deletions
22
.github/workflows/rust.yml
vendored
Normal file
22
.github/workflows/rust.yml
vendored
Normal 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
|
||||||
|
|
@ -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 = "MIT"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
|
|
||||||
|
|
|
||||||
207
README.md
207
README.md
|
|
@ -1,41 +1,61 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<h1>Drift</h1>
|
# Drift
|
||||||
|
|
||||||
[](https://github.com/socatlolmeow/drift/stargazers)
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
|
[](https://github.com/socatlolmeow/drift/stargazers)
|
||||||
|
[](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml)
|
||||||
|
[](LICENSE)
|
||||||
|

|
||||||
|
|
||||||
|
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
## Table of contents
|
||||||
<img src="https://github.com/socatlolmeow/drift/blob/main/examples/showcase.gif?raw=true" alt="Drift showcase" />
|
|
||||||
</div>
|
- [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
|
## 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
|
||||||
- **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
|
||||||
- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung
|
- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL
|
||||||
- **Playlists & queue** - save, load 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
|
||||||
- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info
|
- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info
|
||||||
- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history
|
- A command palette with completion and history
|
||||||
- **Artist browsing** - press `ga` to see an artist's songs
|
- Your queue and position are restored the next time you start Drift
|
||||||
- **Similar songs** - press `gs` to see similar songs
|
- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence
|
||||||
- **Audio visualizer** - a live visualizer while music plays
|
- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds
|
||||||
- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream extraction. Run `drift --check` at any time to verify both are installed.
|
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)
|
### 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
|
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
|
```bash
|
||||||
git clone https://aur.archlinux.org/driftmusic-git.git
|
git clone https://aur.archlinux.org/driftmusic-git.git
|
||||||
|
|
@ -51,11 +71,11 @@ cd driftmusic-git
|
||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
`mpv` and `yt-dlp` are installed automatically.
|
`mpv` and `yt-dlp` are pulled in automatically.
|
||||||
|
|
||||||
### Install script
|
### 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
|
```bash
|
||||||
git clone https://github.com/socatlolmeow/drift.git
|
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.
|
This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup.
|
||||||
|
|
||||||
<details>
|
### Nix
|
||||||
<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 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -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` |
|
| 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 # 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
|
## 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`) |
|
||||||
|
|
@ -117,34 +146,47 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of
|
||||||
| `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` | 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 |
|
| `: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 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.
|
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"
|
accent = "cyan" # a color name or a hex value like "#FF8800"
|
||||||
|
|
||||||
[discord]
|
[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
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
[](https://star-history.com/#socatlolmeow/drift)
|
Recent changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
453
src/app.rs
453
src/app.rs
|
|
@ -7,10 +7,11 @@ 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, Config};
|
use crate::config::{self, Audio, Config};
|
||||||
use crate::lyrics::{self, Lyrics};
|
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::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;
|
||||||
|
|
@ -37,11 +38,19 @@ 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;
|
||||||
|
|
@ -114,6 +123,9 @@ pub enum MenuAction {
|
||||||
Artist,
|
Artist,
|
||||||
Similar,
|
Similar,
|
||||||
Album,
|
Album,
|
||||||
|
SaveTrackAlbum,
|
||||||
|
SaveAlbum,
|
||||||
|
SaveResults,
|
||||||
PlaylistMenu,
|
PlaylistMenu,
|
||||||
PickPlaylist(String),
|
PickPlaylist(String),
|
||||||
NewPlaylist,
|
NewPlaylist,
|
||||||
|
|
@ -193,7 +205,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: Option<char>,
|
pub pending: Vec<Key>,
|
||||||
|
|
||||||
pub play_state: PlayState,
|
pub play_state: PlayState,
|
||||||
pub position: f64,
|
pub position: f64,
|
||||||
|
|
@ -208,6 +220,8 @@ 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>,
|
||||||
|
|
@ -219,6 +233,7 @@ 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,
|
||||||
|
|
@ -294,7 +309,8 @@ impl App {
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
hist_pos: None,
|
hist_pos: None,
|
||||||
count: None,
|
count: None,
|
||||||
pending: None,
|
pending: Vec::new(),
|
||||||
|
keymap: cfg.keymap.clone(),
|
||||||
play_state: PlayState::Stopped,
|
play_state: PlayState::Stopped,
|
||||||
position: 0.0,
|
position: 0.0,
|
||||||
duration: 0.0,
|
duration: 0.0,
|
||||||
|
|
@ -308,6 +324,8 @@ 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,
|
||||||
|
|
@ -526,121 +544,103 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_normal(&mut self, key: KeyEvent) {
|
fn key_normal(&mut self, key: KeyEvent) {
|
||||||
let ctrl = is_ctrl(&key);
|
let key = Key::from(key);
|
||||||
|
|
||||||
if let Some(p) = self.pending.take() {
|
if self.pending.is_empty() {
|
||||||
let n = self.count.take();
|
if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) {
|
||||||
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 key.code {
|
match action {
|
||||||
KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => {
|
Action::SeekForward if !self.in_playlist() => {
|
||||||
return self.open_selected_playlist();
|
return self.open_selected_playlist();
|
||||||
}
|
}
|
||||||
KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc
|
Action::SeekBack | Action::Back if self.in_playlist() => {
|
||||||
if self.in_playlist() && !ctrl =>
|
|
||||||
{
|
|
||||||
return self.close_playlist();
|
return self.close_playlist();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match key.code {
|
match action {
|
||||||
KeyCode::Char('d') if ctrl => self.move_sel(10),
|
Action::HalfPageDown => self.move_sel(10),
|
||||||
KeyCode::Char('u') if ctrl => self.move_sel(-10),
|
Action::HalfPageUp => self.move_sel(-10),
|
||||||
KeyCode::Char('f') if ctrl => self.move_sel(20),
|
Action::PageDown => self.move_sel(20),
|
||||||
KeyCode::Char('b') if ctrl => self.move_sel(-20),
|
Action::PageUp => self.move_sel(-20),
|
||||||
KeyCode::PageDown => self.move_sel(20),
|
Action::MoveDown => self.move_sel(times as isize),
|
||||||
KeyCode::PageUp => self.move_sel(-20),
|
Action::MoveUp => self.move_sel(-(times as isize)),
|
||||||
KeyCode::Home => self.set_selected(0),
|
Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))),
|
||||||
KeyCode::End => self.set_selected(usize::MAX),
|
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),
|
Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)),
|
||||||
KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)),
|
Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)),
|
||||||
KeyCode::Char('g') => {
|
Action::ViewResults => self.set_view(View::Results),
|
||||||
self.pending = Some('g');
|
Action::ViewQueue => self.set_view(View::Queue),
|
||||||
self.count = n;
|
Action::ViewPlaylists => self.set_view(View::Playlists),
|
||||||
}
|
|
||||||
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(),
|
|
||||||
|
|
||||||
KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)),
|
Action::Activate => self.activate(),
|
||||||
KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)),
|
Action::Pause => self.toggle_pause(),
|
||||||
|
Action::Next => self.next(),
|
||||||
KeyCode::Enter => self.activate(),
|
Action::Prev => self.prev(),
|
||||||
KeyCode::Char(' ') => self.toggle_pause(),
|
Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)),
|
||||||
KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
|
Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)),
|
||||||
KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
|
Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)),
|
||||||
KeyCode::Char('h') | KeyCode::Left => {
|
Action::VolumeDown => {
|
||||||
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('_') => {
|
|
||||||
self.set_volume(Amount::Delta(-self.volume_step * times as f64))
|
self.set_volume(Amount::Delta(-self.volume_step * times as f64))
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyCode::Char('a') => self.add_selected(),
|
Action::Add => self.add_selected(),
|
||||||
KeyCode::Char('A') => self.add_all_results(),
|
Action::AddAll => self.add_all_results(),
|
||||||
KeyCode::Char('P') => self.play_all_results(),
|
Action::PlayAll => self.play_all_results(),
|
||||||
KeyCode::Char('J') => self.move_item(1),
|
Action::MoveItemDown => self.move_item(1),
|
||||||
KeyCode::Char('K') => self.move_item(-1),
|
Action::MoveItemUp => self.move_item(-1),
|
||||||
KeyCode::Char('s') => self.toggle_shuffle(),
|
Action::Shuffle => self.toggle_shuffle(),
|
||||||
KeyCode::Char('r') => self.cycle_repeat(),
|
Action::Repeat => self.cycle_repeat(),
|
||||||
|
|
||||||
KeyCode::Char('/') => {
|
Action::Search => {
|
||||||
self.cmd_buf.clear();
|
self.cmd_buf.clear();
|
||||||
self.cmd_search = true;
|
self.cmd_search = true;
|
||||||
self.enter_command_mode();
|
self.enter_command_mode();
|
||||||
}
|
}
|
||||||
KeyCode::Char(':') => {
|
Action::Command => {
|
||||||
self.cmd_buf.clear();
|
self.cmd_buf.clear();
|
||||||
self.cmd_search = false;
|
self.cmd_search = false;
|
||||||
self.enter_command_mode();
|
self.enter_command_mode();
|
||||||
}
|
}
|
||||||
KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel,
|
Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel,
|
||||||
KeyCode::Char('F') => self.open_lyrics(),
|
Action::Lyrics => self.open_lyrics(),
|
||||||
KeyCode::Char('?') => self.open_help(),
|
Action::GoArtist => self.go_to_artist(),
|
||||||
KeyCode::Char('q') => self.should_quit = true,
|
Action::GoSimilar => self.go_similar(),
|
||||||
KeyCode::Esc => self.status = None,
|
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));
|
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 {
|
||||||
|
|
@ -699,6 +700,11 @@ 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));
|
||||||
|
|
@ -806,6 +812,21 @@ 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 "),
|
||||||
|
|
@ -1134,6 +1155,8 @@ 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}'")),
|
||||||
|
|
@ -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) {
|
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}")),
|
||||||
|
|
@ -1306,6 +1379,7 @@ 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,
|
||||||
|
|
@ -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) {
|
fn on_media(&mut self, cmd: Cmd) {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Play => match self.play_state {
|
Cmd::Play => match self.play_state {
|
||||||
|
|
@ -1492,6 +1583,29 @@ 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;
|
||||||
|
|
@ -1503,6 +1617,7 @@ 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);
|
||||||
|
|
@ -1632,17 +1747,53 @@ 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);
|
let result = player::fetch_audio(&id, &audio);
|
||||||
let _ = tx.send(AppEvent::Audio { id, result });
|
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());
|
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 => 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 {
|
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);
|
||||||
|
|
@ -1700,7 +1855,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);
|
self.load_track(&track.id, track.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play_default(&mut self) {
|
fn play_default(&mut self) {
|
||||||
|
|
@ -1792,7 +1947,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);
|
self.load_track(&track.id, track.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pause(&mut self, pause: bool) {
|
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) {
|
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 {
|
||||||
|
|
@ -2331,6 +2520,7 @@ 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 {
|
||||||
|
|
@ -2447,6 +2637,40 @@ 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()));
|
||||||
|
|
@ -2537,12 +2761,53 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ 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),
|
||||||
|
|
@ -67,6 +69,8 @@ pub const COMMAND_NAMES: &[&str] = &[
|
||||||
"shuffle",
|
"shuffle",
|
||||||
"repeat",
|
"repeat",
|
||||||
"save",
|
"save",
|
||||||
|
"savealbum",
|
||||||
|
"saveresults",
|
||||||
"load",
|
"load",
|
||||||
"append",
|
"append",
|
||||||
"delpl",
|
"delpl",
|
||||||
|
|
@ -174,6 +178,8 @@ 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")?),
|
||||||
|
|
@ -284,5 +290,18 @@ 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()))))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
src/config.rs
135
src/config.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
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;
|
||||||
|
|
@ -7,6 +8,7 @@ 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};
|
||||||
|
|
@ -80,6 +82,45 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +145,10 @@ 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 {
|
||||||
|
|
@ -111,7 +156,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) -> String {
|
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String {
|
||||||
let accent = match u.accent {
|
let accent = match u.accent {
|
||||||
Color::Reset => "reset",
|
Color::Reset => "reset",
|
||||||
Color::Black => "black",
|
Color::Black => "black",
|
||||||
|
|
@ -163,6 +208,30 @@ 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,
|
||||||
|
|
@ -174,6 +243,9 @@ client_id = \"{client_id}\"
|
||||||
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(['\\', '"'], ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,7 +260,7 @@ client_id = \"{client_id}\"
|
||||||
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()));
|
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
|
||||||
return (Config::default(), None);
|
return (Config::default(), None);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -201,7 +273,14 @@ client_id = \"{client_id}\"
|
||||||
match toml::from_str::<Config>(&text) {
|
match toml::from_str::<Config>(&text) {
|
||||||
Ok(mut c) => {
|
Ok(mut c) => {
|
||||||
c.playback.sanitize();
|
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) => {
|
Err(e) => {
|
||||||
let bak = path.with_extension("toml.bak");
|
let bak = path.with_extension("toml.bak");
|
||||||
|
|
@ -267,7 +346,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()));
|
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -276,7 +355,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());
|
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::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);
|
||||||
|
|
@ -343,8 +422,52 @@ 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());
|
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ 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::{Activity, ActivityType, Assets, Button, Timestamps};
|
use discord_rich_presence::activity::{
|
||||||
|
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;
|
||||||
|
|
@ -158,6 +160,7 @@ 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)
|
||||||
|
|
|
||||||
353
src/keys.rs
Normal file
353
src/keys.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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)]
|
||||||
|
|
@ -311,6 +313,15 @@ 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)
|
||||||
|
|
@ -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))
|
.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) {
|
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 = Command::new("yt-dlp");
|
let mut cmd = ytdlp(audio);
|
||||||
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)
|
||||||
|
|
@ -357,6 +368,35 @@ pub fn fetch_audio(id: &str) -> 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;
|
||||||
|
|
|
||||||
19
src/ui.rs
19
src/ui.rs
|
|
@ -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());
|
||||||
}
|
}
|
||||||
if let Some(p) = app.pending {
|
for key in &app.pending {
|
||||||
pending.push(p);
|
pending.push_str(&key.to_string());
|
||||||
}
|
}
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
block = block.title_bottom(
|
block = block.title_bottom(
|
||||||
|
|
@ -806,6 +806,8 @@ 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"),
|
||||||
|
|
@ -1132,6 +1134,14 @@ 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",
|
||||||
|
|
@ -1281,7 +1291,10 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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(Clear, popup);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(lines).block(
|
Paragraph::new(lines).block(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue