diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 22d50f9..185e096 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "drift" version = "0.1.0" edition = "2021" description = "A Vim-style terminal music player for YouTube Music (no login required)" -license = "GPL-3.0-or-later" +license = "MIT" [workspace] diff --git a/README.md b/README.md index f5e0613..840730c 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,41 @@
-# Drift +

Drift

+ +[![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. 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) - -Drift showcase -
--- -## Table of contents - -- [Features](#features) -- [Installation](#installation) - - [Arch Linux (AUR)](#arch-linux-aur) - - [Install script](#install-script) - - [From source](#from-source) - - [Nix](#nix) - - [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually) -- [Usage](#usage) -- [Keybinds](#keybinds) -- [Commands](#commands) -- [Lyrics](#lyrics) -- [Configuration](#configuration) - - [Streaming or downloading](#streaming-or-downloading) - - [Discord rich presence](#discord-rich-presence) - - [Custom keys](#custom-keys) - - [Environment variables](#environment-variables) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) -- [License](#license) +
+ Drift showcase +
--- ## Features -- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line -- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung -- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists -- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info -- A command palette with completion and history -- Your queue and position are restored the next time you start Drift -- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence -- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds +- **No login** - search and stream straight from YouTube Music +- **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line +- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung +- **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL +- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info +- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history +- **Artist browsing** - press `ga` to see an artist's songs +- **Similar songs** - press `gs` to see similar songs +- **Audio visualizer** - a live visualizer while music plays +- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file ## 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) @@ -63,7 +43,7 @@ Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/ 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 git clone https://aur.archlinux.org/driftmusic-git.git @@ -71,11 +51,11 @@ cd driftmusic-git makepkg -si ``` -`mpv` and `yt-dlp` are pulled in automatically. +`mpv` and `yt-dlp` are installed automatically. ### 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 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. -### Nix - -The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you: - -```bash -nix run github:socatlolmeow/drift -``` - -### Installing mpv and yt-dlp manually +
+Installing mpv and yt-dlp manually | Platform | Command | | --- | --- | @@ -112,22 +85,20 @@ nix run github:socatlolmeow/drift | Arch | `sudo pacman -S mpv yt-dlp` | | Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` | +
+ ## Usage ```bash drift # start the player drift "daft punk" # start with a search -drift --check # check that mpv and yt-dlp are installed -drift --help -drift --version +drift --check # verify mpv and yt-dlp are installed ``` -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 -These are the defaults. You can change them, see [Custom keys](#custom-keys). - | Key | Action | | --- | --- | | `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 | | `ga` | go to the artist | | `gs` | see similar songs | -| `gb` | go to the album | -| `gB` | save the album as a playlist | | `L` | toggle the lyrics panel | | `F` | fullscreen lyrics | | `/` `:` | search / command line | | `?` | help | | `q` | quit | -## Commands - -Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on). +### Commands | Command | Action | | --- | --- | | `:search "query"` | search YouTube Music | | `:play [N]` | play or resume, or play row `N` | -| `:pause` `:resume` `:toggle` `:stop` | control playback | -| `:next` `:prev` | next / previous track | | `:seek 90` `1:30` `+10` | absolute or relative seek | | `:vol 50` `+5` | set or change the volume | | `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:add [N]` `:addall` `:playall` | queue results | -| `:remove [N]` `:clear` | remove from the queue / clear it | | `:save name` `:load name` `:append name` | work with playlists | -| `:save! name` | save and overwrite an existing playlist | -| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | | `:album [query]` | open the selected song's album, or search albums | | `:playlist query` | search YouTube Music playlists | -| `:similar` | 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 | -| `:results` `:queue` `:playlists` | switch view | | `:lyrics` | toggle the lyrics panel | -| `:quit` | quit | - -`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it. ## Lyrics -Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text 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. @@ -211,111 +169,18 @@ popular_first = false accent = "cyan" # a color name or a hex value like "#FF8800" [discord] -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 +client_id = "" # change this for Discord RPC to work in Drift ``` -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 ` 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. +Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. ## Contributing -Bug reports, ideas and pull requests are welcome. 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 -cargo build -cargo test -``` +## Star History -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 diff --git a/src/app.rs b/src/app.rs index d8fdade..952b95f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,11 +7,10 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::widgets::TableState; 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::model::{split_artists, Collection, RepeatMode, Track}; +use crate::model::{Collection, RepeatMode, Track}; use crate::discord::Discord; -use crate::keys::{Action, Key, Keymap, Lookup}; use crate::mpris::{Cmd, Mpris}; use crate::player::{self, Player, PlayerEvent}; use crate::session::Session; @@ -38,19 +37,11 @@ pub enum AppEvent { title: String, result: Result, String>, }, - SavePlaylist { - name: String, - result: Result, String>, - }, Media(Cmd), Audio { id: String, result: Result, }, - Stream { - id: String, - result: Result, - }, } const FIRST_PAGE: usize = 20; @@ -123,9 +114,6 @@ pub enum MenuAction { Artist, Similar, Album, - SaveTrackAlbum, - SaveAlbum, - SaveResults, PlaylistMenu, PickPlaylist(String), NewPlaylist, @@ -205,7 +193,7 @@ pub struct App { history: Vec, hist_pos: Option, pub count: Option, - pub pending: Vec, + pub pending: Option, pub play_state: PlayState, pub position: f64, @@ -220,8 +208,6 @@ pub struct App { fail_streak: u8, want: Option, fetching: Option, - resolving: Option, - audio: Audio, result_cursor: Option, buffered: Vec, next_page: Option, @@ -233,7 +219,6 @@ pub struct App { mpris: Option, discord: Option, owns_session: bool, - keymap: Keymap, seek_step: f64, volume_step: f64, autoplay: bool, @@ -309,8 +294,7 @@ impl App { history: Vec::new(), hist_pos: None, count: None, - pending: Vec::new(), - keymap: cfg.keymap.clone(), + pending: None, play_state: PlayState::Stopped, position: 0.0, duration: 0.0, @@ -324,8 +308,6 @@ impl App { fail_streak: 0, want: None, fetching: None, - resolving: None, - audio: cfg.audio.clone(), result_cursor: None, buffered: Vec::new(), next_page: None, @@ -544,103 +526,121 @@ impl App { } 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(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { + if let Some(p) = self.pending.take() { + let n = self.count.take(); + if let KeyCode::Char(c) = key.code { + match (p, c) { + ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + ('g', 'e') => { + self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + } + ('g', 'r') => self.set_view(View::Results), + ('g', 'q') => self.set_view(View::Queue), + ('g', 'p') => self.set_view(View::Playlists), + ('d', 'd') => self.delete_selected(), + ('g', 'a') => self.go_to_artist(), + ('g', 's') => self.go_similar(), + ('g', 'b') => self.go_album(), + _ => {} + } + } + return; + } + + if let KeyCode::Char(c @ '0'..='9') = key.code { + if !ctrl && !(c == '0' && self.count.is_none()) { + let d = c as usize - '0' as usize; self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); return; } } - - 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) { + let n = self.count.take(); let times = n.unwrap_or(1); if self.view == View::Playlists { - match action { - Action::SeekForward if !self.in_playlist() => { + match key.code { + KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { 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(); } _ => {} } } - match action { - Action::HalfPageDown => self.move_sel(10), - Action::HalfPageUp => self.move_sel(-10), - Action::PageDown => self.move_sel(20), - Action::PageUp => self.move_sel(-20), - Action::MoveDown => self.move_sel(times as isize), - Action::MoveUp => self.move_sel(-(times as isize)), - Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), - Action::GoBottom => { - self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + match key.code { + KeyCode::Char('d') if ctrl => self.move_sel(10), + KeyCode::Char('u') if ctrl => self.move_sel(-10), + KeyCode::Char('f') if ctrl => self.move_sel(20), + KeyCode::Char('b') if ctrl => self.move_sel(-20), + KeyCode::PageDown => self.move_sel(20), + KeyCode::PageUp => self.move_sel(-20), + KeyCode::Home => self.set_selected(0), + KeyCode::End => self.set_selected(usize::MAX), + + 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(), - Action::Delete => self.delete_selected(), + KeyCode::Char('G') | KeyCode::Menu => self.open_menu(), + KeyCode::Char('d') => { + if matches!(self.view, View::Queue | View::Playlists) { + self.pending = Some('d'); + } + } + KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), - Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)), - Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::ViewResults => self.set_view(View::Results), - Action::ViewQueue => self.set_view(View::Queue), - Action::ViewPlaylists => self.set_view(View::Playlists), + KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), + KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::Activate => self.activate(), - Action::Pause => self.toggle_pause(), - Action::Next => self.next(), - Action::Prev => self.prev(), - Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), - Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), - Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)), - Action::VolumeDown => { + KeyCode::Enter => self.activate(), + KeyCode::Char(' ') => self.toggle_pause(), + KeyCode::Char('n') | KeyCode::Char('>') => self.next(), + KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), + KeyCode::Char('h') | KeyCode::Left => { + self.seek(Amount::Delta(-self.seek_step * times as f64)) + } + KeyCode::Char('l') | KeyCode::Right => { + self.seek(Amount::Delta(self.seek_step * times as f64)) + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.set_volume(Amount::Delta(self.volume_step * times as f64)) + } + KeyCode::Char('-') | KeyCode::Char('_') => { self.set_volume(Amount::Delta(-self.volume_step * times as f64)) } - Action::Add => self.add_selected(), - Action::AddAll => self.add_all_results(), - Action::PlayAll => self.play_all_results(), - Action::MoveItemDown => self.move_item(1), - Action::MoveItemUp => self.move_item(-1), - Action::Shuffle => self.toggle_shuffle(), - Action::Repeat => self.cycle_repeat(), + KeyCode::Char('a') => self.add_selected(), + KeyCode::Char('A') => self.add_all_results(), + KeyCode::Char('P') => self.play_all_results(), + KeyCode::Char('J') => self.move_item(1), + KeyCode::Char('K') => self.move_item(-1), + KeyCode::Char('s') => self.toggle_shuffle(), + KeyCode::Char('r') => self.cycle_repeat(), - Action::Search => { + KeyCode::Char('/') => { self.cmd_buf.clear(); self.cmd_search = true; self.enter_command_mode(); } - Action::Command => { + KeyCode::Char(':') => { self.cmd_buf.clear(); self.cmd_search = false; self.enter_command_mode(); } - Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, - Action::Lyrics => self.open_lyrics(), - Action::GoArtist => self.go_to_artist(), - Action::GoSimilar => self.go_similar(), - Action::GoAlbum => self.go_album(), - Action::SaveAlbum => self.save_album(None), - Action::Help => self.open_help(), - Action::Quit => self.should_quit = true, - Action::Back => self.status = None, + KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel, + KeyCode::Char('F') => self.open_lyrics(), + KeyCode::Char('?') => self.open_help(), + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Esc => self.status = None, + _ => {} } } @@ -688,7 +688,6 @@ impl App { items.push(menu_item('s', "Similar songs", MenuAction::Similar)); if track.album.is_some() { items.push(menu_item('b', "Show album", MenuAction::Album)); - items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum)); } items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); if in_queue { @@ -700,11 +699,6 @@ impl App { if self.view == View::Results { items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); - if self.results_title.starts_with("Album: ") { - items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum)); - } else { - items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults)); - } } if in_queue { items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); @@ -812,21 +806,6 @@ impl App { MenuAction::Artist => self.go_to_artist(), MenuAction::Similar => self.go_similar(), MenuAction::Album => self.go_album(), - MenuAction::SaveTrackAlbum => { - if let Some(t) = track { - let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string()); - self.save_album_background(t, album_title); - } - } - MenuAction::SaveAlbum => { - let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim(); - if !default_name.is_empty() { - self.prefill_command(&format!("savealbum \"{default_name}\"")); - } else { - self.prefill_command("savealbum "); - } - } - MenuAction::SaveResults => self.prefill_command("saveresults "), MenuAction::PlaylistMenu => {} MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::NewPlaylist => self.prefill_command("addto "), @@ -1155,8 +1134,6 @@ impl App { } Command::Save(name) => self.save_playlist(&name, false), Command::SaveForce(name) => self.save_playlist(&name, true), - Command::SaveAlbum(name) => self.save_album(name), - Command::SaveResults(name) => self.save_results_as_playlist(name), Command::Load(name) => match self.store.resolve(&name) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1306,56 +1283,6 @@ impl App { }); } - fn save_album(&mut self, name: Option) { - 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, String> { - let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; - let want = album.to_lowercase(); - let pick = found - .iter() - .find(|c| c.title.to_lowercase() == want) - .or(found.first()) - .ok_or_else(|| "album not found".to_string())?; - ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist) - })(); - let _ = tx.send(AppEvent::SavePlaylist { - name: target_name, - result, - }); - }); - } - fn start_collection_search(&mut self, query: String, kind: Kind) { let (what, title) = match kind { Kind::Album => ("albums", format!("Albums: {query}")), @@ -1379,7 +1306,6 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), - KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(), KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, @@ -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) { match cmd { Cmd::Play => match self.play_state { @@ -1583,29 +1492,6 @@ impl App { } } AppEvent::Media(cmd) => self.on_media(cmd), - AppEvent::SavePlaylist { name, result } => { - self.busy = None; - match result { - Ok(tracks) if tracks.is_empty() => { - self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks")); - } - Ok(tracks) => { - let n = tracks.len(); - let key = self.store.resolve_exact(&name).unwrap_or(name); - self.store.playlists.insert(key.clone(), tracks); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - let sel = self.selected(); - self.set_selected(sel); - } - } - Err(e) => { - self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}")) - } - } - } AppEvent::Lyrics { id, result } => { if self.lyrics_id.as_deref() != Some(id.as_str()) { return; @@ -1617,7 +1503,6 @@ impl App { }; } AppEvent::Audio { id, result } => self.on_audio(id, result), - AppEvent::Stream { id, result } => self.on_stream(id, result), AppEvent::Player(generation, pe) => { if generation == self.player_gen { self.on_player(pe); @@ -1747,53 +1632,17 @@ impl App { self.fetching = Some(id.to_string()); let tx = self.tx.clone(); let id = id.to_string(); - let audio = self.audio.clone(); std::thread::spawn(move || { - let result = player::fetch_audio(&id, &audio); + let result = player::fetch_audio(&id); let _ = tx.send(AppEvent::Audio { id, result }); }); } - fn load_track(&mut self, id: &str, duration: Option) { + fn load_track(&mut self, id: &str) { self.want = Some(id.to_string()); match player::cached_audio(id) { Some(path) => self.on_audio(id.to_string(), Ok(path)), - 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) { - 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)), + None => self.fetch_audio(id), } } @@ -1824,10 +1673,6 @@ impl App { let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else { return; }; - /* streamed tracks (the default, or too long to download) aren't prefetched */ - if !self.audio.should_download(next.duration) { - return; - } let id = next.id.clone(); if player::cached_audio(&id).is_none() { self.fetch_audio(&id); @@ -1855,7 +1700,7 @@ impl App { }; self.duration = track.duration.map_or(0.0, f64::from); self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + self.load_track(&track.id); } fn play_default(&mut self) { @@ -1947,7 +1792,7 @@ impl App { self.position = 0.0; self.duration = track.duration.map_or(0.0, f64::from); self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + self.load_track(&track.id); } fn set_pause(&mut self, pause: bool) { @@ -2334,40 +2179,6 @@ impl App { } } - fn save_results_as_playlist(&mut self, name: Option) { - 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 or :saveresults ", - ); - } - } - }; - let n = self.results.len(); - let key = self.store.resolve_exact(&target_name).unwrap_or(target_name); - self.store - .playlists - .insert(key.clone(), self.results.clone()); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - let sel = self.selected(); - self.set_selected(sel); - } - } - fn add_to_playlist(&mut self, name: &str) { let i = self.selected(); let track = match self.view { @@ -2520,7 +2331,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::keys::Keys; use std::sync::mpsc; fn track(id: &str, title: &str) -> Track { @@ -2637,40 +2447,6 @@ mod tests { app.shuffle = false; } - fn check_custom_keys(app: &mut App) { - let custom = [ - ("move_down", Keys::Many(vec!["e".into()])), - ("go_top", Keys::One("zz".into())), - ("quit", Keys::One("Q".into())), - ]; - let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect(); - app.keymap = Keymap::build(&custom).unwrap(); - - app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect(); - app.view = View::Results; - app.states[View::Results.idx()].select(Some(0)); - - press(app, KeyCode::Char('j')); - assert_eq!(app.selected(), 0); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 1); - press(app, KeyCode::Char('2')); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 3); - press(app, KeyCode::Char('z')); - assert!(app.count.is_none() && !app.pending.is_empty()); - press(app, KeyCode::Char('z')); - assert_eq!(app.selected(), 0); - assert!(app.pending.is_empty()); - - press(app, KeyCode::Char('q')); - assert!(!app.should_quit); - press(app, KeyCode::Char('Q')); - assert!(app.should_quit); - app.should_quit = false; - app.keymap = Keymap::default(); - } - #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); @@ -2761,53 +2537,12 @@ mod tests { check_artist_popup(&mut app); check_paging(&mut app); - check_custom_keys(&mut app); app.run_command("remove"); assert_eq!(app.queue.len(), 1); check_playlist_playback(&mut app); - // Test savealbum and saveresults - let mut t1 = track("song1111111", "Dreams"); - t1.album = Some("Rumours".into()); - let mut t2 = track("song2222222", "Go Your Own Way"); - t2.album = Some("Rumours".into()); - app.results = vec![t1.clone(), t2.clone()]; - app.results_title = "Album: Rumours".into(); - app.view = View::Results; - - // :savealbum with no args uses album name from results_title - app.run_command("savealbum"); - assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2)); - - // :savealbum with custom name - app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#); - assert_eq!( - app.store - .playlists - .get("Fleetwood Mac - Rumours") - .map(Vec::len), - Some(2) - ); - - // :saveresults with custom name - app.run_command(r#"saveresults "Classic Hits""#); - assert_eq!( - app.store.playlists.get("Classic Hits").map(Vec::len), - Some(2) - ); - - // Test AppEvent::SavePlaylist background event - app.on_event(AppEvent::SavePlaylist { - name: "Background Album".into(), - result: Ok(vec![track("bg111111111", "BG Track")]), - }); - assert_eq!( - app.store.playlists.get("Background Album").map(Vec::len), - Some(1) - ); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index 3f6ada2..ccb116d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -31,8 +31,6 @@ pub enum Command { Repeat(Option), Save(String), SaveForce(String), - SaveAlbum(Option), - SaveResults(Option), Load(String), Append(String), DeletePlaylist(String), @@ -69,8 +67,6 @@ pub const COMMAND_NAMES: &[&str] = &[ "shuffle", "repeat", "save", - "savealbum", - "saveresults", "load", "append", "delpl", @@ -178,8 +174,6 @@ pub fn parse(line: &str) -> Result, String> { }, "save" | "w" => Save(name_arg(arg, "save")?), "save!" | "w!" => SaveForce(name_arg(arg, "save!")?), - "savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())), - "saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -290,18 +284,5 @@ mod tests { parse("save! my mix"), Ok(Some(Command::SaveForce("my mix".into()))) ); - assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None)))); - assert_eq!( - parse(r#"savealbum "Abbey Road""#), - Ok(Some(Command::SaveAlbum(Some("Abbey Road".into())))) - ); - assert_eq!( - parse("saveresults"), - Ok(Some(Command::SaveResults(None))) - ); - assert_eq!( - parse(r#"saveresults "My Hits""#), - Ok(Some(Command::SaveResults(Some("My Hits".into())))) - ); } } diff --git a/src/config.rs b/src/config.rs index cb92b38..07e67a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::str::FromStr; @@ -8,7 +7,6 @@ use ratatui::style::Color; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer}; -use crate::keys::{Keymap, Keys}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; @@ -82,45 +80,6 @@ pub struct Discord { pub client_id: String, } -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct Audio { - pub download: bool, - pub download_max_minutes: u32, - pub cookies_from_browser: String, -} - -impl Default for Audio { - fn default() -> Self { - Audio { - download: false, - download_max_minutes: 15, - cookies_from_browser: String::new(), - } - } -} - -impl Audio { - /// Browser name for yt-dlp's --cookies-from-browser, if one is set. - pub fn cookies(&self) -> Option<&str> { - let c = self.cookies_from_browser.trim(); - (!c.is_empty()).then_some(c) - } - - /// Whether a track should be downloaded to the cache before playing, - /// as opposed to streamed. Unknown lengths are streamed when a limit is set. - pub fn should_download(&self, duration: Option) -> bool { - if !self.download { - return false; - } - match (self.download_max_minutes, duration) { - (0, _) => true, - (max, Some(d)) => d <= max.saturating_mul(60), - (_, None) => false, - } - } -} - fn default_accent() -> Color { Color::Cyan } @@ -145,10 +104,6 @@ pub struct Config { pub artist: Artist, pub ui: Ui, pub discord: Discord, - pub audio: Audio, - pub keys: HashMap, - #[serde(skip)] - pub keymap: Keymap, } impl Config { @@ -156,7 +111,7 @@ impl Config { 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 { Color::Reset => "reset", Color::Black => "black", @@ -208,30 +163,6 @@ accent = \"{accent}\" # https://discord.com/developers/applications (its name is shown after # \"Listening to\") and paste its Application ID here. Empty means off. client_id = \"{client_id}\" - -[audio] -# false: stream songs straight from YouTube (starts playing right away). -# true: download songs to the cache first, then play (and prefetch the next one). -download = {download} -# With download = true, songs longer than this many minutes are still streamed -# instead of downloaded. 0 means download everything, however long. -download_max_minutes = {dl_max} -# Use your browser's cookies with yt-dlp when YouTube refuses to play something -# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...). -# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", -# \"firefox:my-profile\". Empty means off. Close the browser if reading fails. -cookies_from_browser = \"{cookies}\" - -[keys] -# Remap keys here. Each line sets an action to one key or a list of keys and -# replaces that action's default keys. Everything you leave out keeps its vim -# default. See the README for the list of actions. -# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab, -# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12), -# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds. -# quit = \"Q\" -# move_down = [\"j\", \"down\"] -# go_top = [\"gg\", \"home\"] ", volume = p.volume, shuffle = p.shuffle, @@ -243,9 +174,6 @@ cookies_from_browser = \"{cookies}\" popular = a.popular_first, accent = accent, client_id = d.client_id, - download = au.download, - dl_max = au.download_max_minutes, - cookies = au.cookies_from_browser.replace(['\\', '"'], ""), ) } @@ -260,7 +188,7 @@ cookies_from_browser = \"{cookies}\" let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default())); return (Config::default(), None); } Err(e) => { @@ -273,14 +201,7 @@ cookies_from_browser = \"{cookies}\" match toml::from_str::(&text) { Ok(mut c) => { c.playback.sanitize(); - 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) + (c, None) } Err(e) => { let bak = path.with_extension("toml.bak"); @@ -346,7 +267,7 @@ pub fn migrate_legacy() { 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)] @@ -355,7 +276,7 @@ mod tests { #[test] 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 d = Playback::default(); assert_eq!(c.playback.volume, d.volume); @@ -422,52 +343,8 @@ mod tests { // Roundtrip: render then parse 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(); 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")); - } } diff --git a/src/discord.rs b/src/discord.rs index 1a0af81..367349a 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -2,9 +2,7 @@ use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use discord_rich_presence::activity::{ - Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps, -}; +use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use crate::app::PlayState; @@ -160,7 +158,6 @@ fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool { }; let mut activity = Activity::new() .activity_type(ActivityType::Listening) - .status_display_type(StatusDisplayType::Details) .details(&p.title) .state(&artist) .assets(assets) diff --git a/src/keys.rs b/src/keys.rs deleted file mode 100644 index 50a997a..0000000 --- a/src/keys.rs +++ /dev/null @@ -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 { - match self.code { - KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize), - _ => None, - } - } -} - -impl From 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), -} - -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, Action)>); - -impl Default for Keymap { - fn default() -> Keymap { - Keymap::build(&HashMap::new()).expect("default keys are valid") - } -} - -impl Keymap { - pub fn build(custom: &HashMap) -> Result { - 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, 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, 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 { - 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::() { - 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 { - 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 { - parse_sequence(text).unwrap() - } - - fn run(map: &Keymap, text: &str) -> Option { - 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 = 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()); - } -} diff --git a/src/main.rs b/src/main.rs index b43714a..c3634f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod app; mod command; mod config; mod discord; -mod keys; mod lyrics; mod model; mod mpris; diff --git a/src/player.rs b/src/player.rs index a4ea4b2..8b41a85 100644 --- a/src/player.rs +++ b/src/player.rs @@ -6,8 +6,6 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Result}; use serde_json::{json, Value}; -use crate::config::Audio; - /// Platform-specific transport for mpv's JSON IPC. /// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). #[cfg(unix)] @@ -313,15 +311,6 @@ impl Drop for Player { const KEEP_FILES: usize = 40; -/// Base yt-dlp command with the user's cookie setting applied. -fn ytdlp(audio: &Audio) -> Command { - let mut cmd = Command::new("yt-dlp"); - if let Some(browser) = audio.cookies() { - cmd.arg("--cookies-from-browser").arg(browser); - } - cmd -} - fn audio_dir() -> PathBuf { dirs::cache_dir() .unwrap_or_else(std::env::temp_dir) @@ -337,13 +326,13 @@ pub fn cached_audio(id: &str) -> Option { .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id)) } -pub fn fetch_audio(id: &str, audio: &Audio) -> Result { +pub fn fetch_audio(id: &str) -> Result { if let Some(path) = cached_audio(id) { return Ok(path); } let dir = audio_dir(); std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let mut cmd = ytdlp(audio); + let mut cmd = Command::new("yt-dlp"); cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"]) .arg("-P") .arg(&dir) @@ -368,35 +357,6 @@ pub fn fetch_audio(id: &str, audio: &Audio) -> Result { .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 { - let mut cmd = ytdlp(audio); - cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"]) - .arg(format!("https://music.youtube.com/watch?v={id}")) - .stdin(Stdio::null()); - crate::sys::no_window(&mut cmd); - let out = cmd - .output() - .map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; - let stdout = String::from_utf8_lossy(&out.stdout); - if let Some(url) = stdout - .lines() - .map(str::trim) - .find(|l| l.starts_with("http")) - .filter(|_| out.status.success()) - { - return Ok(url.to_string()); - } - let stderr = String::from_utf8_lossy(&out.stderr); - Err(stderr - .lines() - .rev() - .find_map(|l| l.strip_prefix("ERROR: ")) - .map(ytdl_reason) - .unwrap_or_else(|| "yt-dlp returned no stream URL".into())) -} - fn prune(dir: &std::path::Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; diff --git a/src/ui.rs b/src/ui.rs index 1f27bdf..6894886 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -432,8 +432,8 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { if let Some(c) = app.count { pending.push_str(&c.to_string()); } - for key in &app.pending { - pending.push_str(&key.to_string()); + if let Some(p) = app.pending { + pending.push(p); } if !pending.is_empty() { block = block.title_bottom( @@ -806,8 +806,6 @@ const CMD_INFO: &[(&str, &str)] = &[ ("shuffle", "toggle shuffle"), ("repeat", "repeat · off | all | one"), ("save", "save the queue as a playlist"), - ("savealbum", "save album as a playlist · savealbum [name]"), - ("saveresults", "save results as a playlist · saveresults [name]"), ("load", "load a playlist into the queue"), ("append", "append a playlist to the queue"), ("delpl", "delete a playlist"), @@ -1134,14 +1132,6 @@ const HELP: &[(&str, &str)] = &[ ":save n :load n", "save the queue as a playlist / load one (:save! n replaces an existing one)", ), - ( - ":savealbum [n]", - "save album or current album results as a playlist", - ), - ( - ":saveresults [n]", - "save current results as a playlist", - ), ( ":append n :delpl n", "append a playlist to the queue / delete it", @@ -1291,10 +1281,7 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) { } }) .collect(); - lines.push(Line::styled( - " Enter select · s save playlist · j k move · Esc cancel", - dim(), - )); + lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim())); f.render_widget(Clear, popup); f.render_widget( Paragraph::new(lines).block(