├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE-MIT ├── Makefile ├── README.md ├── build.rs ├── doc ├── tap.1 ├── tap.yml └── tap_screenshot.png ├── src ├── cli │ ├── args.rs │ ├── logger.rs │ ├── mod.rs │ └── player.rs ├── config │ ├── file.rs │ ├── keybinding.rs │ ├── mod.rs │ └── theme.rs ├── finder │ ├── error_view.rs │ ├── finder_view.rs │ ├── fuzzy_dir.rs │ ├── library.rs │ └── mod.rs ├── main.rs └── player │ ├── audio_file.rs │ ├── help_view.rs │ ├── mod.rs │ ├── player_view.rs │ └── playlist.rs └── tests ├── assets ├── test_audio_invalid.mp3 ├── test_audio_no_tags.mp3 ├── test_flac_audio.flac ├── test_m4a_audio.m4a ├── test_mp3_audio.mp3 ├── test_non_audio.txt ├── test_ogg_audio.ogg └── test_wav_audio.wav ├── testenv └── mod.rs └── tests.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # MSVC Windows builds of rustc generate these, which store debugging information 13 | *.pdb 14 | 15 | .vscode/* 16 | !.vscode/settings.json 17 | !.vscode/tasks.json 18 | !.vscode/launch.json 19 | !.vscode/extensions.json 20 | !.vscode/*.code-snippets 21 | 22 | # Local History for Visual Studio Code 23 | .history/ 24 | 25 | # Built Visual Studio Code Extensions 26 | *.vsix 27 | 28 | # A file to log errors. 29 | error_file.txt 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "adps", 4 | "alsa", 5 | "bincode", 6 | "canonicalize", 7 | "cmus", 8 | "consts", 9 | "crossterm", 10 | "Deque", 11 | "dpkg", 12 | "flac", 13 | "fullscreen", 14 | "gapless", 15 | "hline", 16 | "ilog", 17 | "libc", 18 | "lrtb", 19 | "maxdepth", 20 | "rodio", 21 | "scrollbars", 22 | "serde", 23 | "SIGCHLD", 24 | "SIGCONT", 25 | "SIGHUP", 26 | "siginfo", 27 | "SIGINT", 28 | "SIGQUIT", 29 | "SIGTERM", 30 | "SIGTSTP", 31 | "SIGWINCH", 32 | "subdir", 33 | "tempdir", 34 | "tempfile", 35 | "testenv", 36 | "timdubbins", 37 | "vline", 38 | "walkdir" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tap" 3 | version = "0.5.1" 4 | authors = ["Tim Dubbins "] 5 | description = "An audio player for the terminal with fuzzy-finder" 6 | documentation = "https://github.com/timdubbins/tap" 7 | homepage = "https://github.com/timdubbins/tap" 8 | repository = "https://github.com/timdubbins/tap" 9 | keywords = [ 10 | "audio", 11 | "player", 12 | "fuzzy", 13 | "finder", 14 | "rust", 15 | "cli", 16 | "tui", 17 | "music", 18 | "terminal", 19 | ] 20 | categories = ["command-line-utilities"] 21 | license = "Unlicense OR MIT" 22 | edition = "2021" 23 | 24 | [[bin]] 25 | bench = false 26 | path = "src/main.rs" 27 | name = "tap" 28 | 29 | [dev-dependencies] 30 | tempfile = "3.6" 31 | 32 | [dependencies] 33 | anyhow = "1.0.95" 34 | bincode = "2.0.0-rc.3" 35 | clap = { version = "4.1.8", features = ["derive"] } 36 | colored = "2" 37 | cursive = { git = "https://github.com/timdubbins/cursive", branch = "tap", features = [ 38 | "ncurses-backend", 39 | "toml", 40 | ] } 41 | expiring_bool = { git = "https://github.com/timdubbins/expiring_bool" } 42 | fuzzy-matcher = "0.3.7" 43 | lofty = "0.22.2" 44 | once_cell = "1.20.3" 45 | rand = { version = "0.8.5", features = ["small_rng"] } 46 | rayon = "1.6" 47 | regex = "1.11.1" 48 | reqwest = { version = "0.11", features = ["blocking", "json"] } 49 | rodio = { git = "https://github.com/timdubbins/rodio", branch = "seek", features = [ 50 | "symphonia-aac", 51 | "symphonia-flac", 52 | "symphonia-mp3", 53 | "symphonia-isomp4", 54 | "symphonia-wav", 55 | "vorbis", 56 | ], default-features = false } 57 | serde = { version = "1.0.217", features = ["derive"] } 58 | serde_yaml = "0.9.34" 59 | unicode-segmentation = "1.10.1" 60 | unicode-width = "0.1.5" 61 | walkdir = "2.0" 62 | 63 | [features] 64 | run_tests = [] 65 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Tim Dubbins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | output: target/release/tap 2 | 3 | .PHONY: install clean man uninstall 4 | 5 | VERSION := $(shell git tag | tail -n 1 | tr -d v) 6 | 7 | # Allow users to specify an installation prefix (default: /usr/local) 8 | PREFIX ?= /usr/local 9 | INSTALL_DIR = $(PREFIX)/bin 10 | MAN_DIR = $(PREFIX)/share/man 11 | 12 | CACHE_FILE = $(HOME)/.cache/tap/data 13 | 14 | # Ensure XDG_CONFIG_HOME is set, or default to ~/.config 15 | XDG_CONFIG_HOME ?= $(HOME)/.config 16 | 17 | CONFIG_FILES := \ 18 | $(XDG_CONFIG_HOME)/tap/tap.yml \ 19 | $(XDG_CONFIG_HOME)/tap.yml \ 20 | $(HOME)/.config/tap/tap.yml \ 21 | $(HOME)/.tap.yml 22 | 23 | target/release/tap: src 24 | @cargo build --release 25 | 26 | install: $(INSTALL_DIR)/tap man 27 | $(INSTALL_DIR)/tap: target/release/tap 28 | @mkdir -p $(INSTALL_DIR) 29 | @install -m755 target/release/tap $(INSTALL_DIR)/tap 30 | 31 | man: $(MAN_DIR)/man1 32 | $(MAN_DIR)/man1: doc/tap.1 33 | @mkdir -p $(MAN_DIR)/man1 34 | @install -m644 doc/tap.1 $(MAN_DIR)/man1/tap.1 35 | 36 | clean: 37 | @cargo clean 38 | 39 | uninstall: 40 | @echo "Removing tap binary..." 41 | @if [ -f "$(INSTALL_DIR)/tap" ]; then $(RM) "$(INSTALL_DIR)/tap"; fi 42 | 43 | @echo "Removing man page..." 44 | @if [ -f "$(MAN_DIR)/man1/tap.1" ]; then $(RM) "$(MAN_DIR)/man1/tap.1"; fi 45 | 46 | @echo "Removing cache file..." 47 | @if [ -f "$(CACHE_FILE)" ]; then $(RM) "$(CACHE_FILE)"; fi 48 | 49 | @echo "Removing configuration files..." 50 | @for config in $(CONFIG_FILES); do \ 51 | if [ -f "$$config" ]; then \ 52 | echo "Removing $$config"; \ 53 | $(RM) "$$config"; \ 54 | fi \ 55 | done 56 | 57 | @echo "Uninstall complete." 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tap 2 | 3 | tap is a TUI audio player with fuzzy-finder. Quickly navigate to any album in your library! 4 | 5 | **Quick links:** [Options](#options), [Controls](#controls), [Configuration](#configuration), [Installation](#installation). 6 | 7 | 8 | 9 | 10 | ## Usage 11 | 12 | ```bash 13 | > tap [options] [path] 14 | ``` 15 | 16 | ### Fuzzy-Finder 17 | 18 | Run `tap` in a directory containing music folders to launch the fuzzy-finder: 19 | ```bash 20 | > cd ~/path/to/my_music 21 | > tap 22 | ``` 23 | Playback will begin on selection and you can return to the fuzzy-finder by pressing `Tab`. 24 | 25 | 26 | 27 | 28 | If no `path` is specified, `tap` defaults to the current working directory. 29 | 30 | ### Direct Playback 31 | 32 | To skip the fuzzy-finder and directly open an audio file or album: 33 | 34 | ```bash 35 | > tap ~/path/to/my_album 36 | ``` 37 | 38 | 39 | ## Options 40 | Option | Description 41 | --- |--- 42 | `-d` `--default` | Run from the default directory, if set. 43 | `-p` `--print` | Print the path of the default directory, if set. 44 | `-s` `--set` | Set a default directory. Requires a `tap.yml` config file. 45 | `-b` `--term-bg` | Use the terminal background color. 46 | `-t` `--term-color` | Use the terminal background and foreground colors only. 47 | `-c` `--default-color` | Ignore any user-defined colors. 48 | `--color ` | Set your own color scheme. See [here](#colors) for available names. 49 | `--cli` | Play audio in CLI-mode (without the TUI). 50 | 51 | 52 | ## Controls 53 | 54 |
55 | Keyboard 56 |
57 | 58 | Global | Binding 59 | --- |--- 60 | previous album | `-` 61 | random album | `=` 62 | fuzzy search | `Tab` 63 | artist search | `Ctrl + a` 64 | artist search (a-z) | `A-Z` 65 | album search | `Ctrl + d` 66 | depth search (1-4) | `F1-F4` 67 | parent search | `` ` `` 68 | open file manager | `Ctrl + o` 69 | quit | `Ctrl + q` 70 | 71 | **Note:** Search results are shuffled by default. Sort with `Ctrl` + `s`. 72 | 73 | 74 | 75 | Player | Binding 76 | --- |--- 77 | play or pause | `h` or or `Space` 78 | next | `j` or `n` or 79 | previous | `k` or `p` or 80 | stop | `l` or or `Ctrl + j` or `Enter` 81 | randomize | `*` or `r` (next track is random from library) 82 | shuffle | `~` or `s` (current playlist order is shuffled) 83 | seek << / >> | `,` / `.` 84 | seek to second | `0-9`, `"` 85 | seek to minute | `0-9`, `'` 86 | volume down / up | `[` / `]` 87 | toggle volume display | `v` 88 | toggle mute | `m` 89 | go to first track | `gg` 90 | go to last track | `Ctrl + g` 91 | go to track number | `0-9`, `g` 92 | show keybindings | `?` 93 | quit | `q` 94 | 95 | Finder | Binding 96 | --- |--- 97 | select | `Ctrl + j` or `Enter` 98 | next | `Ctrl + n` or 99 | previous | `Ctrl + p` or 100 | sort results | `Ctrl + s` 101 | cursor right | `Ctrl + f` or 102 | cursor left | `Ctrl + b` or 103 | cursor home | `Home` 104 | cursor end | `End` 105 | clear query | `Ctrl + u` 106 | cancel search | `Esc` 107 | page up | `PageUp` 108 | page down | `PageDown` 109 | 110 |
111 | 112 |
113 | Mouse 114 |
115 | 116 | Global | Binding 117 | --- |--- 118 | fuzzy search | `Middle Button` 119 | 120 | Player | Binding 121 | --- |--- 122 | play or pause | `Left Click` (in window) 123 | select track | `Left Click` (on track) 124 | seek | `Left Hold` (on slider) 125 | volume | `Scroll` (in window) 126 | next / previous | `Scroll` (over tracks) 127 | stop | `Right Click` (anywhere) 128 | 129 | Finder | Binding 130 | --- |--- 131 | cancel search | `Right Click` 132 | scroll | `Scroll` 133 | select | `Left Click` 134 | 135 |
136 | 137 | 138 | ## Configuration 139 | 140 | 141 | tap doesn't create the config file for you, but it looks for one in the following locations: 142 | 143 | - $XDG_CONFIG_HOME/tap/tap.yml 144 | - $XDG_CONFIG_HOME/tap.yml 145 | - $HOME/.config/tap/tap.yml 146 | - $HOME/.tap.yml 147 | 148 | A example config file can be found [here](https://github.com/timdubbins/tap/blob/master/doc/tap.yml). 149 | 150 | ### Colors 151 | 152 | Colors can be set in the config file or using the ```--color``` command. 153 | 154 | The following example will set a [Solarized](https://ethanschoonover.com/solarized/) theme: 155 | ``` 156 | --color fg=268bd2,bg=002b36,hl=fdf6e3,prompt=586e75,header_1=859900,header_2=cb4b16,progress=6c71c4,info=2aa198,err=dc322f 157 | ``` 158 | 159 | 160 | 161 | ### Default Path 162 | 163 | The default path can be set in the config file. This allows you to load the default directory with the `-d --default` command and also provides faster load times by caching. 164 | 165 | When setting a default path tap will write a small amount of encoded data to `~/.cache/tap`. This is guaranteed to be at least as small as the in-memory data and will be updated everytime the default path is accessed. Using the `-s --set` command will update the `path` field in the `tap.yml` config file. 166 | 167 | Without setting a default path tap is `read-only`. 168 | 169 | 170 | ## Installation 171 | You will need an `ncurses` distribution (with development headers) to compile tap. Installation instructions for each supported platform are below: 172 | 173 | 174 |
175 | macOS 176 |
177 | 178 | You can install with [Homebrew](https://brew.sh/) 179 | 180 | ```bash 181 | > brew install timdubbins/tap/tap 182 | > tap --version 183 | 0.5.1 184 | ``` 185 | 186 | `ncurses` can be installed with: 187 | ```bash 188 | > brew install ncurses 189 | ``` 190 | 191 | 192 |
193 | 194 | 195 |
196 | Arch Linux 197 |
198 | 199 | ~~You can install with an AUR [helper](https://wiki.archlinux.org/title/AUR_helpers), 200 | such as [yay](https://github.com/Jguer/yay)~~ 201 | 202 | **The Arch package is not currently maintained. Please install with Rust.** 203 | 204 | ```bash 205 | > yay -S tap 206 | > tap --version 207 | 0.5.1 208 | ``` 209 | 210 | `ncurses` can be installed with: 211 | ```bash 212 | > yay -S ncurses 213 | ``` 214 | 215 | The AUR package is available [here]("https://aur.archlinux.org/packages/tap). 216 |
217 |
218 | 219 | 220 |
221 | Debian (or a Debian derivative, such as Ubuntu) 222 |
223 | 224 | You can install with a binary `.deb` file provided in each tap [release](https://github.com/timdubbins/tap/releases/tag/v0.5.1). 225 | 226 | ```bash 227 | > curl -LO https://github.com/timdubbins/tap/releases/download/v0.5.1/tap_0.5.1.deb 228 | > sudo dpkg -i tap_0.5.1.deb 229 | > tap --version 230 | 0.5.1 231 | ``` 232 | 233 | `ncurses` can be installed with: 234 | ```bash 235 | > sudo apt install libncurses5-dev libncursesw5-dev 236 | ``` 237 | 238 |
239 | 240 |
241 | Rust 242 |
243 | 244 | To compile from source, first you need a [Rust](https://www.rust-lang.org/learn/get-started) installation (if you don't have one) and then you can use [cargo](https://github.com/rust-lang/cargo): 245 | 246 | ```bash 247 | > git clone https://github.com/timdubbins/tap 248 | > cd tap 249 | > cargo install --path . 250 | > tap --version 251 | 0.5.1 252 | ``` 253 | 254 |
255 | 256 | The binaries for each release are also available [here](https://github.com/timdubbins/tap/releases/tag/v0.5.1). 257 | 258 | 259 | ## Notes 260 | 261 | **Supports:** 262 | - Gapless playback. 263 | - `aac`, `flac`, `mp3`, `m4a`, `ogg` and `wav`. 264 | 265 | ## Contributing 266 | 267 | Suggestions / bug reports are welcome! 268 | 269 | ### Inspired by 270 | 271 | - [cmus](https://github.com/cmus/cmus) - popular console music player with many features 272 | - [fzf](https://github.com/junegunn/fzf) - command line fuzzy finder 273 | 274 | ### Made possible by 275 | 276 | - [cursive](https://github.com/gyscos/cursive) - TUI library for Rust with great documentation 277 | - [rodio](https://github.com/RustAudio/rodio) - audio playback library for Rust 278 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | if cfg!(target_os = "macos") { 5 | let output = Command::new("brew") 6 | .args(&["--prefix", "ncurses"]) 7 | .output() 8 | .expect("Failed to run `brew --prefix ncurses`. Make sure Homebrew is installed."); 9 | 10 | if !output.status.success() { 11 | panic!("Failed to locate Homebrew ncurses. Please ensure ncurses is installed via Homebrew."); 12 | } 13 | 14 | let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); 15 | 16 | println!("cargo:rustc-link-search=native={}/lib", prefix); 17 | println!("cargo:rustc-link-lib=dylib=ncurses"); 18 | println!("cargo:include={}/include", prefix); 19 | } else { 20 | println!("cargo:rustc-link-lib=ncurses"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /doc/tap.1: -------------------------------------------------------------------------------- 1 | .TH TAP 1 "February 2025" "tap 1.0" "User Commands" 2 | .SH NAME 3 | tap - TUI audio player 4 | .SH SYNOPSIS 5 | .B tap 6 | [OPTIONS] [PATH] 7 | .SH DESCRIPTION 8 | .B tap 9 | is a terminal-based audio player with a fuzzy-finder. It allows playing audio files from a specified path and provides options to configure color schemes and set default directories. 10 | .SH OPTIONS 11 | .TP 12 | .I path 13 | The path to play or search on. 14 | .TP 15 | .B -s, --set 16 | Set a default directory using the provided path. 17 | .TP 18 | .B -d, --default 19 | Run tap with the default directory, if set. 20 | .TP 21 | .B -p, --print 22 | Print the default directory, if set. 23 | .TP 24 | .B -b, --term_bg 25 | Use the terminal background color. 26 | .TP 27 | .B -t, --term_color 28 | Use the terminal foreground and background colors only. 29 | .TP 30 | .B \-\-color 31 | Set the color scheme using key-value pairs. 32 | .RS 33 | Available keys: 34 | .IR fg ", " bg ", " hl ", " prompt ", " header_1 ", " header_2 ", " progress ", " info ", " err . 35 | .br 36 | Accepted values: hexadecimal (e.g., "#586e75", "859900") or color names (e.g., "red"). 37 | .br 38 | Example: 39 | .EX 40 | \-\-color fg=red,bg=002b36 41 | .EE 42 | .RE 43 | .TP 44 | .B --cli 45 | Run an audio player in the terminal without the TUI. 46 | .TP 47 | .B -v, --version 48 | Print the current version. 49 | .SH AUTHOR 50 | Tim Dubbins 51 | .SH VERSION 52 | .B tap 53 | version 0.5.0 54 | .SH SEE ALSO 55 | .B man(1), sox(1) 56 | .SH BUGS 57 | Bugs can be reported on Github: https://github.com/timdubbins/tap/issues 58 | -------------------------------------------------------------------------------- /doc/tap.yml: -------------------------------------------------------------------------------- 1 | # Set a default path. For use with [-d, --default] flag. 2 | # path: "~/path/to/my_music" 3 | 4 | # Set the colors. 5 | # Values can be hexadecimal ("#81a1be", "1f211d") or named ("red", "light green", etc). 6 | # 7 | # color: 8 | # fg: "#81a1be" 9 | # bg: "#1f211d" 10 | # hl: "#c5c8c6" 11 | # prompt: "#39363e" 12 | # header_1: "#b5bd68" 13 | # header_2: "#f0c674" 14 | # progress: "#b294bb" 15 | # info: "#8abeb7" 16 | # err: "#cc6666" 17 | 18 | # Use the terminal background color. 19 | # term_bg: false 20 | 21 | # Use both the terminal background and foreground colors. 22 | # term_color: false 23 | 24 | # Use the default "tap" colors. 25 | # default_color: false 26 | 27 | # Set keybindings for the player. 28 | # Available Keys: "a"..="z", symbols, "Ctrl+[char]", "Up", "Down", "Left", 29 | # "Right", "Enter", "Esc", "Backspace", "Delete", "Insert", "Home", "End", 30 | # "PageUp", "PageDown", "Tab", "Space". 31 | # 32 | # keybindings: 33 | # play_or_pause: ["h", "Space", "Left"] 34 | # stop: ["l", "Enter", "Right"] 35 | # next: ["j", "n", "Down"] 36 | # previous: ["k", "p", "Up"] 37 | # increase_volume: ["]"] 38 | # decrease_volume: ["["] 39 | # toggle_mute: ["m"] 40 | # toggle_volume_display: ["v"] 41 | # seek_to_min: ["'"] 42 | # seek_to_sec: ["""] 43 | # seek_forward: ["."] 44 | # seek_backward: [","] 45 | # toggle_randomize: ["*", "r"] 46 | # toggle_shuffle: ["~", "s"] 47 | # play_track_number: ["g"] 48 | # play_last_track: ["Ctrl+g"] 49 | # show_help: ["?"] 50 | # quit: ["q"] 51 | -------------------------------------------------------------------------------- /doc/tap_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/doc/tap_screenshot.png -------------------------------------------------------------------------------- /src/cli/args.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use {anyhow::bail, clap::Parser}; 4 | 5 | use crate::TapError; 6 | 7 | // TODO - update README 8 | 9 | // A struct that represents the command line arguments. 10 | #[derive(Debug, Parser)] 11 | #[command( 12 | author = "Tim Dubbins", 13 | about = "An audio player for the terminal with fuzzy-finder", 14 | version = crate::config::VERSION, 15 | )] 16 | pub struct Args { 17 | /// The path to play or search on. 18 | #[arg(index = 1)] 19 | pub path: Option, 20 | 21 | /// Set a default directory using the provided path 22 | #[arg(short = 's', long = "set")] 23 | pub set_default_path: bool, 24 | 25 | /// Run tap with the default directory, if set 26 | #[arg(short = 'd', long = "default")] 27 | pub use_default_path: bool, 28 | 29 | /// Print the default directory, if set 30 | #[arg(short = 'p', long = "print")] 31 | pub print_default_path: bool, 32 | 33 | /// Use the terminal background color 34 | #[arg(short = 'b', long = "term_bg")] 35 | pub term_bg: bool, 36 | 37 | /// Use the terminal foreground and background colors only 38 | #[arg(short = 't', long = "term_color")] 39 | pub term_color: bool, 40 | 41 | /// Use the default color scheme 42 | #[arg(short = 'c', long = "default_color")] 43 | pub default_color: bool, 44 | 45 | /// Set the color scheme with = 46 | /// For example: 47 | ///'--color fg=268bd2,bg=002b36,hl=fdf6e3,prompt=586e75,header_1=859900,header_2=cb4b16,progress=6c71c4,info=2aa198,err=dc322f' 48 | #[arg(long = "color", verbatim_doc_comment)] 49 | pub color: Option, 50 | 51 | /// Run an audio player in the terminal without the TUI 52 | #[arg(long = "cli")] 53 | pub use_cli_player: bool, 54 | 55 | /// Print the current version 56 | #[arg(short = 'v', long = "version")] 57 | pub check_version: bool, 58 | } 59 | 60 | impl Args { 61 | pub fn parse_args() -> Result { 62 | let args = Self::try_parse()?; 63 | args.validate()?; 64 | 65 | Ok(args) 66 | } 67 | 68 | fn validate(&self) -> Result<(), TapError> { 69 | if self.print_default_path && self.path.is_some() { 70 | bail!("'--print' cannot be used with a 'path' argument"); 71 | } 72 | 73 | if self.use_cli_player && self.path.is_none() { 74 | bail!("'--cli' requires a 'path' argument"); 75 | } 76 | 77 | if self.set_default_path && self.path.is_none() { 78 | bail!("'--set' requires a 'path' argument"); 79 | } 80 | 81 | if self.use_cli_player && self.print_default_path { 82 | bail!("'--cli' cannot be used with '--print'") 83 | } 84 | 85 | if self.use_cli_player && self.set_default_path { 86 | bail!("'--cli' cannot be used with '--set'") 87 | } 88 | 89 | if self.print_default_path && self.set_default_path { 90 | bail!("'--print' cannot be used with '--set'") 91 | } 92 | 93 | Ok(()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/cli/logger.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, Write}, 3 | sync::mpsc::{self, Sender}, 4 | thread, 5 | time::Duration, 6 | }; 7 | 8 | // A struct used for logging progress to the console with an animated ellipsis. 9 | pub struct Logger { 10 | tx: Sender<()>, 11 | msg: &'static str, 12 | } 13 | 14 | impl Logger { 15 | pub fn start(msg: &'static str) -> Self { 16 | let (tx, rx) = mpsc::channel(); 17 | let mut ellipses = vec![" ", ". ", ".. ", "..."].into_iter().cycle(); 18 | 19 | thread::spawn(move || loop { 20 | match rx.try_recv() { 21 | Ok(_) | Err(mpsc::TryRecvError::Disconnected) => { 22 | break; 23 | } 24 | Err(mpsc::TryRecvError::Empty) => { 25 | print!("\r[tap]: {}{} ", msg, ellipses.next().unwrap()); 26 | io::stdout().flush().unwrap_or_default(); 27 | thread::sleep(Duration::from_millis(300)); 28 | } 29 | } 30 | }); 31 | 32 | Logger { tx, msg } 33 | } 34 | 35 | pub fn stop(&self) { 36 | let _ = self.tx.send(()); 37 | println!("\r[tap]: {}...", self.msg); 38 | println!("[tap]: done!"); 39 | } 40 | } 41 | 42 | impl Drop for Logger { 43 | fn drop(&mut self) { 44 | let _ = self.tx.send(()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod args; 2 | pub mod logger; 3 | pub mod player; 4 | 5 | use std::path::PathBuf; 6 | 7 | use anyhow::anyhow; 8 | use reqwest::blocking::Client; 9 | use serde::Deserialize; 10 | 11 | use crate::{config::FileConfig, finder::Library, TapError}; 12 | 13 | pub use self::{args::Args, logger::Logger}; 14 | 15 | const REPO_URL: &str = "https://api.github.com/repos/timdubbins/tap/releases/latest"; 16 | 17 | pub struct Cli {} 18 | 19 | impl Cli { 20 | pub fn set_cache(search_root: &PathBuf) -> Result<(), TapError> { 21 | FileConfig::find()?; 22 | let logger = Logger::start("setting default"); 23 | let library = Library::new(search_root); 24 | library.serialize()?; 25 | FileConfig::update_path(search_root)?; 26 | logger.stop(); 27 | Ok(()) 28 | } 29 | 30 | pub fn print_cache() -> Result<(), TapError> { 31 | let file_config = FileConfig::deserialize()?; 32 | 33 | let path = file_config 34 | .path 35 | .ok_or_else(|| anyhow!("Path not set in config file!"))?; 36 | 37 | println!("[tap]: default path: {:?}", path); 38 | Ok(()) 39 | } 40 | 41 | pub fn check_version() -> Result<(), TapError> { 42 | let prefix = "[tap]:"; 43 | 44 | match Self::fetch_latest_version() { 45 | Ok(latest_version) if crate::config::VERSION == latest_version => { 46 | println!( 47 | "{} You're using the latest version: {}", 48 | prefix, 49 | crate::config::VERSION 50 | ); 51 | } 52 | Ok(latest_version) => { 53 | println!( 54 | "{} You're using version: {}. A new version is available: {}", 55 | prefix, 56 | crate::config::VERSION, 57 | latest_version 58 | ); 59 | } 60 | Err(_) => { 61 | println!( 62 | "{} You're using version: {}", 63 | prefix, 64 | crate::config::VERSION 65 | ); 66 | } 67 | } 68 | 69 | Ok(()) 70 | } 71 | 72 | fn fetch_latest_version() -> Result { 73 | #[derive(Deserialize)] 74 | struct GitHubRelease { 75 | tag_name: String, 76 | } 77 | 78 | let client = Client::builder().user_agent("tap").build()?; 79 | let response = client.get(REPO_URL).send()?.json::()?; 80 | 81 | let version = response 82 | .tag_name 83 | .strip_prefix('v') 84 | .unwrap_or(&response.tag_name) 85 | .to_string(); 86 | 87 | Ok(version) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/cli/player.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, stdout, BufRead, Write}, 3 | path::PathBuf, 4 | sync::mpsc::{self, Receiver, TryRecvError}, 5 | thread::{self}, 6 | time::Duration, 7 | }; 8 | 9 | use colored::Colorize; 10 | 11 | use crate::{ 12 | player::{Player, Playlist}, 13 | TapError, 14 | }; 15 | 16 | const TICK: Duration = Duration::from_millis(100); 17 | 18 | // A CLI wrapper for the `Player` struct, responsible for managing the 19 | // audio playback and displaying the current status to the terminal. 20 | pub struct CliPlayer { 21 | player: Player, 22 | } 23 | 24 | impl CliPlayer { 25 | // Runs an audio player in the command line without the TUI. 26 | pub fn try_run(search_root: &PathBuf) -> Result<(), TapError> { 27 | use crate::finder::Library; 28 | 29 | let playlist = Library::first(search_root).first_playlist()?; 30 | let mut cli_player = CliPlayer::try_new(playlist)?; 31 | cli_player.start() 32 | } 33 | 34 | fn try_new(playlist: Playlist) -> Result { 35 | let player = Player::try_new(playlist)?; 36 | 37 | player 38 | .current 39 | .audio_files 40 | .iter() 41 | .skip(1) 42 | .take(100) 43 | .filter_map(|file| file.decode().ok()) 44 | .for_each(|source| player.sink.append(source)); 45 | 46 | Ok(Self { player }) 47 | } 48 | 49 | fn start(&mut self) -> Result<(), TapError> { 50 | self.update_display()?; 51 | let rx = Self::setup_input_thread(); 52 | let mut len = self.player.sink.len(); 53 | 54 | loop { 55 | let current_len = self.player.sink.len(); 56 | 57 | if Self::should_exit(&rx, current_len) { 58 | return Ok(()); 59 | } 60 | 61 | if current_len < len { 62 | len = current_len; 63 | self.player.current.index += 1; 64 | self.update_display()?; 65 | } 66 | 67 | thread::sleep(TICK); 68 | } 69 | } 70 | 71 | fn setup_input_thread() -> Receiver { 72 | let (tx, rx) = mpsc::channel(); 73 | 74 | thread::spawn(move || { 75 | let stdin = io::stdin(); 76 | let handle = stdin.lock(); 77 | 78 | for line in handle.lines() { 79 | let line = line.expect("Failed to read line"); 80 | if line.trim().is_empty() { 81 | tx.send(true).expect("Failed to send quit signal"); 82 | break; 83 | } 84 | } 85 | }); 86 | 87 | rx 88 | } 89 | 90 | fn should_exit(rx: &Receiver, len: usize) -> bool { 91 | len == 0 || rx.try_recv() != Err(TryRecvError::Empty) 92 | } 93 | 94 | fn update_display(&mut self) -> Result<(), TapError> { 95 | let file = self.player.current_file(); 96 | let tap_prefix = "[tap player]:".green().bold(); 97 | 98 | let player_info = format!( 99 | "{} '{}' by '{}' ({}/{}) ", 100 | tap_prefix, 101 | file.title, 102 | file.artist, 103 | self.player.current.index + 1, 104 | self.player.current.audio_files.len() 105 | ); 106 | 107 | print!("\r\x1b[2K{}", player_info); // \x1b[2K clears the entire line 108 | stdout().flush()?; 109 | 110 | Ok(()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/config/file.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | env, 4 | fs::{self, OpenOptions}, 5 | io::{self, Write}, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use {anyhow::bail, regex::Captures, regex::Regex, serde::Deserialize}; 10 | 11 | use crate::TapError; 12 | 13 | // A struct that represents our `tap.yml` config file. 14 | #[derive(Default, Deserialize)] 15 | pub struct FileConfig { 16 | pub path: Option, 17 | pub color: Option>, 18 | pub term_bg: Option, 19 | pub term_color: Option, 20 | pub default_color: Option, 21 | } 22 | 23 | #[derive(Debug, Default, Deserialize)] 24 | struct KeybindingOnly { 25 | keybinding: Option>>, 26 | } 27 | 28 | impl FileConfig { 29 | pub fn update_path(path: &PathBuf) -> Result<(), TapError> { 30 | let file_path = match Self::find() { 31 | Ok(p) => p, 32 | Err(_) => return Ok(()), 33 | }; 34 | 35 | let config_content = match fs::read_to_string(&file_path) { 36 | Ok(content) => content, 37 | Err(_) => return Ok(()), 38 | }; 39 | 40 | // Regex that matches: 41 | // - the prefix (whitespace, optional '#' and "path:" plus following whitespace) 42 | // - then either a double-quoted string or a single-quoted string. 43 | let re = Regex::new( 44 | r#"(?m)^(?P\s*#?\s*path:\s*)(?:"(?P[^"]*)"|'(?P[^']*)')"#, 45 | )?; 46 | 47 | let updated_content = if re.is_match(&config_content) { 48 | re.replace_all(&config_content, |caps: &Captures| { 49 | let standardized_prefix = "path: "; 50 | let quote = if caps.name("value_d").is_some() { 51 | "\"" 52 | } else { 53 | "'" 54 | }; 55 | format!( 56 | "{}{}{}{}", 57 | standardized_prefix, 58 | quote, 59 | path.display(), 60 | quote 61 | ) 62 | }) 63 | .to_string() 64 | } else { 65 | // If no match is found, append the new path line at the end. 66 | let mut new_content = config_content.clone(); 67 | new_content.push('\n'); 68 | new_content.push_str(&format!("path: \"{}\"", path.display())); 69 | new_content 70 | }; 71 | 72 | let mut file = OpenOptions::new() 73 | .write(true) 74 | .truncate(true) 75 | .open(file_path)?; 76 | file.write_all(updated_content.as_bytes())?; 77 | 78 | Ok(()) 79 | } 80 | 81 | pub fn find() -> Result { 82 | let mut paths = vec![]; 83 | 84 | if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { 85 | let xdg_config_home = PathBuf::from(xdg_config_home); 86 | paths.push(xdg_config_home.join("tap").join("tap.yml")); 87 | paths.push(xdg_config_home.join("tap.yml")); 88 | } 89 | 90 | if let Ok(home_dir) = env::var("HOME") { 91 | let home_dir = PathBuf::from(home_dir); 92 | paths.push(home_dir.join(".config").join("tap").join("tap.yml")); 93 | paths.push(home_dir.join(".tap.yml")); 94 | } 95 | 96 | for path in paths { 97 | if path.exists() { 98 | return Ok(path); 99 | } 100 | } 101 | 102 | bail!("Config file not found!") 103 | } 104 | 105 | pub fn deserialize() -> Result { 106 | let config_path = FileConfig::find()?; 107 | let mut file = fs::File::open(config_path)?; 108 | let mut contents = String::new(); 109 | io::Read::read_to_string(&mut file, &mut contents)?; 110 | let file_config = serde_yaml::from_str(&contents)?; 111 | 112 | Ok(file_config) 113 | } 114 | 115 | pub fn load_keybindings_only() -> Result>, TapError> { 116 | let config_path = FileConfig::find()?; 117 | let mut file = fs::File::open(config_path)?; 118 | let mut contents = String::new(); 119 | io::Read::read_to_string(&mut file, &mut contents)?; 120 | let res: KeybindingOnly = serde_yaml::from_str(&contents)?; 121 | 122 | res.keybinding 123 | .ok_or(anyhow::anyhow!("Failed to load keybindings from config")) 124 | } 125 | 126 | pub fn expanded_path(&self) -> Option { 127 | self.path.as_ref().map(|p| { 128 | let path_str = p.to_string_lossy(); 129 | if path_str.starts_with("~/") { 130 | if let Ok(home) = env::var("HOME") { 131 | return Path::new(&home).join(&path_str[2..]); 132 | } 133 | } 134 | p.clone() 135 | }) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/config/keybinding.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::bail; 4 | 5 | use crate::TapError; 6 | 7 | use super::FileConfig; 8 | 9 | use { 10 | cursive::event::{Event, Key}, 11 | once_cell::sync::Lazy, 12 | }; 13 | 14 | pub struct Keybinding {} 15 | 16 | impl Keybinding { 17 | fn parse(key: &str) -> Option { 18 | match key { 19 | "Up" => Some(Event::Key(Key::Up)), 20 | "Down" => Some(Event::Key(Key::Down)), 21 | "Left" => Some(Event::Key(Key::Left)), 22 | "Right" => Some(Event::Key(Key::Right)), 23 | "Enter" => Some(Event::Key(Key::Enter)), 24 | "Esc" => Some(Event::Key(Key::Esc)), 25 | "Backspace" => Some(Event::Key(Key::Backspace)), 26 | "Delete" => Some(Event::Key(Key::Del)), 27 | "Insert" => Some(Event::Key(Key::Ins)), 28 | "Home" => Some(Event::Key(Key::Home)), 29 | "End" => Some(Event::Key(Key::End)), 30 | "PageUp" => Some(Event::Key(Key::PageUp)), 31 | "PageDown" => Some(Event::Key(Key::PageDown)), 32 | "Tab" => Some(Event::Key(Key::Tab)), 33 | "Space" => Some(Event::Char(' ')), 34 | 35 | // Ctrl + [a..=z] 36 | _ if key.starts_with("Ctrl+") && key.len() == 6 => { 37 | key.chars().nth(5).map(Event::CtrlChar) 38 | } 39 | 40 | // All symbols and lowercased letters a..=z 41 | _ if key.len() == 1 42 | && (key.chars().next().unwrap().is_ascii_lowercase() 43 | || key.chars().next().unwrap().is_ascii_punctuation()) => 44 | { 45 | key.chars().next().map(Event::Char) 46 | } 47 | 48 | _ => None, 49 | } 50 | } 51 | 52 | fn default() -> HashMap> { 53 | use Action::*; 54 | 55 | let mut m = HashMap::new(); 56 | // Player Actions: 57 | m.insert( 58 | PlayOrPause, 59 | vec![Event::Char('h'), Event::Char(' '), Event::Key(Key::Left)], 60 | ); 61 | m.insert( 62 | Stop, 63 | vec![ 64 | Event::Char('l'), 65 | Event::CtrlChar('j'), 66 | Event::Key(Key::Enter), 67 | Event::Key(Key::Right), 68 | ], 69 | ); 70 | m.insert( 71 | Next, 72 | vec![Event::Char('j'), Event::Char('n'), Event::Key(Key::Down)], 73 | ); 74 | m.insert( 75 | Previous, 76 | vec![Event::Char('k'), Event::Char('p'), Event::Key(Key::Up)], 77 | ); 78 | m.insert(IncreaseVolume, vec![Event::Char(']')]); 79 | m.insert(DecreaseVolume, vec![Event::Char('[')]); 80 | m.insert(ToggleMute, vec![Event::Char('m')]); 81 | m.insert(ToggleShowingVolume, vec![Event::Char('v')]); 82 | m.insert(SeekToMin, vec![Event::Char('\'')]); 83 | m.insert(SeekToSec, vec![Event::Char('"')]); 84 | m.insert(SeekForward, vec![Event::Char('.'), Event::CtrlChar('l')]); 85 | m.insert(SeekBackward, vec![Event::Char(','), Event::CtrlChar('h')]); 86 | m.insert(ToggleRandomize, vec![Event::Char('*'), Event::Char('r')]); 87 | m.insert(ToggleShuffle, vec![Event::Char('~'), Event::Char('s')]); 88 | m.insert(PlayTrackNumber, vec![Event::Char('g')]); 89 | m.insert(PlayLastTrack, vec![Event::CtrlChar('g'), Event::Char('e')]); 90 | m.insert(ShowHelp, vec![Event::Char('?')]); 91 | m.insert(Quit, vec![Event::Char('q')]); 92 | 93 | // Finder Actions: 94 | // m.insert(Select, vec![Event::Key(Key::Enter), Event::CtrlChar('l')]); 95 | // m.insert(Cancel, vec![Event::Key(Key::Esc)]); 96 | // m.insert(MoveDown, vec![Event::Key(Key::Down), Event::CtrlChar('n')]); 97 | // m.insert(MoveUp, vec![Event::Key(Key::Up), Event::CtrlChar('p')]); 98 | // m.insert(PageUp, vec![Event::Key(Key::PageUp), Event::CtrlChar('b')]); 99 | // m.insert( 100 | // PageDown, 101 | // vec![Event::Key(Key::PageDown), Event::CtrlChar('f')], 102 | // ); 103 | // m.insert(Backspace, vec![Event::Key(Key::Backspace)]); 104 | // m.insert(Delete, vec![Event::Key(Key::Del)]); 105 | // m.insert(CursorLeft, vec![Event::Key(Key::Left)]); 106 | // m.insert(CursorRight, vec![Event::Key(Key::Right)]); 107 | // m.insert(CursorHome, vec![Event::Key(Key::Home)]); 108 | // m.insert(CursorEnd, vec![Event::Key(Key::End)]); 109 | // m.insert(ClearQuery, vec![Event::CtrlChar('u')]); 110 | // Global Actions: 111 | 112 | m 113 | } 114 | } 115 | 116 | pub static PLAYER_EVENT_TO_ACTION: Lazy> = Lazy::new(|| { 117 | let mut merged = Keybinding::default(); 118 | 119 | if let Ok(config) = FileConfig::load_keybindings_only() { 120 | for (action_str, keys) in config.iter() { 121 | if let Ok(action) = Action::from_str(action_str) { 122 | let mut events = Vec::new(); 123 | 124 | for key in keys { 125 | if let Some(event) = Keybinding::parse(key) { 126 | events.push(event); 127 | } 128 | } 129 | 130 | merged.insert(action, events); 131 | } 132 | } 133 | } 134 | 135 | // Reverse mapping: Event → Action, for O(1) lookups. 136 | let mut event_map = HashMap::new(); 137 | for (action, events) in merged { 138 | for event in events { 139 | event_map.insert(event.clone(), action); 140 | } 141 | } 142 | 143 | event_map 144 | }); 145 | 146 | #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] 147 | pub enum Action { 148 | // Play Actions: 149 | PlayOrPause, 150 | Stop, 151 | Next, 152 | Previous, 153 | IncreaseVolume, 154 | DecreaseVolume, 155 | ToggleMute, 156 | ToggleShowingVolume, 157 | SeekToMin, 158 | SeekToSec, 159 | SeekForward, 160 | SeekBackward, 161 | ToggleRandomize, 162 | ToggleShuffle, 163 | PlayTrackNumber, 164 | PlayLastTrack, 165 | ShowHelp, 166 | Quit, 167 | // Finder Actions: 168 | // Select, 169 | // Cancel, 170 | // MoveDown, 171 | // MoveUp, 172 | // PageUp, 173 | // PageDown, 174 | // Backspace, 175 | // Delete, 176 | // CursorLeft, 177 | // CursorRight, 178 | // CursorHome, 179 | // CursorEnd, 180 | // ClearQuery, 181 | // Global Actions: 182 | } 183 | 184 | // Convert action name strings to `Action` enum 185 | impl Action { 186 | fn from_str(action: &str) -> Result { 187 | use Action::*; 188 | 189 | match action { 190 | // Player Actions: 191 | "play_or_pause" => Ok(PlayOrPause), 192 | "stop" => Ok(Stop), 193 | "next" => Ok(Next), 194 | "previous" => Ok(Previous), 195 | "increase_volume" => Ok(IncreaseVolume), 196 | "decrease_volume" => Ok(DecreaseVolume), 197 | "toggle_mute" => Ok(ToggleMute), 198 | "toggle_volume_display" => Ok(ToggleShowingVolume), 199 | "seek_to_min" => Ok(SeekToMin), 200 | "seek_to_sec" => Ok(SeekToSec), 201 | "seek_forward" => Ok(SeekForward), 202 | "seek_backward" => Ok(SeekBackward), 203 | "toggle_randomize" => Ok(ToggleRandomize), 204 | "toggle_shuffle" => Ok(ToggleShuffle), 205 | "play_track_number" => Ok(PlayTrackNumber), 206 | "play_last_track" => Ok(PlayLastTrack), 207 | "show_help" => Ok(ShowHelp), 208 | "quit" => Ok(Quit), 209 | 210 | // Finder Actions: 211 | // "select" => Ok(Select), 212 | // "cancel" => Ok(Cancel), 213 | // "move_down" => Ok(MoveDown), 214 | // "move_up" => Ok(MoveUp), 215 | // "page_up" => Ok(PageUp), 216 | // "page_down" => Ok(PageDown), 217 | // "backspace" => Ok(Backspace), 218 | // "delete" => Ok(Delete), 219 | // "cursor_left" => Ok(CursorLeft), 220 | // "cursor_right" => Ok(CursorRight), 221 | // "cursor_home" => Ok(CursorHome), 222 | // "cursor_end" => Ok(CursorEnd), 223 | // "clear_query" => Ok(ClearQuery), 224 | // Global Actions: 225 | _ => bail!("Unknown action `{}`", action), 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file; 2 | pub mod keybinding; 3 | pub mod theme; 4 | 5 | pub use self::{ 6 | file::FileConfig, 7 | theme::{ColorStyles, Theme}, 8 | }; 9 | 10 | use std::{env, path::PathBuf}; 11 | 12 | use anyhow::{bail, Context}; 13 | 14 | use crate::{cli::Args, TapError}; 15 | 16 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 17 | // TODO - update link and README 18 | 19 | // Program-wide configuration. Derived from merging default values with 20 | #[derive(Debug, Default)] 21 | pub struct Config { 22 | pub check_version: bool, 23 | pub search_root: PathBuf, 24 | pub default_path: Option, 25 | pub set_default_path: bool, 26 | pub use_default_path: bool, 27 | pub print_default_path: bool, 28 | pub use_cli_player: bool, 29 | pub theme: Theme, 30 | term_bg: bool, 31 | term_color: bool, 32 | default_color: bool, 33 | } 34 | 35 | impl Config { 36 | pub fn parse_config() -> Result { 37 | let mut config = Self::default(); 38 | let file_config = FileConfig::deserialize().unwrap_or_default(); 39 | let args = Args::parse_args()?; 40 | 41 | if args.check_version { 42 | config.check_version = true; 43 | 44 | return Ok(config); 45 | } 46 | 47 | config.parse_path(&file_config, &args)?; 48 | config.merge_flags(&file_config, &args); 49 | config.parse_colors(file_config, args)?; 50 | 51 | Ok(config) 52 | } 53 | 54 | fn parse_path(&mut self, file_config: &FileConfig, args: &Args) -> Result<(), TapError> { 55 | if args.use_default_path && file_config.path.is_none() { 56 | bail!("Default path not set"); 57 | } 58 | 59 | let default_path = file_config.expanded_path(); 60 | 61 | self.search_root = match args.path.as_ref().or(default_path.as_ref()) { 62 | Some(path) => path.clone(), 63 | None => env::current_dir().with_context(|| "Failed to get working directory")?, 64 | } 65 | .canonicalize()?; 66 | 67 | if !self.search_root.exists() { 68 | bail!("No such path: {:?}", self.search_root); 69 | } 70 | 71 | self.default_path = default_path; 72 | 73 | Ok(()) 74 | } 75 | 76 | fn merge_flags(&mut self, file_config: &FileConfig, args: &Args) { 77 | // Update `self` with the config file settings. 78 | file_config.term_bg.map(|v| self.term_bg = v); 79 | file_config.term_color.map(|v| self.term_color = v); 80 | file_config.default_color.map(|v| self.default_color = v); 81 | 82 | // Update `self` with the command line args. 83 | self.set_default_path |= args.set_default_path; 84 | self.use_default_path |= args.use_default_path; 85 | self.print_default_path |= args.print_default_path; 86 | self.term_bg |= args.term_bg; 87 | self.term_color |= args.term_color; 88 | self.default_color |= args.default_color; 89 | self.use_cli_player |= args.use_cli_player; 90 | } 91 | 92 | fn parse_colors(&mut self, file_config: FileConfig, args: Args) -> Result<(), TapError> { 93 | let mut theme = Theme::default(); 94 | 95 | if self.default_color { 96 | self.theme = theme; 97 | 98 | return Ok(()); 99 | } 100 | 101 | let args_theme: Theme = args.color.unwrap_or_default().try_into()?; 102 | let file_theme: Theme = file_config.color.unwrap_or_default().into(); 103 | let term_bg = self.term_bg && args_theme.get("bg").is_none(); 104 | 105 | if self.term_color && args_theme.is_empty() { 106 | theme.set_term_color(); 107 | } else { 108 | theme.extend(file_theme); 109 | theme.extend(args_theme); 110 | 111 | if term_bg { 112 | theme.set_term_bg(); 113 | } 114 | } 115 | 116 | self.theme = theme; 117 | 118 | Ok(()) 119 | } 120 | } 121 | 122 | // fn expand_tilde(path: PathBuf) -> PathBuf { 123 | // let path_str = path.to_string_lossy(); 124 | // if path_str.starts_with("~/") { 125 | // if let Ok(home) = env::var("HOME") { 126 | // return std::path::Path::new(&home).join(&path_str[2..]); 127 | // } 128 | // } 129 | // path 130 | // } 131 | -------------------------------------------------------------------------------- /src/config/theme.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{hash_map::IntoIter, HashMap}, 3 | iter::IntoIterator, 4 | ops::{Deref, DerefMut}, 5 | }; 6 | 7 | use { 8 | anyhow::{anyhow, bail}, 9 | cursive::{ 10 | theme::{ 11 | BorderStyle, 12 | Color::{self, Rgb}, 13 | ColorStyle, Palette, 14 | PaletteColor::{self, *}, 15 | Theme as CursiveTheme, 16 | }, 17 | With, 18 | }, 19 | }; 20 | 21 | use crate::TapError; 22 | 23 | // A struct representing a theme, which maps UI elements to specific colors. 24 | #[derive(Debug)] 25 | pub struct Theme { 26 | // A mapping between element names and their corresponding colors. 27 | pub color_map: HashMap, 28 | } 29 | 30 | impl Theme { 31 | const COLOR_NAMES: [&'static str; 9] = [ 32 | "fg", "bg", "hl", "prompt", "header_1", "header_2", "progress", "info", "err", 33 | ]; 34 | 35 | pub fn validate_color(name: &str) -> bool { 36 | Self::COLOR_NAMES.contains(&name) 37 | } 38 | 39 | pub fn set_term_color(&mut self) { 40 | self.iter_mut() 41 | .for_each(|(_, value)| *value = Color::TerminalDefault); 42 | } 43 | 44 | pub fn set_term_bg(&mut self) { 45 | self.insert("bg".to_string(), Color::TerminalDefault); 46 | } 47 | } 48 | 49 | impl Deref for Theme { 50 | type Target = HashMap; 51 | 52 | fn deref(&self) -> &Self::Target { 53 | &self.color_map 54 | } 55 | } 56 | 57 | impl DerefMut for Theme { 58 | fn deref_mut(&mut self) -> &mut Self::Target { 59 | &mut self.color_map 60 | } 61 | } 62 | 63 | impl TryFrom for Theme { 64 | type Error = TapError; 65 | 66 | fn try_from(value: String) -> Result { 67 | let mut color_map = HashMap::new(); 68 | 69 | for pair in value.split(',').map(str::trim) { 70 | if let Some((key, value)) = pair.split_once('=') { 71 | let name = key.trim(); 72 | let value = value.trim(); 73 | 74 | if !Self::validate_color(name) { 75 | bail!( 76 | "Invalid color name '{}' for '--color '.\nAvailable names: 'fg', 'bg', 'hl', 'prompt', 'header_1', 'header_2', 'progress', 'info', 'err'", 77 | name 78 | ); 79 | } 80 | 81 | let color = Color::parse(value).ok_or_else(|| { 82 | anyhow!( 83 | "Invalid color value '{}' for '--color '.\nExample values: 'red', 'light green', '#123456'", 84 | value 85 | ) 86 | })?; 87 | 88 | color_map.insert(name.to_string(), color); 89 | } 90 | } 91 | 92 | let theme = Theme { color_map }; 93 | 94 | Ok(theme) 95 | } 96 | } 97 | 98 | impl From> for Theme { 99 | fn from(value: HashMap) -> Self { 100 | let color_map = value 101 | .into_iter() 102 | .filter_map(|(name, value)| { 103 | Theme::validate_color(&name) 104 | .then(|| Color::parse(&value).map(|color| (name, color))) 105 | .flatten() 106 | }) 107 | .collect(); 108 | 109 | Theme { color_map } 110 | } 111 | } 112 | 113 | impl IntoIterator for Theme { 114 | type Item = (String, Color); 115 | type IntoIter = IntoIter; 116 | 117 | fn into_iter(self) -> Self::IntoIter { 118 | self.color_map.into_iter() 119 | } 120 | } 121 | 122 | impl Default for Theme { 123 | fn default() -> Self { 124 | let mut m = HashMap::new(); 125 | m.insert("fg".into(), Rgb(129, 161, 190)); // blue #81a1be 126 | m.insert("bg".into(), Rgb(31, 33, 29)); // black #1f211d 127 | m.insert("hl".into(), Rgb(197, 200, 198)); // white #c5c8c6 128 | m.insert("prompt".into(), Rgb(57, 54, 62)); // grey #39363e 129 | m.insert("header_1".into(), Rgb(181, 189, 104)); // green #b5bd68 130 | m.insert("header_2".into(), Rgb(240, 198, 116)); // yellow #f0c674 131 | m.insert("progress".into(), Rgb(178, 148, 187)); // magenta #b294bb 132 | m.insert("info".into(), Rgb(138, 190, 183)); // cyan #8abeb7 133 | m.insert("err".into(), Rgb(204, 102, 102)); // red #cc6666 134 | 135 | Self { color_map: m } 136 | } 137 | } 138 | 139 | impl From<&Theme> for CursiveTheme { 140 | fn from(theme: &Theme) -> Self { 141 | CursiveTheme { 142 | shadow: false, 143 | borders: BorderStyle::Simple, 144 | palette: Palette::default().with(|palette| { 145 | palette[Shadow] = theme.color_map["progress"]; 146 | palette[Primary] = theme.color_map["hl"]; 147 | palette[Secondary] = theme.color_map["fg"]; 148 | palette[Tertiary] = theme.color_map["prompt"]; 149 | palette[Background] = theme.color_map["bg"]; 150 | palette[View] = theme.color_map["bg"]; 151 | palette[TitlePrimary] = theme.color_map["header_1"]; 152 | palette[TitleSecondary] = theme.color_map["header_2"]; 153 | palette[Highlight] = theme.color_map["info"]; 154 | palette[HighlightInactive] = theme.color_map["err"]; 155 | }), 156 | } 157 | } 158 | } 159 | 160 | // A marker struct that provides predefined `ColorStyle` instances 161 | // for various UI elements. 162 | #[derive(Debug)] 163 | pub struct ColorStyles; 164 | 165 | impl ColorStyles { 166 | #[inline] 167 | pub fn fg() -> ColorStyle { 168 | ColorStyle::front(PaletteColor::Secondary) 169 | } 170 | 171 | #[inline] 172 | pub fn hl() -> ColorStyle { 173 | ColorStyle::front(PaletteColor::Primary) 174 | } 175 | 176 | #[inline] 177 | pub fn prompt() -> ColorStyle { 178 | ColorStyle::front(PaletteColor::Tertiary) 179 | } 180 | 181 | #[inline] 182 | pub fn header_1() -> ColorStyle { 183 | ColorStyle::front(PaletteColor::TitlePrimary) 184 | } 185 | 186 | #[inline] 187 | pub fn header_2() -> ColorStyle { 188 | ColorStyle::front(PaletteColor::TitleSecondary) 189 | } 190 | 191 | #[inline] 192 | pub fn progress() -> ColorStyle { 193 | ColorStyle::front(PaletteColor::Shadow) 194 | } 195 | 196 | #[inline] 197 | pub fn info() -> ColorStyle { 198 | ColorStyle::front(PaletteColor::Highlight) 199 | } 200 | 201 | #[inline] 202 | pub fn err() -> ColorStyle { 203 | ColorStyle::front(PaletteColor::HighlightInactive) 204 | } 205 | } 206 | 207 | #[cfg(test)] 208 | mod tests { 209 | use super::*; 210 | 211 | #[test] 212 | fn test_default_palette_uses_only_defined_names() { 213 | let palette = Theme::default().color_map; 214 | 215 | for name in palette.keys() { 216 | assert!( 217 | Theme::validate_color(name), 218 | "Palette contains an undefined color name: {}", 219 | name 220 | ); 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/finder/error_view.rs: -------------------------------------------------------------------------------- 1 | use cursive::{ 2 | event::{Event, EventTrigger, MouseEvent}, 3 | utils::markup::StyledString, 4 | view::Resizable, 5 | views::{FixedLayout, Layer, LinearLayout, OnEventView, OnLayoutView, ResizedView, TextView}, 6 | Cursive, Rect, Vec2, View, 7 | }; 8 | 9 | use crate::{config::ColorStyles, TapError}; 10 | 11 | // A Cursive view to display error messages. 12 | pub struct ErrorView; 13 | 14 | impl ErrorView { 15 | fn new(content: String) -> ResizedView> { 16 | let mut content = StyledString::styled(content, ColorStyles::hl()); 17 | content.append_plain(" "); 18 | content.append(StyledString::styled(" ", ColorStyles::err().invert())); 19 | content.append_plain(" "); 20 | 21 | OnLayoutView::new( 22 | FixedLayout::new().child( 23 | Rect::from_point(Vec2::zero()), 24 | LinearLayout::horizontal() 25 | .child(Layer::with_color( 26 | TextView::new(" [error]: "), 27 | ColorStyles::err(), 28 | )) 29 | .child(TextView::new(content)) 30 | .full_width(), 31 | ), 32 | |layout, size: cursive::XY| { 33 | layout.set_child_position(0, Rect::from_size((0, size.y - 2), (size.x, 2))); 34 | layout.layout(size); 35 | }, 36 | ) 37 | .full_screen() 38 | } 39 | 40 | pub fn load(siv: &mut Cursive, err: TapError) { 41 | let content = err.to_string(); 42 | siv.screen_mut() 43 | .add_transparent_layer(OnEventView::new(Self::new(content)).on_event( 44 | Self::trigger(), 45 | |siv| { 46 | siv.pop_layer(); 47 | }, 48 | )); 49 | } 50 | 51 | fn trigger() -> EventTrigger { 52 | EventTrigger::from_fn(|event| { 53 | matches!( 54 | event, 55 | Event::Char(_) 56 | | Event::Key(_) 57 | | Event::Mouse { 58 | event: MouseEvent::WheelUp | MouseEvent::WheelDown | MouseEvent::Press(_), 59 | .. 60 | } 61 | ) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/finder/finder_view.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::min, time::Instant}; 2 | 3 | use cursive::event::Key; 4 | use once_cell::sync::Lazy; 5 | 6 | use { 7 | anyhow::anyhow, 8 | cursive::{ 9 | event::{Event, EventResult, MouseButton, MouseEvent}, 10 | theme::Effect, 11 | view::{Nameable, Resizable}, 12 | CbSink, Cursive, Printer, View, XY, 13 | }, 14 | rand::{seq::SliceRandom, thread_rng}, 15 | unicode_segmentation::UnicodeSegmentation, 16 | unicode_width::UnicodeWidthStr, 17 | }; 18 | 19 | use crate::{ 20 | config::ColorStyles, 21 | finder::{ErrorView, Finder, FuzzyDir, Library, LibraryFilter}, 22 | player::{Player, PlayerView, Playlist}, 23 | }; 24 | 25 | static FRAMES: Lazy> = Lazy::new(|| { 26 | vec![ 27 | "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", 28 | ] 29 | }); 30 | 31 | // A struct representing the view and state of the Finder. 32 | pub struct FinderView { 33 | // The finder instance, which performs fuzzy searches and manages the results. 34 | finder: Finder, 35 | // The column of the text input cursor. 36 | cursor: usize, 37 | // The index of the selected directory in the search results. 38 | selected_index: usize, 39 | // Tracks the timestamp when initialization started. 40 | // Set to `Some(Instant::now())` when the struct is first initialized. 41 | // Set to `None` once initialization is complete and never used afterward. 42 | init_timestamp: Option, 43 | // The vertical offset needed to ensure the selected directory is visible. 44 | offset_y: usize, 45 | // The dimensions of the view, in cells. 46 | size: XY, 47 | // A sender for scheduling callbacks to be executed by the Cursive root. 48 | cb_sink: CbSink, 49 | } 50 | 51 | impl FinderView { 52 | pub fn new(finder: Finder, cb_sink: CbSink) -> Self { 53 | FinderView { 54 | finder, 55 | cb_sink, 56 | init_timestamp: None, 57 | cursor: 0, 58 | selected_index: 0, 59 | offset_y: 0, 60 | size: XY { x: 0, y: 0 }, 61 | } 62 | } 63 | 64 | pub fn load(filter: LibraryFilter) -> Option { 65 | Some(EventResult::with_cb(move |siv: &mut Cursive| { 66 | let library = { 67 | let base_library = siv 68 | .user_data::() 69 | .expect("Library should be set in user_data"); 70 | 71 | let mut library = match &filter { 72 | LibraryFilter::Parent(child) => base_library.parent_of(child.clone()), 73 | _ => base_library.apply_filter(&filter), 74 | }; 75 | 76 | library.shuffle(&mut thread_rng()); 77 | library 78 | }; 79 | 80 | let finder = Finder::new(library); 81 | let cb_sink = siv.cb_sink().clone(); 82 | let mut finder_view = FinderView::new(finder, cb_sink); 83 | 84 | if let LibraryFilter::Key(key) = filter { 85 | finder_view.insert(key.to_ascii_lowercase(), false); 86 | } 87 | 88 | Self::remove(siv); 89 | siv.add_layer(finder_view.with_name(super::ID).full_screen()); 90 | })) 91 | } 92 | 93 | // Update the internal library and recalc fuzzy matches. 94 | pub fn update_library(&mut self, batch: &mut Vec) { 95 | self.finder.library.fdirs.append(batch); 96 | self.finder.update(self.finder.query.clone()); 97 | } 98 | 99 | // Replace the internal library and recalc fuzzy matches. 100 | pub fn set_library(&mut self, library: Library) { 101 | self.finder.library = library; 102 | self.finder.update(self.finder.query.clone()); 103 | } 104 | 105 | pub fn depth(event: &Event) -> Option { 106 | let depth = event.f_num().expect("event should be usize"); 107 | Self::load(LibraryFilter::Depth(depth)) 108 | } 109 | 110 | pub fn key(event: &Event) -> Option { 111 | let key = event.char().expect("event should be char"); 112 | Self::load(LibraryFilter::Key(key)) 113 | } 114 | 115 | pub fn all(_: &Event) -> Option { 116 | Self::load(LibraryFilter::Unfiltered) 117 | } 118 | 119 | pub fn artist(_: &Event) -> Option { 120 | Self::load(LibraryFilter::Artist) 121 | } 122 | 123 | pub fn album(_: &Event) -> Option { 124 | Self::load(LibraryFilter::Album) 125 | } 126 | 127 | pub fn parent(_: &Event) -> Option { 128 | Some(EventResult::with_cb(move |siv: &mut Cursive| { 129 | let child = siv 130 | .call_on_name(super::ID, |finder_view: &mut FinderView| { 131 | finder_view.selected_dir() 132 | }) 133 | .unwrap_or_else(|| { 134 | siv.call_on_name(crate::player::ID, |player_view: &mut PlayerView| { 135 | player_view.current_dir().clone() 136 | }) 137 | }); 138 | 139 | if let Some(event) = Self::load(LibraryFilter::Parent(child)) { 140 | event.process(siv); 141 | } 142 | })) 143 | } 144 | 145 | fn next(&mut self) { 146 | if self.selected_index == 0 { 147 | return; 148 | } 149 | if self.selected_index == self.offset_y { 150 | self.offset_y -= 1; 151 | } 152 | self.selected_index -= 1; 153 | } 154 | 155 | pub fn previous(&mut self) { 156 | if self.selected_index == self.finder.matches - 1 { 157 | return; 158 | } 159 | let last_visible_row = self.visible_rows().1; 160 | 161 | if self.selected_index - self.offset_y > last_visible_row - 2 { 162 | self.offset_y += 1; 163 | } 164 | self.selected_index += 1; 165 | } 166 | 167 | fn page_up(&mut self) { 168 | if self.finder.matches == 0 { 169 | return; 170 | } 171 | 172 | let last_visible_row = self.visible_rows().1; 173 | 174 | if self.selected_index + last_visible_row < self.finder.matches - 1 { 175 | self.offset_y += last_visible_row; 176 | self.selected_index += last_visible_row; 177 | } else { 178 | self.selected_index = self.finder.matches - 1; 179 | if self.offset_y + last_visible_row <= self.selected_index { 180 | self.offset_y += last_visible_row; 181 | } 182 | } 183 | } 184 | 185 | fn page_down(&mut self) { 186 | if self.finder.matches == 0 { 187 | return; 188 | } 189 | let last_visible_row = self.visible_rows().1; 190 | 191 | self.selected_index = if self.selected_index >= last_visible_row { 192 | self.selected_index - last_visible_row 193 | } else { 194 | 0 195 | }; 196 | 197 | self.offset_y = if self.offset_y >= last_visible_row { 198 | self.offset_y - last_visible_row 199 | } else { 200 | 0 201 | }; 202 | } 203 | 204 | fn cursor_left(&mut self) { 205 | if self.cursor > 0 { 206 | let len = { 207 | let text = &self.finder.query[0..self.cursor]; 208 | text.graphemes(true).last().unwrap().len() 209 | }; 210 | self.cursor -= len; 211 | } 212 | } 213 | 214 | fn cursor_right(&mut self) { 215 | if self.cursor < self.finder.query.len() { 216 | let len = self.finder.query[self.cursor..] 217 | .graphemes(true) 218 | .next() 219 | .unwrap() 220 | .len(); 221 | self.cursor += len; 222 | } 223 | } 224 | 225 | fn update_search_results(&mut self, query: String) { 226 | self.finder.update(query); 227 | self.selected_index = 0; 228 | self.offset_y = 0; 229 | } 230 | 231 | fn delete(&mut self) { 232 | if self.cursor < self.finder.query.len() { 233 | let mut query = self.finder.query.clone(); 234 | let len = query[self.cursor..].graphemes(true).next().unwrap().len(); 235 | query.drain(self.cursor..self.cursor + len); 236 | self.update_search_results(query); 237 | } else if self.finder.query.len() == 0 { 238 | self.clear(); 239 | } 240 | } 241 | 242 | fn backspace(&mut self) { 243 | if self.cursor > 0 { 244 | self.cursor_left(); 245 | self.delete() 246 | } else { 247 | _ = self.cb_sink.send(Box::new(|siv| { 248 | Self::load(LibraryFilter::Unfiltered).map(|e| e.process(siv)); 249 | })) 250 | } 251 | } 252 | 253 | fn insert(&mut self, ch: char, apply_matching: bool) { 254 | let mut query = self.finder.query.clone(); 255 | query.insert(self.cursor, ch); 256 | self.cursor += ch.len_utf8(); 257 | 258 | if apply_matching { 259 | self.update_search_results(query); 260 | } else { 261 | // This is to prevent lowecased artist names receiving 262 | // positively biased match scores when filtering by key. 263 | self.finder.query = query; 264 | self.selected_index = 0; 265 | self.offset_y = 0; 266 | } 267 | } 268 | 269 | fn sort(&mut self) { 270 | let query = self.finder.query.clone(); 271 | self.finder.library.fdirs.sort(); 272 | self.update_search_results(query); 273 | } 274 | 275 | fn clear(&mut self) { 276 | self.cursor = 0; 277 | self.offset_y = 0; 278 | self.update_search_results("".into()); 279 | } 280 | 281 | fn count(&self) -> String { 282 | format!("{}/{} ", self.finder.matches, self.finder.library.len()) 283 | } 284 | 285 | pub fn selected_dir(&self) -> Option { 286 | self.finder.library.get(self.selected_index).cloned() 287 | } 288 | 289 | fn update_finder(&mut self, dir: FuzzyDir) -> EventResult { 290 | let library = Library::new(&dir.path); 291 | self.finder.library = library; 292 | self.clear(); 293 | EventResult::consumed() 294 | } 295 | 296 | fn load_playlist(&self, next: Playlist) -> EventResult { 297 | EventResult::with_cb(move |siv| { 298 | siv.call_on_name(crate::player::ID, |player_view: &mut PlayerView| { 299 | if player_view.current_dir().path != next.fdir.path { 300 | player_view.update_playlist(next.clone(), true); 301 | } 302 | }) 303 | .map(|_| _ = Self::remove(siv)) 304 | .unwrap_or_else(|| match Player::try_new(next.clone()) { 305 | Ok(player) => PlayerView::load(siv, player), 306 | Err(_) => ErrorView::load(siv, anyhow!("Invalid selection!")), 307 | }); 308 | }) 309 | } 310 | 311 | fn on_select(&mut self) -> EventResult { 312 | match self.selected_dir() { 313 | Some(dir) if dir.contains_subdir => self.update_finder(dir), 314 | Some(dir) => match Playlist::try_from(dir) { 315 | Ok(playlist) => self.load_playlist(playlist), 316 | Err(_) => EventResult::with_cb(|siv| { 317 | ErrorView::load(siv, anyhow!("Failed to create playlist!")) 318 | }), 319 | }, 320 | None => EventResult::with_cb(|siv| ErrorView::load(siv, anyhow!("Nothing selected!"))), 321 | } 322 | } 323 | 324 | fn visible_rows(&self) -> (usize, usize) { 325 | let last_visible_row = if self.size.y > 2 { self.size.y - 2 } else { 0 }; 326 | let first_visible_row = self.size.y - 1 - min(last_visible_row, self.finder.matches); 327 | 328 | (first_visible_row, last_visible_row) 329 | } 330 | 331 | fn mouse_select(&mut self, position: XY) -> EventResult { 332 | let (first_visible_row, last_visible_row) = self.visible_rows(); 333 | 334 | if position.y < first_visible_row || position.y > last_visible_row { 335 | return EventResult::consumed(); 336 | } 337 | let next_selected = last_visible_row + self.offset_y - position.y; 338 | 339 | if next_selected == self.selected_index { 340 | self.on_select() 341 | } else { 342 | self.selected_index = next_selected; 343 | EventResult::consumed() 344 | } 345 | } 346 | 347 | fn on_cancel(&self) -> EventResult { 348 | EventResult::with_cb(|siv| { 349 | if let None = siv.call_on_name(crate::player::ID, |_: &mut PlayerView| {}) { 350 | siv.quit(); 351 | } else { 352 | siv.pop_layer(); 353 | } 354 | }) 355 | } 356 | 357 | fn spinner_frame(&self) -> Option<&str> { 358 | self.init_timestamp.and_then(|last_update| { 359 | let elapsed = last_update.elapsed().as_millis() / 100; 360 | let index = (elapsed % FRAMES.len() as u128) as usize; 361 | Some(FRAMES[index]) 362 | }) 363 | } 364 | 365 | pub fn set_init_timestamp(&mut self, ts: Option) { 366 | self.init_timestamp = ts; 367 | } 368 | 369 | pub fn remove(siv: &mut cursive::Cursive) { 370 | if siv.find_name::(super::ID).is_some() { 371 | siv.pop_layer(); 372 | } 373 | } 374 | } 375 | 376 | impl View for FinderView { 377 | fn layout(&mut self, size: XY) { 378 | self.size = size; 379 | } 380 | 381 | fn draw(&self, p: &Printer) { 382 | // The size of the screen we can draw on. 383 | let (w, h) = (p.size.x, p.size.y); 384 | 385 | if h > 3 { 386 | // The first row of the list. 387 | let start_row = h - 3; 388 | // The number of visible rows. 389 | let visible_rows = min(self.finder.matches - self.offset_y, h - 2); 390 | 391 | for y in 0..visible_rows { 392 | let index = y + self.offset_y; 393 | // The items are drawn in ascending order, starting on third row from bottom. 394 | let row = start_row - y; 395 | // Only draw items that have matches. 396 | if self.finder.library[index].match_weight != 0 { 397 | // Set the color depending on whether row is currently selected or not. 398 | let (primary, highlight) = 399 | if row + self.selected_index == start_row + self.offset_y { 400 | // Draw the symbol to show the currently selected item. 401 | p.with_color(ColorStyles::header_2(), |p| p.print((0, row), ">")); 402 | // The colors for the currently selected row. 403 | (ColorStyles::hl(), ColorStyles::header_1()) 404 | } else { 405 | // The colors for the not selected row. 406 | (ColorStyles::fg(), ColorStyles::hl()) 407 | }; 408 | // Draw the item's display name. 409 | p.with_color(primary, |p| { 410 | p.print((2, row), self.finder.library[index].name.as_str()) 411 | }); 412 | // Draw the fuzzy matched indices in a highlighting color. 413 | for x in &self.finder.library[index].match_indices { 414 | let mut chars = self.finder.library[index].name.chars(); 415 | p.with_effect(Effect::Bold, |p| { 416 | p.with_color(highlight, |p| { 417 | p.print( 418 | (x + 2, row), 419 | chars.nth(*x).unwrap_or_default().to_string().as_str(), 420 | ) 421 | }); 422 | }); 423 | } 424 | } 425 | } 426 | 427 | // Draw the page count. 428 | if self.finder.matches > 0 { 429 | p.with_color(ColorStyles::prompt(), |p| { 430 | let rows_per_page = h - 2; 431 | let current_page = self.selected_index / rows_per_page; 432 | let total_pages = (self.finder.matches + rows_per_page - 1) / rows_per_page - 1; 433 | let formatted_page_count = format!(" {}/{}", current_page, total_pages); 434 | let start_column = self.size.x - formatted_page_count.chars().count(); 435 | p.print((start_column, 0), formatted_page_count.as_str()); 436 | }); 437 | } 438 | } 439 | 440 | if h > 1 { 441 | // The last row we can draw on. 442 | let query_row = h - 1; 443 | 444 | // Draw the spinner. 445 | if let Some(frame) = self.spinner_frame() { 446 | p.with_color(ColorStyles::info(), |p| { 447 | p.print((0, query_row - 1), frame); 448 | }); 449 | } 450 | 451 | // Draw the match count and some borders. 452 | p.with_color(ColorStyles::progress(), |p| { 453 | let lines = min(self.finder.matches / 4, h / 4); 454 | p.print_vline((w - 1, query_row - 1 - lines), lines, "│"); 455 | p.print_hline((2, query_row - 1), w - 3, "─"); 456 | p.print((2, query_row - 1), &self.count()); 457 | }); 458 | 459 | // Draw the text input area that shows the query. 460 | p.with_color(ColorStyles::hl(), |p| { 461 | p.print_hline((0, query_row), w, " "); 462 | p.print((2, query_row), &self.finder.query); 463 | }); 464 | 465 | let c = if self.cursor == self.finder.query.len() { 466 | "_" 467 | } else { 468 | self.finder.query[self.cursor..] 469 | .graphemes(true) 470 | .next() 471 | .expect("should find a char") 472 | }; 473 | let offset = self.finder.query[..self.cursor].width(); 474 | p.with_effect(Effect::Reverse, |p| { 475 | p.print((offset + 2, query_row), c); 476 | }); 477 | 478 | // Draw the symbol to show the start of the text input area. 479 | p.with_color(ColorStyles::prompt(), |p| p.print((0, query_row), ">")); 480 | } 481 | } 482 | 483 | fn on_event(&mut self, event: Event) -> EventResult { 484 | match event { 485 | Event::Char(c) => self.insert(c, true), 486 | Event::Key(Key::Enter) | Event::CtrlChar('j') => return self.on_select(), 487 | Event::Key(Key::Esc) => return self.on_cancel(), 488 | Event::Key(Key::Down) | Event::CtrlChar('n') => self.next(), 489 | Event::Key(Key::Up) | Event::CtrlChar('p') => self.previous(), 490 | Event::Key(Key::PageUp) => self.page_up(), 491 | Event::Key(Key::PageDown) => self.page_down(), 492 | Event::Key(Key::Backspace) => self.backspace(), 493 | Event::Key(Key::Del) => self.delete(), 494 | Event::Key(Key::Left) | Event::CtrlChar('b') => self.cursor_left(), 495 | Event::Key(Key::Right) | Event::CtrlChar('f') => self.cursor_right(), 496 | Event::Key(Key::Home) => self.cursor = 0, 497 | Event::Key(Key::End) => self.cursor = self.finder.query.len(), 498 | Event::CtrlChar('u') => self.clear(), 499 | Event::CtrlChar('s') => self.sort(), 500 | Event::Mouse { 501 | event, position, .. 502 | } => match event { 503 | MouseEvent::Press(MouseButton::Right) => return self.on_cancel(), 504 | MouseEvent::Press(MouseButton::Left) => return self.mouse_select(position), 505 | MouseEvent::WheelDown => self.next(), 506 | MouseEvent::WheelUp => self.previous(), 507 | _ => (), 508 | }, 509 | _ => (), 510 | } 511 | EventResult::Ignored 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /src/finder/fuzzy_dir.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::Ordering, path::PathBuf}; 2 | 3 | use { 4 | anyhow::{anyhow, bail}, 5 | bincode::{Decode, Encode}, 6 | serde::{Deserialize, Serialize}, 7 | walkdir::DirEntry, 8 | }; 9 | 10 | use crate::{player::AudioFile, TapError}; 11 | 12 | // A struct representing a directory containing audio files, with metadata used for 13 | // fuzzy matching and filtering. 14 | #[derive(Clone, Debug, Eq, PartialEq, Ord, Encode, Decode, Serialize, Deserialize, Default)] 15 | pub struct FuzzyDir { 16 | // The file name of the directory entry. 17 | pub name: String, 18 | // Indices of characters in `name` that are matched during fuzzy searching. 19 | pub match_indices: Vec, 20 | // The weight of the fuzzy match; better matches have higher weights. 21 | pub match_weight: i64, 22 | // The full path of the directory entry on the file system. 23 | pub path: PathBuf, 24 | // The depth of the directory relative to the initial search root. 25 | pub depth: usize, 26 | // The first character of the `name`, converted to uppercase, used as a key for filtering. 27 | pub key: char, 28 | // Whether or not this directory contains an audio file. 29 | pub contains_audio: bool, 30 | // Whether or not this directory contains a subdirectory. 31 | pub contains_subdir: bool, 32 | } 33 | 34 | impl TryFrom for FuzzyDir { 35 | type Error = TapError; 36 | 37 | fn try_from(path: PathBuf) -> Result { 38 | let mut dir = FuzzyDir::default(); 39 | dir.name = path 40 | .file_name() 41 | .and_then(|os_str| os_str.to_str().map(String::from)) 42 | .ok_or(anyhow!("Invalid file name"))?; 43 | 44 | Ok(dir) 45 | } 46 | } 47 | 48 | impl FuzzyDir { 49 | pub fn new(entry: DirEntry) -> Result { 50 | let (contains_audio, contains_subdir) = Self::check_audio_and_subdirs(&entry)?; 51 | 52 | let name = entry 53 | .file_name() 54 | .to_os_string() 55 | .into_string() 56 | .unwrap_or_default(); 57 | 58 | let key = name.chars().next().unwrap_or_default().to_ascii_uppercase(); 59 | 60 | let audio_dir = FuzzyDir { 61 | name, 62 | key, 63 | contains_audio, 64 | contains_subdir, 65 | depth: entry.depth(), 66 | path: entry.into_path(), 67 | match_indices: vec![], 68 | // Assign an initial non-zero weight to ensure the directory 69 | // is included in the results before fuzzy matching. 70 | match_weight: 1, 71 | }; 72 | 73 | Ok(audio_dir) 74 | } 75 | 76 | // Checks whether the directory contains at least one audio file or subdirectory. 77 | fn check_audio_and_subdirs(entry: &DirEntry) -> Result<(bool, bool), TapError> { 78 | let mut contains_audio = false; 79 | let mut contains_subdir = false; 80 | 81 | for entry in entry.path().read_dir()? { 82 | if let Ok(entry) = entry { 83 | if entry.path().is_dir() { 84 | contains_subdir = true; 85 | } else if !contains_audio { 86 | contains_audio = AudioFile::validate_format(&entry.path()); 87 | } 88 | } 89 | 90 | if contains_audio && contains_subdir { 91 | break; 92 | } 93 | } 94 | 95 | if entry.depth() == 0 { 96 | if contains_audio { 97 | contains_subdir = false; 98 | } else { 99 | bail!("No audio in root dir") 100 | } 101 | } 102 | 103 | if contains_audio || contains_subdir { 104 | Ok((contains_audio, contains_subdir)) 105 | } else { 106 | bail!("No audio files or subdirectories found!") 107 | } 108 | } 109 | 110 | pub fn is_parent_of(&self, other: &FuzzyDir) -> bool { 111 | other.path.starts_with(&self.path) && other.path != self.path 112 | } 113 | 114 | fn is_hidden(entry: &walkdir::DirEntry) -> bool { 115 | entry 116 | .file_name() 117 | .to_str() 118 | .map_or(false, |s| s.starts_with('.')) 119 | } 120 | 121 | pub fn is_visible_dir(entry: &walkdir::DirEntry) -> bool { 122 | entry.file_type().is_dir() && !Self::is_hidden(entry) 123 | } 124 | } 125 | 126 | impl<'a> FromIterator<&'a FuzzyDir> for Vec { 127 | fn from_iter>(iter: I) -> Self { 128 | iter.into_iter().cloned().collect() 129 | } 130 | } 131 | 132 | impl PartialOrd for FuzzyDir { 133 | // Case-insensitive, optimized. 134 | fn partial_cmp(&self, other: &Self) -> Option { 135 | match self.key.cmp(&other.key) { 136 | Ordering::Equal => Some( 137 | self.name 138 | .chars() 139 | .skip(1) 140 | .map(|c| c.to_ascii_lowercase()) 141 | .cmp(other.name.chars().skip(1).map(|c| c.to_ascii_lowercase())), 142 | ), 143 | other_order => Some(other_order), 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/finder/library.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | fs::{self, File}, 4 | io::{Read, Write}, 5 | ops::{Deref, DerefMut}, 6 | path::PathBuf, 7 | sync::mpsc::{self, Receiver, Sender}, 8 | thread, 9 | }; 10 | 11 | use { 12 | anyhow::bail, 13 | bincode::{Decode, Encode}, 14 | rayon::iter::{ParallelBridge, ParallelIterator}, 15 | walkdir::WalkDir, 16 | }; 17 | 18 | use crate::{config::Config, player::Playlist, FuzzyDir, TapError}; 19 | 20 | const BATCH_SIZE: usize = 200; 21 | 22 | #[derive(Decode, Encode, Clone, Default)] 23 | pub struct Library { 24 | pub root: PathBuf, 25 | pub fdirs: Vec, 26 | } 27 | 28 | impl Library { 29 | pub fn new(root: &PathBuf) -> Self { 30 | let fdirs = WalkDir::new(root) 31 | .into_iter() 32 | .par_bridge() 33 | .filter_map(|entry| entry.ok()) 34 | .filter(FuzzyDir::is_visible_dir) 35 | .filter_map(|entry| FuzzyDir::new(entry).ok()) 36 | .collect::>(); 37 | 38 | Self { 39 | fdirs, 40 | root: root.clone(), 41 | } 42 | } 43 | 44 | pub fn first(root: &PathBuf) -> Self { 45 | let first = WalkDir::new(root) 46 | .into_iter() 47 | .filter_map(|entry| entry.ok()) 48 | .filter(FuzzyDir::is_visible_dir) 49 | .filter_map(|entry| FuzzyDir::new(entry).ok()) 50 | .filter(|fdir| fdir.contains_audio) 51 | .take(1); 52 | 53 | Self { 54 | fdirs: first.collect::>(), 55 | root: root.clone(), 56 | } 57 | } 58 | 59 | pub fn load_in_background(config: &Config, lib_tx: Sender) { 60 | let (fdir_tx, fdir_rx) = mpsc::channel::(); 61 | let search_root: PathBuf; 62 | 63 | match Self::deserialize(config) { 64 | Ok(library) => { 65 | search_root = library.root.clone(); 66 | _ = lib_tx.send(LibraryEvent::Init(library)); 67 | LibraryEvent::spawn_update_cache(&search_root, fdir_rx, lib_tx) 68 | } 69 | Err(_) if config.use_default_path && config.default_path.is_some() => { 70 | search_root = config.default_path.clone().unwrap(); 71 | LibraryEvent::spawn_update_cache(&search_root, fdir_rx, lib_tx) 72 | } 73 | _ => { 74 | search_root = config.search_root.clone(); 75 | LibraryEvent::spawn_batch(&search_root, fdir_rx, lib_tx); 76 | } 77 | } 78 | 79 | WalkDir::new(search_root) 80 | .into_iter() 81 | .par_bridge() 82 | .filter_map(|entry| entry.ok()) 83 | .for_each_with(fdir_tx, |fdir_tx, entry| { 84 | if FuzzyDir::is_visible_dir(&entry) { 85 | if let Ok(fdir) = FuzzyDir::new(entry) { 86 | _ = fdir_tx.send(fdir); 87 | } 88 | } 89 | }); 90 | } 91 | 92 | pub fn serialize(&self) -> Result<(), TapError> { 93 | let cfg = bincode::config::standard(); 94 | let data = bincode::encode_to_vec(self, cfg)?; 95 | let path = cache_path()?; 96 | let mut file = File::create(path)?; 97 | file.write_all(&data)?; 98 | Ok(()) 99 | } 100 | 101 | pub fn deserialize(config: &Config) -> Result { 102 | let cache_path = cache_path()?; 103 | let mut file = File::open(&cache_path)?; 104 | let mut buffer = Vec::new(); 105 | file.read_to_end(&mut buffer)?; 106 | 107 | if buffer.is_empty() { 108 | bail!("Cache not set") 109 | } 110 | 111 | let cfg = bincode::config::standard(); 112 | let (root, bytes) = bincode::decode_from_slice::(&buffer[..], cfg)?; 113 | 114 | if Some(&root) != config.default_path.as_ref() { 115 | bail!("Cache is dirty") 116 | } 117 | 118 | if !config.use_default_path && config.search_root != root { 119 | bail!("Cache not used"); 120 | } 121 | 122 | let fdirs = bincode::decode_from_slice::, _>(&buffer[bytes..], cfg)?.0; 123 | 124 | Ok(Self { root, fdirs }) 125 | } 126 | 127 | pub fn audio_count(&self) -> usize { 128 | self.iter() 129 | .filter(|fdir| fdir.contains_audio) 130 | .take(2) 131 | .count() 132 | } 133 | 134 | pub fn audio_dirs(&self) -> Vec { 135 | self.iter().filter(|fdir| fdir.contains_audio).collect() 136 | } 137 | 138 | // Finds the path of the first dir with audio. 139 | pub fn first_playlist(&self) -> Result { 140 | match self.iter().find(|fdir| fdir.contains_audio) { 141 | Some(dir) => Playlist::try_from(dir.clone()), 142 | None => Playlist::process(&self.root.clone(), false), 143 | } 144 | } 145 | 146 | pub fn parent_of(&self, child: Option) -> Self { 147 | let fdirs = child 148 | .filter(|child| child.depth > 0) 149 | .map(|child| { 150 | self.fdirs 151 | .iter() 152 | .filter(|fdir| fdir.is_parent_of(&child) && fdir.path != self.root) 153 | .collect() 154 | }) 155 | .unwrap_or(self.fdirs.clone()); 156 | 157 | let fdirs = if fdirs.is_empty() { 158 | self.fdirs.clone() 159 | } else { 160 | fdirs 161 | }; 162 | 163 | Self { 164 | root: self.root.clone(), 165 | fdirs, 166 | } 167 | } 168 | 169 | pub fn apply_filter(&self, filter: &LibraryFilter) -> Library { 170 | use LibraryFilter::*; 171 | 172 | let predicate: Box bool> = match filter { 173 | Artist => Box::new(|fdir| fdir.contains_subdir), 174 | Album => Box::new(|fdir| fdir.contains_audio), 175 | Depth(depth) => Box::new(|fdir| fdir.depth == *depth), 176 | Key(key) => Box::new(|fdir| !fdir.contains_audio && fdir.key == *key), 177 | _ => return self.clone(), 178 | }; 179 | 180 | let fdirs = self 181 | .fdirs 182 | .iter() 183 | .filter(|dir| predicate(dir)) 184 | .cloned() 185 | .collect(); 186 | 187 | Self { 188 | fdirs, 189 | root: self.root.clone(), 190 | } 191 | } 192 | } 193 | 194 | #[derive(Debug)] 195 | pub enum LibraryEvent { 196 | Batch(Vec), 197 | Init(Library), 198 | Finished(Option), 199 | } 200 | 201 | impl LibraryEvent { 202 | fn spawn_batch(search_root: &PathBuf, fdir_rx: Receiver, lib_tx: Sender) { 203 | let root = search_root.clone(); 204 | 205 | thread::spawn(move || { 206 | let mut batch = Vec::with_capacity(BATCH_SIZE); 207 | let mut first_batch_sent = false; 208 | 209 | for fd in fdir_rx { 210 | batch.push(fd.clone()); 211 | if batch.len() >= BATCH_SIZE { 212 | if !first_batch_sent { 213 | let library = Library { 214 | root: root.clone(), 215 | fdirs: batch.clone(), 216 | }; 217 | _ = lib_tx.send(Self::Init(library)); 218 | first_batch_sent = true; 219 | } else { 220 | _ = lib_tx.send(Self::Batch(batch.clone())); 221 | } 222 | 223 | batch.clear(); 224 | } 225 | } 226 | 227 | if !batch.is_empty() { 228 | if !first_batch_sent { 229 | _ = lib_tx.send(Self::Init(Library { root, fdirs: batch })); 230 | } else { 231 | _ = lib_tx.send(Self::Batch(batch)); 232 | } 233 | } 234 | 235 | _ = lib_tx.send(Self::Finished(None)); 236 | }); 237 | } 238 | 239 | fn spawn_update_cache( 240 | search_root: &PathBuf, 241 | fdir_rx: Receiver, 242 | lib_tx: Sender, 243 | ) { 244 | let root = search_root.clone(); 245 | 246 | thread::spawn(move || { 247 | let mut full_library = Library { 248 | root: root.clone(), 249 | fdirs: Vec::new(), 250 | }; 251 | 252 | for fdir in fdir_rx { 253 | full_library.fdirs.push(fdir); 254 | } 255 | 256 | _ = full_library.serialize(); 257 | _ = lib_tx.send(Self::Finished(Some(full_library))); 258 | }); 259 | } 260 | } 261 | 262 | #[derive(Clone, Debug, PartialEq)] 263 | pub enum LibraryFilter { 264 | // All dirs 265 | Unfiltered, 266 | // Dir depth from search root (1-4) 267 | Depth(usize), 268 | // All artists 269 | Artist, 270 | // All ablums 271 | Album, 272 | // All artists starting with key (A-Z) 273 | Key(char), 274 | // The parent of current dir 275 | Parent(Option), 276 | } 277 | 278 | impl Deref for Library { 279 | type Target = Vec; 280 | 281 | fn deref(&self) -> &Self::Target { 282 | &self.fdirs 283 | } 284 | } 285 | 286 | impl DerefMut for Library { 287 | fn deref_mut(&mut self) -> &mut Self::Target { 288 | &mut self.fdirs 289 | } 290 | } 291 | 292 | impl core::fmt::Debug for Library { 293 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 294 | let _ = writeln!(f, "Library:"); 295 | for dir in &self.fdirs { 296 | writeln!(f, " {},", dir.name)?; 297 | } 298 | writeln!(f, "") 299 | } 300 | } 301 | 302 | pub fn cache_path() -> Result { 303 | let home_dir = PathBuf::from(env::var("HOME")?); 304 | let cache_dir = PathBuf::from(home_dir).join(".cache/tap"); 305 | fs::create_dir_all(&cache_dir)?; 306 | let cache_path = PathBuf::from(cache_dir).join("data"); 307 | 308 | Ok(cache_path) 309 | } 310 | -------------------------------------------------------------------------------- /src/finder/mod.rs: -------------------------------------------------------------------------------- 1 | mod error_view; 2 | pub mod finder_view; 3 | pub mod fuzzy_dir; 4 | pub mod library; 5 | 6 | pub use self::{ 7 | error_view::ErrorView, 8 | finder_view::FinderView, 9 | fuzzy_dir::FuzzyDir, 10 | library::{Library, LibraryEvent, LibraryFilter}, 11 | }; 12 | 13 | use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; 14 | 15 | pub const ID: &str = "finder"; 16 | 17 | // A struct that performs fuzzy matching on a collection of items. 18 | #[derive(Clone)] 19 | pub struct Finder { 20 | // The query string used for fuzzy matching. 21 | pub query: String, 22 | // The total count of items that match the fuzzy query. 23 | pub matches: usize, 24 | // The collection of items to perform fuzzy matching on. 25 | pub library: Library, 26 | } 27 | 28 | impl Finder { 29 | pub fn new(library: Library) -> Self { 30 | Self { 31 | query: String::new(), 32 | matches: library.len(), 33 | library, 34 | } 35 | } 36 | 37 | pub fn update(&mut self, query: String) { 38 | self.query = query; 39 | self.perform_fuzzy_matching(); 40 | } 41 | 42 | fn perform_fuzzy_matching(&mut self) { 43 | if self.query.is_empty() { 44 | for i in 0..self.library.len() { 45 | self.library[i].match_weight = 1; 46 | self.library[i].match_indices.clear(); 47 | } 48 | self.matches = self.library.len(); 49 | } else { 50 | let mut match_count = 0; 51 | let matcher = Box::new(SkimMatcherV2::default().ignore_case()); 52 | for i in 0..self.library.len() { 53 | let dir = &mut self.library[i]; 54 | if let Some((weight, indices)) = matcher.fuzzy_indices(&dir.name, &self.query) { 55 | dir.match_weight = weight; 56 | dir.match_indices = indices; 57 | match_count += 1; 58 | } else { 59 | dir.match_weight = 0; 60 | dir.match_indices.clear(); 61 | } 62 | } 63 | self.matches = match_count; 64 | self.library 65 | .sort_by(|a, b| b.match_weight.cmp(&a.match_weight)); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod config; 3 | mod finder; 4 | mod player; 5 | 6 | use std::{ 7 | sync::{ 8 | mpsc::{self, Receiver, Sender}, 9 | Arc, Mutex, 10 | }, 11 | thread::{self, JoinHandle}, 12 | time::Instant, 13 | }; 14 | 15 | use { 16 | anyhow::{anyhow, bail}, 17 | colored::Colorize, 18 | cursive::{ 19 | event::{Event, EventTrigger, Key, MouseButton, MouseEvent}, 20 | theme::Theme as CursiveTheme, 21 | CbSink, Cursive, 22 | }, 23 | }; 24 | 25 | use crate::{ 26 | cli::{player::CliPlayer, Cli}, 27 | config::Config, 28 | finder::{FinderView, FuzzyDir, Library, LibraryEvent, LibraryFilter, ID as F_ID}, 29 | player::{Player, PlayerView, Playlist, ID as P_ID}, 30 | }; 31 | 32 | pub type TapError = anyhow::Error; 33 | 34 | fn main() { 35 | if let Err(err) = set_up_run() { 36 | let err_prefix = "[tap error]:".red().bold(); 37 | eprintln!("\r{err_prefix} {err}"); 38 | } 39 | } 40 | 41 | fn set_up_run() -> Result<(), TapError> { 42 | let config = Config::parse_config()?; 43 | 44 | if config.check_version { 45 | return Cli::check_version(); 46 | } 47 | 48 | if config.print_default_path { 49 | return Cli::print_cache(); 50 | } 51 | 52 | if config.set_default_path { 53 | return Cli::set_cache(&config.search_root); 54 | } 55 | 56 | if config.use_cli_player { 57 | return CliPlayer::try_run(&config.search_root); 58 | } 59 | 60 | let mut siv = cursive::ncurses(); 61 | siv.set_theme(CursiveTheme::from(&config.theme)); 62 | siv.set_fps(15); 63 | 64 | if let Ok(playlist) = Playlist::process(&config.search_root, true) { 65 | let player = Player::try_new(playlist)?; 66 | PlayerView::load(&mut siv, player); 67 | siv.run(); 68 | } else { 69 | let cb_sink = siv.cb_sink().clone(); 70 | let (lib_tx, lib_rx) = mpsc::channel(); 71 | let (err_tx, err_rx) = mpsc::channel::(); 72 | let err_state = Arc::new(Mutex::new(None)); 73 | Library::load_in_background(&config, lib_tx.clone()); 74 | spawn_tui_loader(lib_rx, err_tx.clone(), cb_sink.clone()); 75 | let err_handle = spawn_err_handle(err_rx, Arc::clone(&err_state), cb_sink); 76 | siv.run(); 77 | check_err_state(err_handle, err_state)?; 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | fn check_err_state( 84 | err_handle: JoinHandle<()>, 85 | err_state: Arc>>, 86 | ) -> Result<(), TapError> { 87 | // Drop handle if we cannot join in order to prevent hanging (which occurs 88 | // when the event loop is quit before the UI has finished updating). 89 | if err_handle.is_finished() { 90 | err_handle.join().expect("Couldn't join err_handle") 91 | } else { 92 | drop(err_handle); 93 | } 94 | 95 | let mut err_state = err_state 96 | .lock() 97 | .map_err(|e| anyhow::anyhow!("Mutex error: {:?}", e))?; 98 | 99 | if let Some(err) = err_state.take() { 100 | bail!(err); 101 | } 102 | 103 | Ok(()) 104 | } 105 | 106 | fn spawn_tui_loader(lib_rx: Receiver, err_tx: Sender, cb_sink: CbSink) { 107 | thread::spawn(move || { 108 | while let Ok(event) = lib_rx.recv() { 109 | let is_finished = match &event { 110 | LibraryEvent::Finished(_) => true, 111 | _ => false, 112 | }; 113 | 114 | let err_tx = err_tx.clone(); 115 | 116 | let cb = Box::new(move |siv: &mut Cursive| match event { 117 | LibraryEvent::Init(library) => match library.audio_count() { 118 | 0 => _ = err_tx.send(anyhow!("No audio found: {:?}", library.root)), 119 | 1 => init_player_view(library, siv, err_tx), 120 | _ => init_finder_view(library, siv, err_tx), 121 | }, 122 | LibraryEvent::Batch(mut batch) => update_library(&mut batch, siv), 123 | LibraryEvent::Finished(opt_library) => { 124 | set_library(opt_library, siv, err_tx.clone()); 125 | set_global_callbacks(siv); 126 | check_tui(siv, err_tx); 127 | } 128 | }); 129 | 130 | _ = cb_sink.send(cb); 131 | 132 | if is_finished { 133 | break; 134 | } 135 | } 136 | }); 137 | } 138 | 139 | fn init_player_view(library: Library, siv: &mut Cursive, err_tx: Sender) { 140 | library 141 | .first_playlist() 142 | .and_then(Player::try_new) 143 | .map(|player| PlayerView::load(siv, player)) 144 | .unwrap_or_else(|err| _ = err_tx.send(anyhow!(err))) 145 | } 146 | 147 | fn init_finder_view(library: Library, siv: &mut Cursive, err_tx: Sender) { 148 | siv.set_user_data(library); 149 | FinderView::load(LibraryFilter::Unfiltered) 150 | .map(|e| e.process(siv)) 151 | .unwrap_or_else(|| _ = err_tx.send(anyhow!("Failed to load library"))); 152 | siv.call_on_name(F_ID, |fv: &mut FinderView| { 153 | fv.set_init_timestamp(Some(Instant::now())) 154 | }); 155 | } 156 | 157 | fn update_library(batch: &mut Vec, siv: &mut Cursive) { 158 | siv.with_user_data(|library: &mut Library| { 159 | library.fdirs.extend(batch.iter().cloned()); 160 | }); 161 | siv.call_on_name(F_ID, |fv: &mut FinderView| { 162 | fv.update_library(batch); 163 | }); 164 | } 165 | 166 | fn set_library(full_library: Option, siv: &mut Cursive, err_tx: Sender) { 167 | if let Some(full_library) = full_library { 168 | if siv.user_data::().is_some() { 169 | siv.set_user_data(full_library.clone()); 170 | siv.call_on_name(F_ID, |fv: &mut FinderView| { 171 | fv.set_library(full_library); 172 | }); 173 | } else { 174 | init_finder_view(full_library, siv, err_tx); 175 | } 176 | } 177 | siv.call_on_name(F_ID, |fv: &mut FinderView| fv.set_init_timestamp(None)); 178 | } 179 | 180 | fn spawn_err_handle( 181 | err_rx: Receiver, 182 | err_state: Arc>>, 183 | cb_sink: CbSink, 184 | ) -> JoinHandle<()> { 185 | thread::spawn(move || { 186 | if let Ok(err) = err_rx.recv() { 187 | *err_state.lock().unwrap() = Some(err); 188 | _ = cb_sink.send(Box::new(|siv: &mut Cursive| { 189 | siv.quit(); 190 | })); 191 | } 192 | }) 193 | } 194 | 195 | fn check_tui(siv: &mut Cursive, err_tx: Sender) { 196 | if siv.find_name::(P_ID).is_some() { 197 | return; 198 | } 199 | 200 | if siv.find_name::(F_ID).is_some() { 201 | return; 202 | } 203 | 204 | let path = siv 205 | .user_data::() 206 | .map(|library| library.root.display().to_string()) 207 | .unwrap_or_else(|| "path".to_owned()); 208 | 209 | _ = err_tx.send(anyhow!("Error loading {:?}", path)); 210 | } 211 | 212 | fn set_global_callbacks(siv: &mut Cursive) { 213 | if siv.user_data::().is_none() { 214 | return; 215 | } 216 | 217 | siv.set_on_pre_event('-', PlayerView::previous_album); 218 | siv.set_on_pre_event('=', PlayerView::random_album); 219 | siv.set_on_pre_event_inner(unfiltered_trigger(), FinderView::all); 220 | siv.set_on_pre_event_inner(fn_keys(), FinderView::depth); 221 | siv.set_on_pre_event_inner(uppercase_chars(), FinderView::key); 222 | siv.set_on_pre_event_inner(Event::CtrlChar('a'), FinderView::artist); 223 | siv.set_on_pre_event_inner(Event::CtrlChar('d'), FinderView::album); 224 | siv.set_on_pre_event_inner(Event::Char('`'), FinderView::parent); 225 | siv.set_on_pre_event(Event::CtrlChar('o'), open_file_manager); 226 | siv.set_on_pre_event(Event::CtrlChar('q'), |siv| siv.quit()); 227 | } 228 | 229 | fn fn_keys() -> EventTrigger { 230 | EventTrigger::from_fn(|event| { 231 | matches!( 232 | event, 233 | Event::Key(Key::F1) | Event::Key(Key::F2) | Event::Key(Key::F3) | Event::Key(Key::F4) 234 | ) 235 | }) 236 | } 237 | 238 | fn uppercase_chars() -> EventTrigger { 239 | EventTrigger::from_fn(|event| matches!(event, Event::Char('A'..='Z'))) 240 | } 241 | 242 | fn unfiltered_trigger() -> EventTrigger { 243 | EventTrigger::from_fn(|event| { 244 | matches!( 245 | event, 246 | Event::Key(Key::Tab) 247 | | Event::Mouse { 248 | event: MouseEvent::Press(MouseButton::Middle), 249 | .. 250 | } 251 | ) 252 | }) 253 | } 254 | 255 | fn open_file_manager(siv: &mut Cursive) { 256 | let opt_path = siv 257 | .call_on_name(P_ID, |pv: &mut PlayerView| { 258 | Some(pv.current_dir().path.clone()) 259 | }) 260 | .or_else(|| { 261 | siv.call_on_name(F_ID, |fv: &mut FinderView| { 262 | fv.selected_dir().map(|dir| dir.path) 263 | }) 264 | }) 265 | .flatten(); 266 | 267 | let arg = match opt_path.as_ref().map(|p| p.as_os_str().to_str()).flatten() { 268 | Some(p) => p, 269 | None => return, 270 | }; 271 | 272 | let command = match std::env::consts::OS { 273 | "macos" => "open", 274 | "linux" => "xdg-open", 275 | _ => return, 276 | }; 277 | 278 | _ = std::process::Command::new(command).arg(arg).status(); 279 | } 280 | -------------------------------------------------------------------------------- /src/player/audio_file.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::Ordering, collections::HashSet, fs::File, io::BufReader, path::PathBuf}; 2 | 3 | use { 4 | anyhow::{anyhow, bail}, 5 | lofty::{ 6 | prelude::{Accessor, AudioFile as LoftyAudioFile, TaggedFileExt}, 7 | probe::Probe, 8 | }, 9 | once_cell::sync::Lazy, 10 | rodio::Decoder, 11 | }; 12 | 13 | use crate::TapError; 14 | 15 | pub static AUDIO_FORMATS: Lazy> = Lazy::new(|| { 16 | let mut m = HashSet::new(); 17 | m.insert("aac"); 18 | m.insert("flac"); 19 | m.insert("mp3"); 20 | m.insert("m4a"); 21 | m.insert("ogg"); 22 | m.insert("wav"); 23 | m.insert("wma"); 24 | m 25 | }); 26 | 27 | // A struct representing metadata for an audio file. 28 | #[derive(Clone, Debug, Eq, PartialEq, Ord)] 29 | pub struct AudioFile { 30 | // The file path to the audio file. 31 | pub path: PathBuf, 32 | // The title of the audio track. 33 | pub title: String, 34 | // The artist that performed the track. 35 | pub artist: String, 36 | // The album the track belongs to. 37 | pub album: String, 38 | // The release year of the track, if available. 39 | pub year: Option, 40 | // The track number on the album. 41 | pub track: u32, 42 | // The duration of the track in seconds. 43 | pub duration: usize, 44 | } 45 | 46 | impl AudioFile { 47 | pub fn new(path: PathBuf) -> Result { 48 | let tagged_file = Probe::open(&path) 49 | .map_err(|e| anyhow!("faied to probe {:?}: {}", path, e))? 50 | .read() 51 | .map_err(|e| anyhow!("failed to read {:?}: {}", path, e))?; 52 | 53 | let tag = tagged_file 54 | .primary_tag() 55 | .or_else(|| tagged_file.first_tag()) 56 | .ok_or_else(|| anyhow!("no tags found for {:?}", path))?; 57 | 58 | let audio_file = Self { 59 | artist: tag.artist().as_deref().unwrap_or("None").trim().to_string(), 60 | album: tag.album().as_deref().unwrap_or("None").trim().to_string(), 61 | title: tag.title().as_deref().unwrap_or("None").trim().to_string(), 62 | year: tag.year(), 63 | track: tag.track().unwrap_or(0), 64 | duration: tagged_file.properties().duration().as_secs() as usize, 65 | path, 66 | }; 67 | 68 | Ok(audio_file) 69 | } 70 | 71 | pub fn decode(&self) -> Result>, TapError> { 72 | let source = match File::open(self.path.clone()) { 73 | Ok(inner) => match Decoder::new(BufReader::new(inner)) { 74 | Ok(s) => s, 75 | Err(_) => bail!("could not decode {:?}", self.path), 76 | }, 77 | Err(_) => bail!("could not open {:?}", self.path), 78 | }; 79 | Ok(source) 80 | } 81 | 82 | // Checks if the given file path has a valid audio file extension. 83 | pub fn validate_format(p: &PathBuf) -> bool { 84 | let ext = p.extension().unwrap_or_default().to_str().unwrap(); 85 | AUDIO_FORMATS.contains(&ext) 86 | } 87 | } 88 | 89 | impl PartialOrd for AudioFile { 90 | // Sorts `AudioFile` instances by album, track number, and finally by title if needed. 91 | fn partial_cmp(&self, other: &Self) -> Option { 92 | Some( 93 | self.album 94 | .cmp(&other.album) 95 | .then(match self.track == other.track { 96 | true => self.title.cmp(&other.title), 97 | false => self.track.cmp(&other.track), 98 | }), 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/player/help_view.rs: -------------------------------------------------------------------------------- 1 | use cursive::{ 2 | event::{Event, EventTrigger, MouseEvent}, 3 | view::Resizable, 4 | views::{ 5 | Dialog, DummyView, LinearLayout, ListView, OnEventView, PaddedView, ScrollView, TextView, 6 | }, 7 | Cursive, 8 | }; 9 | 10 | // A Cursive view for displaying the keybindings. 11 | pub struct HelpView; 12 | 13 | impl HelpView { 14 | fn new() -> ScrollView> { 15 | ScrollView::new(PaddedView::lrtb( 16 | 2, 17 | 2, 18 | 0, 19 | 0, 20 | LinearLayout::vertical() 21 | .child( 22 | Dialog::new().title("Global").content( 23 | ListView::new() 24 | .child("fuzzy search:", TextView::new("Tab")) 25 | .child("depth search:", TextView::new("F1...F4")) 26 | .child("filtered search:", TextView::new("A-Z")) 27 | .child("artist search:", TextView::new("Ctrl + a")) 28 | .child("album search:", TextView::new("Ctrl + s")) 29 | .child("parent search:", TextView::new("Ctrl + p")) 30 | .child("previous album:", TextView::new("-")) 31 | .child("random album:", TextView::new("=")) 32 | .child("open file manager:", TextView::new("Ctrl + o")), 33 | ), 34 | ) 35 | .child(DummyView.fixed_height(1)) 36 | .child( 37 | Dialog::new().title("Player").content( 38 | ListView::new() 39 | .child("play or pause:", TextView::new("h or ← or Space")) 40 | .child("next:", TextView::new("j or n or ↓")) 41 | .child("previous:", TextView::new("k or p or ↑")) 42 | .child("stop:", TextView::new("l or → or Enter")) 43 | .child("step << / >>:", TextView::new(", / .")) 44 | .child("seek to sec", TextView::new("0-9 + \"")) 45 | .child("seek to min", TextView::new("0-9 + \'")) 46 | .child("random:", TextView::new("* or r")) 47 | .child("shuffle:", TextView::new("~ or s")) 48 | .child("volume down / up:", TextView::new("[ / ]")) 49 | .child("show volume:", TextView::new("v")) 50 | .child("mute:", TextView::new("m")) 51 | .child("go to first track:", TextView::new("gg")) 52 | .child("go to last track:", TextView::new("Ctrl + g")) 53 | .child("go to track number:", TextView::new("0-9, g")) 54 | .child("help:", TextView::new("?")) 55 | .child("quit:", TextView::new("q")), 56 | ), 57 | ) 58 | .child(DummyView.fixed_height(1)) 59 | .child( 60 | Dialog::new().title("Fuzzy").content( 61 | ListView::new() 62 | .child("clear search:", TextView::new("Ctrl + u")) 63 | .child("cancel search:", TextView::new("Esc")) 64 | .child("page up:", TextView::new("Ctrl + h or PgUp")) 65 | .child("page down:", TextView::new("Ctrl + l or PgDn")) 66 | .child("random page:", TextView::new("Ctrl + z")), 67 | ), 68 | ), 69 | )) 70 | .show_scrollbars(true) 71 | } 72 | 73 | pub fn load(siv: &mut Cursive) { 74 | siv.add_layer( 75 | OnEventView::new(Self::new()).on_event(Self::trigger(), |siv| { 76 | siv.pop_layer(); 77 | }), 78 | ) 79 | } 80 | 81 | fn trigger() -> EventTrigger { 82 | EventTrigger::from_fn(|event| { 83 | matches!( 84 | event, 85 | Event::Char(_) 86 | | Event::Key(_) 87 | | Event::Mouse { 88 | event: MouseEvent::Press(_), 89 | .. 90 | } 91 | ) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/player/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod audio_file; 2 | pub mod help_view; 3 | pub mod player_view; 4 | pub mod playlist; 5 | 6 | pub use self::{ 7 | audio_file::AudioFile, help_view::HelpView, player_view::PlayerView, playlist::Playlist, 8 | }; 9 | 10 | use std::time::{Duration, Instant}; 11 | 12 | use rodio::{OutputStream, Sink}; 13 | 14 | use crate::TapError; 15 | 16 | pub const ID: &str = "player"; 17 | 18 | // Enum representing the playback status of the audio player. 19 | #[derive(Clone, Copy, Debug, PartialEq)] 20 | pub enum PlaybackStatus { 21 | Playing, 22 | Paused, 23 | Stopped, 24 | } 25 | 26 | // A struct representing an audio player that manages playback of audio files 27 | // and maintains the state related to playback. 28 | pub struct Player { 29 | // The currently loaded playlist. 30 | pub current: Playlist, 31 | // The previously loaded playlist, if any. 32 | pub previous: Option, 33 | // The current volume as a percentage, in range 0..=120. 34 | pub volume: u8, 35 | // Whether the player is muted or not. 36 | pub is_muted: bool, 37 | // Whether or not the next track will be selected randomly. 38 | pub is_randomized: bool, 39 | // Whether or not the current playlist order is shuffled. 40 | pub is_shuffled: bool, 41 | // Whether or not the next track is queued. 42 | pub next_track_queued: bool, 43 | // Whether the player is playing, paused or stopped. 44 | pub status: PlaybackStatus, 45 | // The instant that playback started or resumed. 46 | last_started: Instant, 47 | // The instant that the player was paused. Reset when player is stopped. 48 | last_elapsed: Duration, 49 | // Handle to the audio sink. 50 | pub sink: Sink, 51 | // Reference to the output stream. 52 | _stream: OutputStream, 53 | } 54 | 55 | impl Player { 56 | pub fn try_new(playlist: Playlist) -> Result { 57 | let (_stream, _stream_handle) = OutputStream::try_default()?; 58 | let sink = Sink::try_new(&_stream_handle)?; 59 | 60 | let mut player = Self { 61 | current: playlist, 62 | previous: None, 63 | volume: 100, 64 | is_muted: false, 65 | is_randomized: false, 66 | is_shuffled: false, 67 | next_track_queued: false, 68 | status: PlaybackStatus::Stopped, 69 | last_started: Instant::now(), 70 | last_elapsed: Duration::ZERO, 71 | sink, 72 | _stream, 73 | }; 74 | 75 | player.play(); 76 | 77 | Ok(player) 78 | } 79 | 80 | pub fn play(&mut self) { 81 | if let Ok(source) = self.current_file().decode() { 82 | self.sink.append(source); 83 | self.sink.play(); 84 | self.status = PlaybackStatus::Playing; 85 | self.last_started = Instant::now(); 86 | } else { 87 | self.increment_track() 88 | } 89 | } 90 | 91 | // Resumes a paused sink and records the start time. 92 | pub fn resume(&mut self) { 93 | self.sink.play(); 94 | self.status = PlaybackStatus::Playing; 95 | self.last_started = Instant::now(); 96 | } 97 | 98 | // Pauses the sink and records the elapsed time. 99 | pub fn pause(&mut self) { 100 | self.last_elapsed = self.elapsed(); 101 | self.sink.pause(); 102 | self.status = PlaybackStatus::Paused; 103 | } 104 | 105 | // Empties the sink, clears the current inputs and elapsed time. 106 | pub fn stop(&mut self) { 107 | self.next_track_queued = false; 108 | self.sink.stop(); 109 | self.status = PlaybackStatus::Stopped; 110 | self.last_elapsed = Duration::ZERO; 111 | } 112 | 113 | // Starts playback if not playing, pauses otherwise. 114 | pub fn play_or_pause(&mut self) { 115 | match self.status { 116 | PlaybackStatus::Paused => self.resume(), 117 | PlaybackStatus::Playing => self.pause(), 118 | PlaybackStatus::Stopped => self.play(), 119 | }; 120 | } 121 | 122 | // Play the last track in the current playlist. 123 | pub fn play_last_track(&mut self) { 124 | let last = self.current.audio_files.len().saturating_sub(1); 125 | self.play_index(last); 126 | } 127 | 128 | // Skip to next track in the playlist. 129 | pub fn increment_track(&mut self) { 130 | if !self.current.is_last_track() { 131 | self.current.index += 1; 132 | } 133 | 134 | let is_stopped = self.is_stopped(); 135 | self.next_track_queued = false; 136 | self.stop(); 137 | 138 | if !is_stopped { 139 | self.play(); 140 | } 141 | } 142 | 143 | // Skip to previous track in the playlist. 144 | pub fn decrement_track(&mut self) { 145 | let is_stopped = self.is_stopped(); 146 | self.current.index = self.current.index.saturating_sub(1); 147 | self.next_track_queued = false; 148 | self.stop(); 149 | 150 | if !is_stopped { 151 | self.play(); 152 | } 153 | } 154 | 155 | // Increases volume by 10%, to maximum of 120%. 156 | pub fn increment_volume(&mut self) { 157 | if self.volume < 120 { 158 | self.volume += 10; 159 | if !self.is_muted { 160 | self.sink.set_volume(self.volume as f32 / 100.0); 161 | } 162 | } 163 | } 164 | 165 | // Decreases volume by 10%, to minimum of 0%. 166 | pub fn decrement_volume(&mut self) { 167 | if self.volume > 0 { 168 | self.volume -= 10; 169 | if !self.is_muted { 170 | self.sink.set_volume(self.volume as f32 / 100.0); 171 | } 172 | } 173 | } 174 | 175 | // Toggles the `is_muted` state and adjusts the volume accordingly. 176 | pub fn toggle_mute(&mut self) { 177 | self.is_muted ^= true; 178 | self.sink.set_volume(if self.is_muted { 179 | 0.0 180 | } else { 181 | self.volume as f32 / 100.0 182 | }); 183 | } 184 | 185 | // Toggles `is_randomized` and removes the current next 186 | // track from the sink when `is_randomized` is true. 187 | pub fn toggle_randomize(&mut self) { 188 | self.is_randomized ^= true; 189 | self.is_shuffled = false; 190 | self.next_track_queued = false; 191 | 192 | while self.is_randomized && self.sink.len() > 1 { 193 | self.sink.pop(); 194 | } 195 | } 196 | 197 | // Toggles `is_randomized` and removes the current next 198 | // track from the sink when `is_randomized` is true. 199 | pub fn toggle_shuffle(&mut self) { 200 | self.is_shuffled ^= true; 201 | self.is_randomized = false; 202 | 203 | self.next_track_queued = false; 204 | 205 | while self.is_shuffled && self.sink.len() > 1 { 206 | self.sink.pop(); 207 | } 208 | } 209 | 210 | // Seeks the playback to the provided seek_time, in seconds. 211 | pub fn seek_to_time(&mut self, seek_time: Duration) { 212 | let elapsed = self.elapsed(); 213 | if seek_time < elapsed { 214 | let diff = elapsed - seek_time; 215 | self.seek_backward(diff); 216 | } else { 217 | let diff = seek_time - elapsed; 218 | self.seek_forward(diff); 219 | } 220 | } 221 | 222 | // Performs the seek operation in the forward direction. 223 | pub fn seek_forward(&mut self, seek: Duration) { 224 | if !self.is_playing() { 225 | self.play_or_pause(); 226 | } 227 | 228 | let elapsed = self.elapsed(); 229 | let duration = Duration::new(self.current_file().duration as u64, 0); 230 | 231 | if duration - elapsed < seek + Duration::new(0, 500) { 232 | if self.current.is_last_track() { 233 | self.stop(); 234 | } else { 235 | self.increment_track() 236 | } 237 | } else { 238 | let future = elapsed + seek; 239 | if self.sink.try_seek(future).is_ok() { 240 | self.last_started -= seek; 241 | } 242 | } 243 | } 244 | 245 | // Performs the seek operation in the backward direction. 246 | pub fn seek_backward(&mut self, seek: Duration) { 247 | if !self.is_playing() { 248 | self.play_or_pause(); 249 | } 250 | 251 | let elapsed = self.elapsed(); 252 | 253 | if elapsed < seek + Duration::new(0, 500) { 254 | self.stop(); 255 | self.play(); 256 | } else { 257 | let time = elapsed - seek; 258 | if self.sink.try_seek(time).is_ok() { 259 | if self.last_elapsed == Duration::ZERO { 260 | self.last_started += seek; 261 | } else if self.last_elapsed >= seek { 262 | self.last_elapsed -= seek; 263 | } else { 264 | let diff = seek - self.last_elapsed; 265 | self.last_elapsed = Duration::ZERO; 266 | self.last_started += diff; 267 | } 268 | } 269 | } 270 | } 271 | 272 | pub fn is_empty(&self) -> bool { 273 | self.sink.empty() 274 | } 275 | 276 | // The time elapsed during playback. 277 | #[inline] 278 | pub fn elapsed(&self) -> Duration { 279 | self.last_elapsed 280 | + if self.is_playing() { 281 | Instant::now() - self.last_started 282 | } else { 283 | Duration::default() 284 | } 285 | } 286 | 287 | // Checks and updates the playback state based on the 288 | // current state of the sink and the tracks queued. 289 | pub fn update_on_poll(&mut self) { 290 | if !self.is_playing() { 291 | return; 292 | } 293 | 294 | if self.sink.len() == 1 { 295 | if self.next_track_queued { 296 | self.last_started = Instant::now(); 297 | self.last_elapsed = Duration::ZERO; 298 | self.current.index += 1; 299 | self.next_track_queued = false; 300 | } else if let Some(next) = self.current.get_next_track() { 301 | if let Ok(source) = next.decode() { 302 | self.sink.append(source); 303 | self.next_track_queued = true; 304 | } 305 | } 306 | } else if self.sink.empty() { 307 | self.stop(); 308 | } 309 | } 310 | 311 | pub fn current_file(&self) -> &AudioFile { 312 | &self.current.audio_files[self.current.index] 313 | } 314 | 315 | // Whether the player is playing or not. 316 | pub fn is_playing(&self) -> bool { 317 | self.status == PlaybackStatus::Playing 318 | } 319 | 320 | // Whether the player is paused or not. 321 | pub fn is_paused(&self) -> bool { 322 | self.status == PlaybackStatus::Paused 323 | } 324 | 325 | // Whether the player is stopped or not. 326 | pub fn is_stopped(&self) -> bool { 327 | self.status == PlaybackStatus::Stopped 328 | } 329 | 330 | pub fn play_index(&mut self, index: usize) { 331 | self.stop(); 332 | self.current.index = index; 333 | self.next_track_queued = false; 334 | self.play(); 335 | } 336 | } 337 | 338 | impl Clone for Player { 339 | fn clone(&self) -> Self { 340 | let (_stream, _stream_handle) = OutputStream::try_default().expect("Stream reinit"); 341 | let sink = Sink::try_new(&_stream_handle).expect("Sink reinit"); 342 | 343 | Player { 344 | current: self.current.clone(), 345 | previous: self.previous.clone(), 346 | volume: self.volume, 347 | is_muted: self.is_muted, 348 | is_randomized: self.is_randomized, 349 | is_shuffled: self.is_shuffled, 350 | next_track_queued: self.next_track_queued, 351 | status: self.status.clone(), 352 | last_started: Instant::now(), 353 | last_elapsed: self.last_elapsed, 354 | sink, 355 | _stream, 356 | } 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/player/player_view.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use { 4 | cursive::{ 5 | event::{Event, EventResult, MouseButton, MouseEvent}, 6 | theme::{ColorStyle, Effect}, 7 | traits::View, 8 | view::Nameable, 9 | CbSink, Cursive, Printer, XY, 10 | }, 11 | expiring_bool::ExpiringBool, 12 | }; 13 | 14 | use crate::{ 15 | config::{ 16 | keybinding::{Action, PLAYER_EVENT_TO_ACTION}, 17 | ColorStyles, 18 | }, 19 | finder::{FuzzyDir, Library}, 20 | player::{AudioFile, HelpView, PlaybackStatus, Player, Playlist}, 21 | }; 22 | 23 | const SEEK_TIME: Duration = Duration::from_secs(10); 24 | 25 | // A struct representing the view and state of the audio player. 26 | pub struct PlayerView { 27 | // The `AudioPlayer` instance responsible for handling audio playback. 28 | player: Player, 29 | // The time to seek to, in seconds. Set to `Some` when a seek operation has been initiated. 30 | mouse_seek_time: Option, 31 | // Whether the current volume level is displayed in the UI, managed with an `ExpiringBool`. 32 | showing_volume: ExpiringBool, 33 | // The list of numbers from last keyboard input. 34 | number_input: Vec, 35 | // Whether or not a double-tap event was registered. 36 | timed_bool: ExpiringBool, 37 | // The vertical offset required to ensure the current track is visible in the playlist. 38 | offset_y: usize, 39 | // The dimensions of the view, in cells. 40 | size: XY, 41 | // A sender for scheduling callbacks to be executed by the Cursive root. 42 | cb_sink: CbSink, 43 | } 44 | 45 | impl PlayerView { 46 | pub fn new(player: Player, cb_sink: CbSink) -> Self { 47 | Self { 48 | player, 49 | cb_sink, 50 | mouse_seek_time: None, 51 | offset_y: 0, 52 | showing_volume: ExpiringBool::new(false, Duration::from_millis(1500)), 53 | number_input: vec![], 54 | timed_bool: ExpiringBool::new(false, Duration::from_millis(500)), 55 | size: XY::default(), 56 | } 57 | } 58 | 59 | pub fn load(siv: &mut Cursive, player: Player) { 60 | let cb_sink = siv.cb_sink().clone(); 61 | let player_view = PlayerView::new(player, cb_sink).with_name(super::ID); 62 | 63 | siv.pop_layer(); 64 | siv.add_layer(player_view); 65 | } 66 | 67 | pub fn update_playlist(&mut self, next: Playlist, set_playing: bool) { 68 | let is_stopped = self.player.is_stopped(); 69 | self.player.stop(); 70 | self.player.previous = Some(self.player.current.clone()); 71 | self.player.current = next; 72 | self.player.play(); 73 | 74 | if !set_playing && is_stopped { 75 | self.player.stop(); 76 | } 77 | } 78 | 79 | fn increase_volume(&mut self) { 80 | self.player.increment_volume(); 81 | self.showing_volume.set(); 82 | } 83 | 84 | fn decrease_volume(&mut self) { 85 | self.player.decrement_volume(); 86 | self.showing_volume.set(); 87 | } 88 | 89 | fn next(&mut self) { 90 | if self.player.is_randomized { 91 | self.random_track_and_album(); 92 | } else if self.player.is_shuffled { 93 | self.shuffled_track(); 94 | } else { 95 | self.player.increment_track(); 96 | } 97 | } 98 | 99 | fn previous(&mut self) { 100 | if self.player.is_randomized || self.player.is_shuffled { 101 | self.player 102 | .previous 103 | .clone() 104 | .map(|next| self.update_playlist(next, false)); 105 | } else { 106 | self.player.decrement_track(); 107 | } 108 | } 109 | 110 | // Selects a random track from a random album. 111 | fn random_track_and_album(&self) { 112 | let mut current = self.player.current.clone(); 113 | 114 | _ = self.cb_sink.send(Box::new(|siv| { 115 | let next = match siv.user_data::() { 116 | Some(library) => { 117 | let dirs = library.audio_dirs(); 118 | Playlist::randomized_track(current, &dirs) 119 | } 120 | None => { 121 | current.set_random_index(); 122 | current 123 | } 124 | }; 125 | 126 | siv.call_on_name(super::ID, |player_view: &mut PlayerView| { 127 | player_view.update_playlist(next, false); 128 | }); 129 | })); 130 | } 131 | 132 | // Selects a random track from the current playlist. 133 | fn shuffled_track(&mut self) { 134 | let mut current = self.player.current.clone(); 135 | current.set_random_index(); 136 | self.update_playlist(current, false); 137 | } 138 | 139 | // Callback to select the previous album. 140 | pub fn previous_album(siv: &mut Cursive) { 141 | siv.call_on_name(super::ID, |player_view: &mut PlayerView| { 142 | player_view.player.previous.clone().map(|mut previous| { 143 | previous.index = 0; 144 | player_view.update_playlist(previous, false) 145 | }); 146 | }); 147 | 148 | crate::finder::FinderView::remove(siv); 149 | } 150 | 151 | // Callback to select a random album. 152 | pub fn random_album(siv: &mut Cursive) { 153 | let dirs = match siv.user_data::() { 154 | Some(library) => library.audio_dirs(), 155 | None => return, 156 | }; 157 | 158 | siv.call_on_name(super::ID, |player_view: &mut PlayerView| { 159 | let current = player_view.player.current.clone(); 160 | let next = Playlist::randomized(current, &dirs); 161 | player_view.update_playlist(next, false); 162 | }) 163 | .unwrap_or_else(|| { 164 | Playlist::some_randomized(&dirs) 165 | .and_then(|next| Player::try_new(next).ok()) 166 | .map(|player| PlayerView::load(siv, player)); 167 | }); 168 | 169 | crate::finder::FinderView::remove(siv); 170 | } 171 | 172 | // Play the track selected from keyboard input. 173 | fn play_track_number(&mut self) { 174 | if let Some(index) = self.map_input_to_index() { 175 | self.player.play_index(index); 176 | } else { 177 | if self.timed_bool.is_true() { 178 | self.player.play_index(0); 179 | } else { 180 | self.timed_bool.set(); 181 | } 182 | } 183 | } 184 | 185 | fn map_input_to_index(&mut self) -> Option { 186 | let track = concatenate_digits(&self.number_input) as u32; 187 | 188 | let index = self 189 | .player 190 | .current 191 | .audio_files 192 | .iter() 193 | .position(|f| f.track == track); 194 | 195 | self.number_input.clear(); 196 | 197 | index 198 | } 199 | 200 | fn parse_seek_input(&mut self) -> Option { 201 | if self.number_input.is_empty() { 202 | return None; 203 | } else { 204 | let time = concatenate_digits(&self.number_input) as u64; 205 | self.number_input.clear(); 206 | 207 | Some(Duration::new(time, 0)) 208 | } 209 | } 210 | 211 | // Seeks the playback to the input time in seconds. 212 | fn seek_to_sec(&mut self) { 213 | self.parse_seek_input().map(|secs| { 214 | self.player.seek_to_time(secs); 215 | }); 216 | } 217 | 218 | // Seeks the playback to the input time in minutes. 219 | fn seek_to_min(&mut self) { 220 | self.parse_seek_input().map(|secs| { 221 | self.player.seek_to_time(secs * 60); 222 | }); 223 | } 224 | 225 | // Handles the mouse left button press actions. 226 | fn mouse_button_left(&mut self, offset: XY, position: XY) { 227 | match Area::from(position, offset, self.size) { 228 | Area::ProgressBar => self.mouse_hold_seek(offset, position), 229 | 230 | Area::Playlist => { 231 | let index = position.y - offset.y + self.offset_y - 1; 232 | 233 | if index == self.player.current.index { 234 | self.player.play_or_pause(); 235 | } else { 236 | self.player.play_index(index); 237 | } 238 | } 239 | Area::Background => _ = self.player.play_or_pause(), 240 | } 241 | } 242 | 243 | // Updates the seek position from mouse input. 244 | fn mouse_hold_seek(&mut self, offset: XY, position: XY) { 245 | if self.size.x > 16 && position.x > offset.x { 246 | if self.player.is_stopped() { 247 | self.player.play(); 248 | } 249 | self.player.pause(); 250 | let position = (position.x - offset.x).clamp(8, self.size.x - 8) - 8; 251 | self.mouse_seek_time = Some(position * self.duration() / (self.size.x - 16)); 252 | } 253 | } 254 | 255 | // Performs the seek operation from mouse input. 256 | fn mouse_release_seek(&mut self) { 257 | if let Some(secs) = self.mouse_seek_time { 258 | let seek_time = Duration::new(secs as u64, 0); 259 | self.player.seek_to_time(seek_time); 260 | } 261 | self.mouse_seek_time = None; 262 | } 263 | 264 | // Handles the mouse wheel (scrolling) actions. 265 | fn mouse_wheel(&mut self, event: MouseEvent, offset: XY, position: XY) { 266 | match Area::from(position, offset, self.size) { 267 | Area::Playlist => match event { 268 | MouseEvent::WheelUp => self.player.decrement_track(), 269 | MouseEvent::WheelDown => self.player.increment_track(), 270 | _ => (), 271 | }, 272 | _ => match event { 273 | MouseEvent::WheelUp => self.increase_volume(), 274 | MouseEvent::WheelDown => self.decrease_volume(), 275 | _ => (), 276 | }, 277 | } 278 | } 279 | 280 | #[inline] 281 | pub fn current_dir(&self) -> &FuzzyDir { 282 | &self.player.current.fdir 283 | } 284 | 285 | #[inline] 286 | pub fn duration(&self) -> usize { 287 | self.player.current_file().duration 288 | } 289 | 290 | // Formats the display for the current playback status. 291 | #[inline] 292 | fn playback_status(&self) -> (&'static str, ColorStyle, Effect) { 293 | match self.player.status { 294 | PlaybackStatus::Paused => ("|", ColorStyles::hl(), Effect::Simple), 295 | PlaybackStatus::Playing => (">", ColorStyles::header_2(), Effect::Simple), 296 | PlaybackStatus::Stopped => (".", ColorStyles::err(), Effect::Simple), 297 | } 298 | } 299 | 300 | // Formats the display showing whether the player is muted or randomized. 301 | #[inline] 302 | fn playback_opts(&self) -> &'static str { 303 | match ( 304 | self.player.is_randomized, 305 | self.player.is_shuffled, 306 | self.player.is_muted, 307 | ) { 308 | (true, false, true) => " *m", 309 | (false, true, true) => " ~m", 310 | (true, false, false) => " *", // is_randomized 311 | (false, true, false) => " ~", // is_shuffled 312 | (false, false, true) => " m", //is_muted 313 | _ => unreachable!(), 314 | } 315 | } 316 | 317 | // Formats the player header. 318 | #[inline] 319 | fn album_and_year(&self, f: &AudioFile) -> String { 320 | if let Some(year) = f.year { 321 | format!("{} ({})", f.album, year) 322 | } else { 323 | f.album.to_string() 324 | } 325 | } 326 | 327 | // Formats the volume display. 328 | #[inline] 329 | fn volume(&self) -> String { 330 | format!(" vol: {:>3} %", self.player.volume) 331 | } 332 | 333 | // The elapsed playback time to display. When seeking with the mouse we use the 334 | // elapsed time had the seeking process completed. 335 | #[inline] 336 | fn elapsed(&self) -> usize { 337 | match self.mouse_seek_time { 338 | Some(t) if self.player.is_paused() => t, 339 | _ => self.player.elapsed().as_secs() as usize, 340 | } 341 | } 342 | 343 | // Computes the y offset needed to show the results of the fuzzy match. 344 | #[inline] 345 | fn update_offset(&self) -> usize { 346 | let index = self.player.current.index; 347 | let available_y = self.size.y; 348 | let required_y = self.player.current.audio_files.len() + 2; 349 | let offset = required_y.saturating_sub(available_y); 350 | 351 | std::cmp::min(index, offset) 352 | } 353 | } 354 | 355 | impl View for PlayerView { 356 | fn layout(&mut self, size: XY) { 357 | if self.player.is_playing() { 358 | if self.player.is_randomized { 359 | if self.player.is_empty() { 360 | self.random_track_and_album() 361 | } 362 | } else if self.player.is_shuffled { 363 | if self.player.is_empty() { 364 | self.shuffled_track() 365 | } 366 | } else { 367 | self.player.update_on_poll(); 368 | } 369 | } 370 | 371 | self.size = self.required_size(size); 372 | self.offset_y = self.update_offset(); 373 | } 374 | 375 | fn required_size(&mut self, constraint: cursive::Vec2) -> cursive::Vec2 { 376 | let player_size = self.player.current.xy_size; 377 | 378 | let size = XY { 379 | x: std::cmp::min(player_size.x, constraint.x), 380 | y: std::cmp::min(player_size.y, constraint.y), 381 | }; 382 | 383 | size 384 | } 385 | 386 | fn draw(&self, p: &Printer) { 387 | let (w, h) = (self.size.x, self.size.y); 388 | let f = self.player.current_file(); 389 | let duration_column = w.saturating_sub(9); 390 | let elapsed = self.elapsed(); 391 | let (length, extra) = ratio(elapsed, f.duration, w.saturating_sub(16)); 392 | 393 | let p = p.cropped((w.saturating_sub(2), h)); 394 | 395 | // Draw the header: 'Artist, Album, Year'. 396 | if h > 1 { 397 | p.with_effect(Effect::Bold, |p| { 398 | p.with_color(ColorStyles::header_1(), |p| p.print((2, 0), &f.artist)); 399 | p.with_effect(Effect::Italic, |p| { 400 | p.with_color(ColorStyles::header_2(), |p| { 401 | p.print((f.artist.len() + 4, 0), &self.album_and_year(f)) 402 | }) 403 | }) 404 | }); 405 | 406 | // Draw the current volume. 407 | if self.showing_volume.is_true() { 408 | p.with_color(ColorStyles::prompt(), |p| { 409 | p.print((duration_column.saturating_sub(5), 0), &self.volume()) 410 | }); 411 | }; 412 | } 413 | 414 | // Draw the playlist, with rows: 'Track, Title, Duration'. 415 | if h > 2 { 416 | for (i, f) in self 417 | .player 418 | .current 419 | .audio_files 420 | .iter() 421 | .enumerate() 422 | .skip(self.offset_y) 423 | .take(h - 2) 424 | { 425 | let current_row = i + 1 - self.offset_y; 426 | 427 | if i == self.player.current.index { 428 | // Draw the playback status. 429 | let (symbol, color, effect) = self.playback_status(); 430 | p.with_color(color, |p| { 431 | p.with_effect(effect, |p| p.print((3, current_row), symbol)) 432 | }); 433 | // Draw the active row. 434 | p.with_color(ColorStyles::hl(), |p| { 435 | p.print((6, current_row), &format!("{:02} {}", f.track, f.title)); 436 | if duration_column > 11 437 | && (self.player.is_randomized 438 | || self.player.is_shuffled 439 | || self.player.is_muted) 440 | { 441 | // Draw the playback options. 442 | p.with_color(ColorStyles::info(), |p| { 443 | p.with_effect(Effect::Italic, |p| { 444 | p.print( 445 | (duration_column - 3, current_row), 446 | self.playback_opts(), 447 | ) 448 | }) 449 | }) 450 | } 451 | p.print((duration_column, current_row), &mins_and_secs(f.duration)); 452 | }) 453 | } else { 454 | // Draw the inactive rows. 455 | p.with_color(ColorStyles::fg(), |p| { 456 | p.print((6, current_row), &format!("{:02} {}", f.track, f.title)); 457 | p.print((duration_column, current_row), &mins_and_secs(f.duration)); 458 | }) 459 | } 460 | } 461 | } 462 | 463 | // Draw the footer: elapsed time, progress bar, remaining time 464 | if h > 0 { 465 | let bottom_row = h - 1; 466 | let remaining = f.duration.saturating_sub(elapsed); 467 | 468 | // Draw the elapsed time. 469 | p.with_color(ColorStyles::hl(), |p| { 470 | p.print((0, bottom_row), &mins_and_secs(elapsed)); 471 | }); 472 | 473 | // Draw the progress bar. 474 | { 475 | // Draw the fractional component. 476 | p.with_color(ColorStyles::progress(), |p| { 477 | p.print((length + 8, bottom_row), sub_block(extra)); 478 | }); 479 | 480 | // Draw the block component. 481 | p.cropped((length + 8, h)) 482 | .with_color(ColorStyles::progress(), |p| { 483 | p.print_hline((8, bottom_row), length, "█"); 484 | }); 485 | } 486 | 487 | // Draw the remaining time. 488 | p.with_color(ColorStyles::hl(), |p| { 489 | p.print((duration_column, bottom_row), &mins_and_secs(remaining)) 490 | }); 491 | } 492 | } 493 | 494 | fn on_event(&mut self, event: Event) -> EventResult { 495 | use Action::*; 496 | use MouseEvent::*; 497 | 498 | if let Some(action) = PLAYER_EVENT_TO_ACTION.get(&event) { 499 | match action { 500 | PlayOrPause => self.player.play_or_pause(), 501 | Stop => self.player.stop(), 502 | Next => self.next(), 503 | Previous => self.previous(), 504 | IncreaseVolume => self.increase_volume(), 505 | DecreaseVolume => self.decrease_volume(), 506 | ToggleMute => self.player.toggle_mute(), 507 | ToggleShowingVolume => _ = self.showing_volume.toggle(), 508 | SeekToMin => self.seek_to_min(), 509 | SeekToSec => self.seek_to_sec(), 510 | SeekForward => self.player.seek_forward(SEEK_TIME), 511 | SeekBackward => self.player.seek_backward(SEEK_TIME), 512 | ToggleRandomize => self.player.toggle_randomize(), 513 | ToggleShuffle => self.player.toggle_shuffle(), 514 | PlayTrackNumber => self.play_track_number(), 515 | PlayLastTrack => self.player.play_last_track(), 516 | ShowHelp => return show_help_view(), 517 | Quit => return quit(), 518 | } 519 | } else { 520 | match event { 521 | Event::Char(c) if c.is_ascii_digit() => { 522 | self.number_input.push(c.to_digit(10).unwrap() as usize); 523 | } 524 | Event::Mouse { 525 | event, 526 | offset, 527 | position, 528 | } => match event { 529 | Press(MouseButton::Left) => self.mouse_button_left(offset, position), 530 | Press(MouseButton::Right) => self.player.stop(), 531 | Release(MouseButton::Left) => self.mouse_release_seek(), 532 | Hold(MouseButton::Left) => { 533 | if self.mouse_seek_time.is_some() { 534 | self.mouse_hold_seek(offset, position); 535 | } 536 | } 537 | WheelUp | WheelDown => self.mouse_wheel(event, offset, position), 538 | _ => (), 539 | }, 540 | _ => (), 541 | } 542 | } 543 | 544 | EventResult::Ignored 545 | } 546 | } 547 | 548 | fn quit() -> EventResult { 549 | EventResult::with_cb(|siv| { 550 | siv.quit(); 551 | }) 552 | } 553 | 554 | fn show_help_view() -> EventResult { 555 | EventResult::with_cb(|siv| { 556 | HelpView::load(siv); 557 | }) 558 | } 559 | 560 | // Computes the values required to draw the progress bar. 561 | fn ratio(value: usize, max: usize, length: usize) -> (usize, usize) { 562 | if max == 0 { 563 | return (0, 0); 564 | } 565 | 566 | let integer = length * value / max; 567 | let fraction = length * value - max * integer; 568 | 569 | (integer, fraction * 8 / max) 570 | } 571 | 572 | // The characters needed to draw the fractional part of the progress bar. 573 | fn sub_block(extra: usize) -> &'static str { 574 | match extra { 575 | 0 => " ", 576 | 1 => "▏", 577 | 2 => "▎", 578 | 3 => "▍", 579 | 4 => "▌", 580 | 5 => "▋", 581 | 6 => "▊", 582 | 7 => "▉", 583 | _ => "█", 584 | } 585 | } 586 | 587 | // Formats the playback time. 588 | fn mins_and_secs(secs: usize) -> String { 589 | format!(" {:02}:{:02}", secs / 60, secs % 60) 590 | } 591 | 592 | // Represents different areas of the player. 593 | enum Area { 594 | ProgressBar, 595 | Playlist, 596 | Background, 597 | } 598 | 599 | impl Area { 600 | fn from(position: XY, offset: XY, size: XY) -> Self { 601 | let translation_y = position.y - offset.y; 602 | 603 | if position.y <= offset.y 604 | || translation_y > size.y 605 | || position.x <= offset.x + 1 606 | || position.x + 2 - offset.x >= size.x 607 | || size.x <= 16 608 | { 609 | return Area::Background; 610 | } 611 | 612 | if translation_y >= size.y - 2 && translation_y <= size.y { 613 | return Area::ProgressBar; 614 | } 615 | 616 | Area::Playlist 617 | } 618 | } 619 | 620 | // Concatenates the single-digit numbers in the input array into a single number. 621 | // For example, given [1, 2, 3], the function returns 123. 622 | // Assumes all elements of the array are between 0 and 9. 623 | fn concatenate_digits(arr: &[usize]) -> usize { 624 | arr.iter().fold(0, |acc, x| acc * 10 + x) 625 | } 626 | -------------------------------------------------------------------------------- /src/player/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::max, fs, path::PathBuf}; 2 | 3 | use rand::seq::IteratorRandom; 4 | 5 | use { 6 | anyhow::{anyhow, bail}, 7 | cursive::XY, 8 | rand::{seq::SliceRandom, thread_rng}, 9 | }; 10 | 11 | use crate::{finder::FuzzyDir, player::AudioFile, TapError}; 12 | 13 | const MIN_WIDTH: usize = 30; 14 | const X_PADDING: usize = 15; 15 | const Y_PADDING: usize = 3; 16 | const ROW_PADDING: usize = 6; 17 | 18 | #[derive(Debug, Clone, Default, PartialEq)] 19 | pub struct Playlist { 20 | pub fdir: FuzzyDir, 21 | pub audio_files: Vec, 22 | pub index: usize, 23 | pub xy_size: XY, 24 | } 25 | 26 | impl Playlist { 27 | fn new(fdir: FuzzyDir, audio_files: Vec, width: usize) -> Self { 28 | let xy_size = XY { 29 | x: max(width, MIN_WIDTH) + X_PADDING, 30 | y: audio_files.len() + Y_PADDING, 31 | }; 32 | 33 | Playlist { 34 | fdir, 35 | audio_files, 36 | xy_size, 37 | index: 0, 38 | } 39 | } 40 | 41 | pub fn process(path: &PathBuf, bail_on_subdir: bool) -> Result { 42 | if path.is_file() { 43 | Playlist::process_file(path) 44 | } else if path.is_dir() { 45 | Playlist::process_dir(path, bail_on_subdir) 46 | } else { 47 | bail!("{:?} is not a file or directory", path); 48 | } 49 | } 50 | 51 | fn process_file(p: &PathBuf) -> Result { 52 | if !AudioFile::validate_format(p) { 53 | bail!("{:?} is not a valid audio file", p); 54 | } 55 | let af = AudioFile::new(p.clone())?; 56 | af.decode()?; 57 | let width = max( 58 | af.title.len() + ROW_PADDING, 59 | af.artist.len() + af.album.len(), 60 | ); 61 | let fdir = FuzzyDir::default(); 62 | 63 | Ok(Self::new(fdir, vec![af], width)) 64 | } 65 | 66 | fn process_dir(p: &PathBuf, bail_on_subdir: bool) -> Result { 67 | let mut width = 0; 68 | let mut audio_files = Vec::new(); 69 | for entry in fs::read_dir(p).map_err(|e| anyhow!("failed to read {:?}: {}", p, e))? { 70 | let entry = entry?; 71 | let path = entry.path(); 72 | if bail_on_subdir && path.is_dir() { 73 | bail!("Directory {:?} contains subdirectories", p); 74 | } 75 | if AudioFile::validate_format(&path) { 76 | if let Ok(af) = AudioFile::new(path.clone()) { 77 | let len = max( 78 | af.title.len() + ROW_PADDING, 79 | af.artist.len() + af.album.len(), 80 | ); 81 | width = width.max(len); 82 | audio_files.push(af); 83 | } 84 | } 85 | } 86 | if audio_files.is_empty() { 87 | bail!("No audio files detected in {:?}", p); 88 | } 89 | 90 | audio_files.first().unwrap().decode()?; 91 | audio_files.sort(); 92 | let fdir = FuzzyDir::default(); 93 | 94 | Ok(Self::new(fdir, audio_files, width)) 95 | } 96 | 97 | pub fn set_random_index(&mut self) { 98 | let len = self.audio_files.len(); 99 | let mut rng = rand::thread_rng(); 100 | 101 | if len >= 2 { 102 | self.index = (0..len) 103 | .filter(|&i| i == 0 || i != self.index) 104 | .choose(&mut rng) 105 | .unwrap_or(0); 106 | } else { 107 | self.index = 0; 108 | } 109 | } 110 | 111 | pub fn some_randomized(fdirs: &[FuzzyDir]) -> Option { 112 | let mut rng = thread_rng(); 113 | 114 | (0..10).find_map(|_| { 115 | fdirs 116 | .choose(&mut rng) 117 | .and_then(|dir| Playlist::try_from(dir.clone()).ok()) 118 | }) 119 | } 120 | 121 | pub fn randomized_track(current: Playlist, fdirs: &[FuzzyDir]) -> Playlist { 122 | let mut rng = thread_rng(); 123 | 124 | let mut playlist = (0..10) 125 | .find_map(|_| { 126 | fdirs 127 | .choose(&mut rng) 128 | .and_then(|fdir| Playlist::try_from(fdir.clone()).ok()) 129 | .filter(|next| *next != current) 130 | }) 131 | .unwrap_or(current); 132 | 133 | playlist.set_random_index(); 134 | 135 | playlist 136 | } 137 | 138 | pub fn randomized(current: Playlist, fdirs: &[FuzzyDir]) -> Playlist { 139 | let mut rng = thread_rng(); 140 | 141 | (0..10) 142 | .find_map(|_| { 143 | fdirs 144 | .choose(&mut rng) 145 | .and_then(|fdir| Playlist::try_from(fdir.clone()).ok()) 146 | .filter(|next| *next != current) 147 | }) 148 | .unwrap_or(current) 149 | } 150 | 151 | pub fn get_next_track(&self) -> Option { 152 | self.audio_files 153 | .get(self.index + 1) 154 | .map(|file| file.clone()) 155 | } 156 | 157 | pub fn is_last_track(&self) -> bool { 158 | self.index == self.audio_files.len() - 1 159 | } 160 | } 161 | 162 | impl TryFrom for Playlist { 163 | type Error = TapError; 164 | fn try_from(fdir: FuzzyDir) -> Result { 165 | let mut playlist = Playlist::process_dir(&fdir.path, false)?; 166 | playlist.fdir = fdir; 167 | 168 | Ok(playlist) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/assets/test_audio_invalid.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_audio_invalid.mp3 -------------------------------------------------------------------------------- /tests/assets/test_audio_no_tags.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_audio_no_tags.mp3 -------------------------------------------------------------------------------- /tests/assets/test_flac_audio.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_flac_audio.flac -------------------------------------------------------------------------------- /tests/assets/test_m4a_audio.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_m4a_audio.m4a -------------------------------------------------------------------------------- /tests/assets/test_mp3_audio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_mp3_audio.mp3 -------------------------------------------------------------------------------- /tests/assets/test_non_audio.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_non_audio.txt -------------------------------------------------------------------------------- /tests/assets/test_ogg_audio.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_ogg_audio.ogg -------------------------------------------------------------------------------- /tests/assets/test_wav_audio.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timdubbins/tap/7f63e5fd1424206bfec112f5ba2aa6b00a08b36f/tests/assets/test_wav_audio.wav -------------------------------------------------------------------------------- /tests/testenv/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::process::Output; 3 | use std::{env, process}; 4 | 5 | use tempfile::TempDir; 6 | 7 | // Environment for the integration tests. 8 | pub struct TestEnv { 9 | // Temporary working directory. 10 | pub temp_dir: TempDir, 11 | // Path to the tap executable. 12 | tap_exe: PathBuf, 13 | } 14 | 15 | impl TestEnv { 16 | pub fn new( 17 | dirs: &[&'static str], 18 | audio_files: &[(&'static str, &'static str)], 19 | dummy_files: &[&'static str], 20 | ) -> TestEnv { 21 | let temp_dir = 22 | create_working_dir(dirs, audio_files, dummy_files).expect("working directory"); 23 | let tap_exe = find_exe(); 24 | TestEnv { temp_dir, tap_exe } 25 | } 26 | 27 | // Assert that calling tap with the specified arguments produces the expected error. 28 | pub fn assert_success(&self, args: &[&str]) { 29 | let output = self.run_command(".".as_ref(), args); 30 | assert!(output.status.success()) 31 | } 32 | 33 | // Assert that calling tap with the specified arguments produces the expected error. 34 | pub fn assert_error_msg(&self, args: &[&str], expected: &str) { 35 | let output = self.run_command(".".as_ref(), args); 36 | let stderr = String::from_utf8(output.stderr).expect("error message should be utf8"); 37 | 38 | assert!( 39 | stderr.contains(expected), 40 | "\nThe error message:\n`{}`\n\ 41 | does not contain the expected message:\n`{}`\n", 42 | stderr, 43 | expected 44 | ); 45 | } 46 | 47 | pub fn assert_normalized_paths(&self, expected: &[&str]) { 48 | let output = self.run_command(".".as_ref(), &[]); 49 | let stderr = normalize(output); 50 | 51 | for path in expected.iter() { 52 | assert!( 53 | stderr.contains(&path.to_string()), 54 | "\nThe list of paths:\n{:?}\n\ 55 | does not contain the expected path:\n`{}`\n", 56 | stderr, 57 | path 58 | ); 59 | } 60 | 61 | assert_eq!(stderr.len(), expected.len()); 62 | } 63 | 64 | fn run_command(&self, path: &Path, args: &[&str]) -> process::Output { 65 | let mut cmd = process::Command::new(&self.tap_exe); 66 | cmd.current_dir(self.temp_dir.path().join(path)); 67 | cmd.args(args); 68 | 69 | cmd.output().expect("tap output") 70 | } 71 | } 72 | 73 | pub fn create_working_dir( 74 | dirs: &[&'static str], 75 | audio_data: &[(&'static str, &'static str)], 76 | dummy_data: &[&'static str], 77 | ) -> Result { 78 | let temp_dir = tempfile::Builder::new() 79 | .prefix("tap-tests") 80 | .tempdir() 81 | .expect("failed to create temporary directory"); 82 | 83 | let assets_dir = find_assets_dir(); 84 | 85 | for path in dirs { 86 | let path = temp_dir.path().join(path); 87 | std::fs::create_dir_all(path).expect("failed to create subdirectories") 88 | } 89 | 90 | for (temp_path, asset_path) in audio_data { 91 | let src = assets_dir.join(asset_path); 92 | let dest = temp_dir.path().join(temp_path); 93 | std::fs::copy(src, dest).expect("failed to copy audio data"); 94 | } 95 | 96 | for path in dummy_data { 97 | let path = temp_dir.path().join(path); 98 | std::fs::File::create(path).expect("failed to create dummy data"); 99 | } 100 | 101 | Ok(temp_dir) 102 | } 103 | 104 | // Find the test assets. 105 | pub fn find_assets_dir() -> std::path::PathBuf { 106 | // Tests exe is in target/debug/deps, test assets are in tests/assets 107 | 108 | let root = std::env::current_exe() 109 | .expect("tests executable") 110 | .parent() 111 | .expect("tests executable directory") 112 | .parent() 113 | .expect("tap executable directory") 114 | .parent() 115 | .expect("target directory") 116 | .parent() 117 | .expect("project root") 118 | .to_path_buf(); 119 | 120 | root.join("tests").join("assets") 121 | } 122 | 123 | fn normalize(output: Output) -> Vec { 124 | let stderr = String::from_utf8(output.stderr).unwrap(); 125 | let slice = &stderr[38..]; 126 | let end = slice.find("]").unwrap(); 127 | 128 | slice[..end] 129 | .split(",") 130 | .map(|s| { 131 | let s = String::from(s); 132 | s[75..s.len() - 1].to_string() 133 | }) 134 | .collect::>() 135 | } 136 | 137 | // Find the tap executable. 138 | fn find_exe() -> PathBuf { 139 | // Tests exe is in target/debug/deps, the tap exe is in target/debug 140 | let root = env::current_exe() 141 | .expect("tests executable") 142 | .parent() 143 | .expect("tests executable directory") 144 | .parent() 145 | .expect("tap executable directory") 146 | .to_path_buf(); 147 | 148 | root.join("tap") 149 | } 150 | -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | mod testenv; 2 | 3 | use crate::testenv::TestEnv; 4 | 5 | #[test] 6 | fn test_empty_dir_error() { 7 | let te = TestEnv::new(&["one", "one/two"], &[], &[]); 8 | te.assert_error_msg(&[], "is empty"); 9 | } 10 | 11 | #[test] 12 | fn test_no_audio_error() { 13 | let te = TestEnv::new( 14 | &["one/two_a", "one/two_b"], 15 | &[], 16 | &["invalid_audio.mp3", "one/foo.txt"], 17 | ); 18 | te.assert_error_msg(&[], "no audio"); 19 | } 20 | 21 | #[test] 22 | fn test_multiple_audio_files_success() { 23 | let te = TestEnv::new( 24 | &["one"], 25 | &[ 26 | ("one/a.mp3", "test_mp3_audio.mp3"), 27 | ("one/b.flac", "test_flac_audio.flac"), 28 | ("one/c.wav", "test_wav_audio.wav"), 29 | ("one/c.ogg", "test_ogg_audio.ogg"), 30 | ], 31 | &[], 32 | ); 33 | te.assert_success(&[]); 34 | } 35 | 36 | #[test] 37 | fn test_automate_success() { 38 | let te = TestEnv::new( 39 | &["one", "one/two", "one/three"], 40 | &[ 41 | ("one/two/a.mp3", "test_mp3_audio.mp3"), 42 | ("one/three/b.mp3", "test_mp3_audio.mp3"), 43 | ], 44 | &[], 45 | ); 46 | te.assert_success(&["--automate"]); 47 | } 48 | 49 | #[test] 50 | fn test_default_is_not_set_error() { 51 | let te = TestEnv::new( 52 | &["one", "two"], 53 | &[ 54 | ("one/a.mp3", "test_mp3_audio.mp3"), 55 | ("two/b.mp3", "test_mp3_audio.mp3"), 56 | ], 57 | &[], 58 | ); 59 | 60 | te.assert_error_msg(&["-d"], "set a default"); 61 | } 62 | 63 | #[test] 64 | fn test_find_two_audio_dirs() { 65 | let te = TestEnv::new( 66 | &["one", "two"], 67 | &[ 68 | ("one/a.mp3", "test_mp3_audio.mp3"), 69 | ("one/b.mp3", "test_mp3_audio.mp3"), 70 | ("two/c.mp3", "test_mp3_audio.mp3"), 71 | ("two/d.mp3", "test_mp3_audio.mp3"), 72 | ], 73 | &[], 74 | ); 75 | te.assert_normalized_paths(&["one", "two"]); 76 | } 77 | 78 | #[test] 79 | fn test_exclude_empty_dir() { 80 | let te = TestEnv::new( 81 | &["one", "one/two", "one/empty_dir"], 82 | &[ 83 | ("one/two/a.mp3", "test_mp3_audio.mp3"), 84 | ("one/two/b.mp3", "test_mp3_audio.mp3"), 85 | ], 86 | &[], 87 | ); 88 | te.assert_normalized_paths(&["one/two"]); 89 | } 90 | 91 | #[test] 92 | fn test_exclude_empty_leaf_but_include_audio_parent() { 93 | let te = TestEnv::new( 94 | &["one", "one/two", "one/two/empty_leaf"], 95 | &[ 96 | ("one/two/a.mp3", "test_mp3_audio.mp3"), 97 | ("one/two/b.mp3", "test_mp3_audio.mp3"), 98 | ], 99 | &[], 100 | ); 101 | te.assert_normalized_paths(&["one/two"]); 102 | } 103 | 104 | #[test] 105 | fn test_exclude_non_audio_leaf_but_include_audio_parent() { 106 | let te = TestEnv::new( 107 | &["one", "one/two", "one/two/three"], 108 | &[ 109 | ("one/two/a.mp3", "test_mp3_audio.mp3"), 110 | ("one/two/b.mp3", "test_mp3_audio.mp3"), 111 | ], 112 | &["one/two/three/c.foo"], 113 | ); 114 | te.assert_normalized_paths(&["one/two"]); 115 | } 116 | 117 | #[test] 118 | fn test_find_audio_in_root_dir() { 119 | let te = TestEnv::new( 120 | &["one", "one/two", "one/three", "one/empty_dir"], 121 | &[ 122 | ("one/a.mp3", "test_mp3_audio.mp3"), 123 | ("one/two/b.mp3", "test_mp3_audio.mp3"), 124 | ("one/three/c.mp3", "test_mp3_audio.mp3"), 125 | ("one/three/d.mp3", "test_mp3_audio.mp3"), 126 | ], 127 | &[], 128 | ); 129 | te.assert_normalized_paths(&["one", "one/two", "one/three"]); 130 | } 131 | 132 | #[test] 133 | fn test_single_audio_dir() { 134 | let te = TestEnv::new( 135 | &["one"], 136 | &[ 137 | ("one/a.mp3", "test_mp3_audio.mp3"), 138 | ("one/b.mp3", "test_mp3_audio.mp3"), 139 | ], 140 | &[], 141 | ); 142 | te.assert_success(&[]); 143 | } 144 | --------------------------------------------------------------------------------