├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── release.yml │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── tori.ico ├── tori.png ├── tori.svg ├── tori_64x60.png └── tori_64x64.png ├── contrib ├── pack-aur.py ├── requirements.txt └── tori.desktop ├── docs ├── assets │ ├── getting_started_01.jpg │ ├── getting_started_02.jpg │ ├── getting_started_03.jpg │ ├── getting_started_04.jpg │ ├── getting_started_05.jpg │ ├── hotkey_modal.jpg │ ├── searching.jpg │ └── tori_assets ├── configuration.md ├── getting_started.md ├── index.md ├── style │ └── extra.css └── troubleshooting.md ├── mkdocs.yaml ├── tori-player ├── Cargo.toml └── src │ ├── controller │ └── mod.rs │ ├── lib.rs │ ├── output.rs │ ├── resampler.rs │ └── source.rs └── tori ├── Cargo.lock ├── Cargo.toml ├── README.md ├── build.rs └── src ├── app ├── app_screen │ ├── mod.rs │ └── now_playing.rs ├── browse_screen │ ├── mod.rs │ ├── playlists.rs │ └── songs.rs ├── component.rs ├── filtered_list.rs ├── mod.rs ├── modal │ ├── confirmation_modal.rs │ ├── help_modal.rs │ ├── hotkey_modal.rs │ ├── input_modal.rs │ └── mod.rs └── playlist_screen │ ├── centered_list.rs │ └── mod.rs ├── command.rs ├── config ├── mod.rs └── shortcuts.rs ├── dbglog.rs ├── default_config.yaml ├── error.rs ├── events.rs ├── lib.rs ├── m3u ├── mod.rs ├── parser.rs ├── playlist_management.rs └── stringreader.rs ├── main.rs ├── player ├── mod.rs ├── mpv │ ├── mod.rs │ └── select.rs └── tori_player_glue.rs ├── rect_ops.rs ├── util.rs ├── visualizer └── mod.rs └── widgets ├── mod.rs ├── notification.rs └── scrollbar.rs /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System Information (please complete the following information):** 27 | - OS: [e.g. Windows] 28 | - tori version [e.g. v.0.1.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/tori/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "cargo" 13 | directory: "/tori-player/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Check & Test 2 | 3 | on: 4 | push: 5 | branches: [ "master", "fix/ci" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: apt-get update 20 | run: sudo apt-get update 21 | - name: Install libmpv 22 | run: sudo apt-get -y install libmpv-dev libxcb-shape0-dev libxcb-xfixes0-dev 23 | - name: Install ac-ffmpeg dependencies 24 | run: sudo apt-get -y install libavutil-dev libavcodec-dev libavformat-dev libswresample-dev libswscale-dev 25 | - name: Install ALSA dev files 26 | run: sudo apt-get -y install libasound2-dev 27 | - name: Build 28 | run: cargo build --verbose 29 | - name: Run tests 30 | run: cargo test --verbose 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | tori/target/ 3 | tori-player/target/ 4 | playlists/* 5 | del.txt 6 | site/ 7 | contrib/venv 8 | contrib/aur 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Setup cargo-dist 6 | - Install icon in the more modern freedesktop destination (thanks to #11) 7 | - Proper error handling for the visualizer thread 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "tori", 5 | "tori-player" 6 | ] 7 | 8 | # The profile that 'cargo dist' will build with 9 | [profile.dist] 10 | inherits = "release" 11 | lto = "thin" 12 | 13 | # Config for 'cargo dist' 14 | [workspace.metadata.dist] 15 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 16 | cargo-dist-version = "0.8.2" 17 | # The preferred Rust toolchain to use in CI (rustup toolchain syntax) 18 | rust-toolchain-version = "1.69.0" 19 | # CI backends to support 20 | ci = ["github"] 21 | # The installers to generate for each app 22 | installers = ["shell", "powershell"] 23 | # Target platforms to build apps for (Rust target-triple syntax) 24 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] 25 | # Publish jobs to run in CI 26 | pr-run-mode = "plan" 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![tori](https://raw.githubusercontent.com/LeoRiether/tori/master/assets/tori_64x60.png) tori 2 | ### The frictionless music player for the terminal 3 | 4 | tori is a terminal-based music player and playlist manager that can play music from local files 5 | and external URLs (supported by yt-dlp). 6 | 7 | ![tori screenshot](https://user-images.githubusercontent.com/8211902/233261347-f1cb6597-0d2f-41e5-88b0-32590de43946.png) 8 | 9 | ## Features 10 | - Plays songs from local files and external URLs 11 | - Configurable keybinds 12 | - Filters songs by name, artist or filepath/URL 13 | - Sorts songs by name or duration 14 | - Spectrum visualizer 15 | 16 | ## Documentation 17 | tori's documentation is hosted [here](https://leoriether.github.io/tori/). It includes a [Getting Started guide](https://leoriether.github.io/tori/#getting_started/) and [configuration instructions](https://leoriether.github.io/tori/#configuration/). 18 | 19 | For code-related documentation, there's also a [docs.rs entry](https://docs.rs/tori). 20 | 21 | ## Installing 22 | - Make sure you have the dependencies installed 23 | - Install [the Rust toolchain](https://www.rust-lang.org/tools/install) 24 | - Run `cargo install tori` 25 | 26 | Alternatively, if you use an Arch-based Linux distro, you can install tori from the AUR: `yay -S tori-bin` 27 | 28 | Prebuild binaries for Windows, Mac and other Linux distros will be available soon. 29 | 30 | ### Dependencies 31 | - [mpv](https://mpv.io/) 32 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (recommended) or youtube-dl 33 | - [cava](https://github.com/karlstav/cava) (optional) for the visualizer 34 | 35 | ### yt-dlp 36 | If you're using yt-dlp instead of youtube-dl, edit your `mpv.conf` and paste the following line: 37 | ```conf 38 | script-opts=ytdl_hook-ytdl_path=yt-dlp 39 | ``` 40 | 41 | Either this or follow [the guide I followed :)](https://www.funkyspacemonkey.com/replace-youtube-dl-with-yt-dlp-how-to-make-mpv-work-with-yt-dlp) 42 | ## Alternatives 43 | - [musikcube](https://github.com/clangen/musikcube) is what I used before writing tori. 44 | It's a great player, but only plays from local files. 45 | - [cmus](https://cmus.github.io/) 46 | - [yewtube](https://github.com/mps-youtube/yewtube) 47 | -------------------------------------------------------------------------------- /assets/tori.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/assets/tori.ico -------------------------------------------------------------------------------- /assets/tori.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/assets/tori.png -------------------------------------------------------------------------------- /assets/tori.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 146 | -------------------------------------------------------------------------------- /assets/tori_64x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/assets/tori_64x60.png -------------------------------------------------------------------------------- /assets/tori_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/assets/tori_64x64.png -------------------------------------------------------------------------------- /contrib/pack-aur.py: -------------------------------------------------------------------------------- 1 | # Reference for later: https://manojkarthick.com/posts/2021/03/rust-aur/ 2 | # You should upload the .tar.gz to a release afterwards! 3 | 4 | import os 5 | import subprocess 6 | import toml 7 | 8 | AUR = "contrib/aur" 9 | 10 | 11 | class Packer: 12 | def __init__(self): 13 | if not os.path.exists("tori/Cargo.toml"): 14 | print("pack-aur should be called at the root of the Rust project") 15 | exit(1) 16 | 17 | self.config = toml.load("tori/Cargo.toml") 18 | 19 | def clone_aur(self): 20 | if not os.path.exists(AUR): 21 | print(f":: Cloning AUR package") 22 | subprocess.run( 23 | ["git", "clone", "ssh://aur@aur.archlinux.org/tori-bin.git", AUR] 24 | ) 25 | 26 | def build_binary(self): 27 | name = self.config["package"]["name"] 28 | print(f":: Building \x1b[92m{name}\x1b[0m") # ]] 29 | 30 | subprocess.run(["cargo", "build", "--release"]) 31 | subprocess.run(["strip", f"target/release/{name}"]) 32 | 33 | def make_pkgbuild(self): 34 | print(":: Generating \x1b[92mPKGBUILD\x1b[0m") # ]] 35 | 36 | pkg = self.config["package"] 37 | pkgname = pkg["name"] 38 | version = pkg["version"] 39 | description = pkg["description"] 40 | author0 = pkg["authors"][0] 41 | license = pkg["license"] 42 | url = pkg["repository"] 43 | depends = ['"' + d + '"' for d in pkg["metadata"]["depends"]] 44 | optdepends = ['"' + d + '"' for d in pkg["metadata"]["optdepends"]] 45 | 46 | content = f"""\ 47 | # Maintainer: {author0} 48 | 49 | pkgname={pkgname}-bin 50 | pkgver={version} 51 | pkgrel=1 52 | pkgdesc="{description}" 53 | url="{url}" 54 | license=("{license}") 55 | arch=("x86_64") 56 | provides=("{pkgname}") 57 | conflicts=("{pkgname}") 58 | depends=({str.join(" ", depends)}) 59 | optdepends=({str.join(" ", optdepends)}) 60 | source=("{url}/releases/download/v$pkgver/tori-$pkgver-x86_64.tar.gz") 61 | sha256sums=("we'll see") 62 | 63 | package() {{ 64 | install -dm755 "$pkgdir/usr/bin" 65 | install -dm755 "$pkgdir/usr/share/licenses/$pkgname" 66 | install -dm755 "$pkgdir/usr/share/applications" 67 | install -dm755 "$pkgdir/usr/share/icons/hicolor/scalable/apps" 68 | 69 | install -Dm755 tori -t "$pkgdir/usr/bin" 70 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 71 | 72 | install -Dm644 tori.desktop "$pkgdir/usr/share/applications/tori.desktop" 73 | install -Dm644 tori.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/" 74 | }} 75 | """ 76 | 77 | with open(f"{AUR}/PKGBUILD", "w") as f: 78 | f.write(content) 79 | 80 | def make_targz(self): 81 | pkgname = self.config["package"]["name"] 82 | version = self.config["package"]["version"] 83 | filename = f"{pkgname}-{version}-x86_64.tar.gz" 84 | print(f":: Generating \x1b[92m{filename}\x1b[0m") # ]] 85 | 86 | subprocess.run( 87 | [ 88 | "tar", 89 | "-czf", 90 | f"{AUR}/{filename}", 91 | "LICENSE", 92 | "-C", 93 | "target/release", 94 | f"{pkgname}", 95 | "-C", 96 | "../../assets", 97 | "tori.svg", 98 | "-C", 99 | "../contrib", 100 | "tori.desktop", 101 | ] 102 | ) 103 | subprocess.run(["updpkgsums", f"{AUR}/PKGBUILD"]) 104 | 105 | def makepkg(self): 106 | print(":: makepkg [.SRCINFO]") 107 | pwd = os.getcwd() 108 | os.chdir(AUR) 109 | with open(".SRCINFO", "w") as srcinfo: 110 | subprocess.run(["makepkg", "--printsrcinfo"], stdout=srcinfo) 111 | 112 | print(":: makepkg [check]") 113 | subprocess.run("rm -rf src pkg *.zst", shell=True) # clean 114 | subprocess.run(["makepkg"]) 115 | os.chdir(pwd) 116 | 117 | def run(self): 118 | self.clone_aur() 119 | self.build_binary() 120 | self.make_pkgbuild() 121 | self.make_targz() 122 | self.makepkg() 123 | print(":: \x1b[92mDone\x1b[0m") # ]] 124 | 125 | 126 | if __name__ == "__main__": 127 | Packer().run() 128 | -------------------------------------------------------------------------------- /contrib/requirements.txt: -------------------------------------------------------------------------------- 1 | toml==0.10.2 2 | -------------------------------------------------------------------------------- /contrib/tori.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=tori 4 | GenericName=Music player 5 | Comment=The frictionless music player for the terminal 6 | Icon=tori 7 | TryExec=tori 8 | Exec=tori 9 | Terminal=true 10 | Categories=AudioVideo;Audio;Player;ConsoleOnly; 11 | -------------------------------------------------------------------------------- /docs/assets/getting_started_01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/getting_started_01.jpg -------------------------------------------------------------------------------- /docs/assets/getting_started_02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/getting_started_02.jpg -------------------------------------------------------------------------------- /docs/assets/getting_started_03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/getting_started_03.jpg -------------------------------------------------------------------------------- /docs/assets/getting_started_04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/getting_started_04.jpg -------------------------------------------------------------------------------- /docs/assets/getting_started_05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/getting_started_05.jpg -------------------------------------------------------------------------------- /docs/assets/hotkey_modal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/hotkey_modal.jpg -------------------------------------------------------------------------------- /docs/assets/searching.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeoRiether/tori/0c27b324846ca4bf6a783968257897d31fb0e9dc/docs/assets/searching.jpg -------------------------------------------------------------------------------- /docs/assets/tori_assets: -------------------------------------------------------------------------------- 1 | ../../assets/ -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | All of tori's configuration is defined in $CONFIG_DIR/tori.yaml, where $CONFIG_DIR is, 3 | depending on your operating system: 4 | 5 | | Platform | Value | Example | 6 | | ------- | ------------------------------------- | ---------------------------------------- | 7 | | Linux | `$XDG_CONFIG_HOME` or `$HOME`/.config | /home/alice/.config | 8 | | macOS | `$HOME`/Library/Application Support | /Users/Alice/Library/Application Support | 9 | | Windows | `{FOLDERID_LocalAppData}` | C:\Users\Alice\AppData\Local | 10 | 11 | ## Commands 12 | 13 | Every configurable action in tori is called a "command". A list of your current key bindings can be 14 | opened by pressing `?`. The bindings are in the format `: `. 15 | 16 | ![getting started 05](./assets/getting_started_05.jpg) 17 | 18 | If you want to know what text tori uses to represent a given hotkey (for example, whether it's 19 | 'Ctrl+Space', 'C- ' or 'C-space'), use the "Hotkey Modal" by pressing `!` and then the key you 20 | want to test. 21 | 22 | ![hotkey modal](./assets/hotkey_modal.jpg) 23 | 24 | The list of all commands can be found [at docs.rs](https://docs.rs/tori/latest/tori/command/enum.Command.html). 25 | 26 | ## Defaults 27 | 28 | The default directory tori uses to store playlists depends on your OS: 29 | 30 | | Platform | Value | Example | 31 | | ------- | ------------------ | -------------------- | 32 | | Linux | `XDG_MUSIC_DIR`/tori | /home/alice/Music/tori | 33 | | macOS | `$HOME`/Music/tori | /Users/Alice/Music/tori | 34 | | Windows | `{FOLDERID_Music}`/tori | C:\Users\Alice\Music\tori | 35 | 36 | Here's the default configuration file: 37 | ```yaml 38 | playlists_dir: {audio_dir described in the above table} 39 | visualizer_gradient: 40 | - [46, 20, 66] 41 | - [16, 30, 71] 42 | keybindings: 43 | '?': OpenHelpModal 44 | C-c: Quit 45 | C-d: Quit 46 | q: Quit 47 | ">": NextSong 48 | "<": PrevSong 49 | " ": TogglePause 50 | L: ToggleLoop 51 | S-right: SeekForward 52 | S-left: SeekBackward 53 | o: OpenInBrowser 54 | y: CopyUrl 55 | t: CopyTitle 56 | A-up: VolumeUp 57 | A-down: VolumeDown 58 | m: Mute 59 | v: ToggleVisualizer 60 | s: NextSortingMode 61 | R: Rename 62 | X: Delete 63 | S-down: SwapSongDown 64 | S-up: SwapSongUp 65 | J: SwapSongDown 66 | K: SwapSongUp 67 | ",": Shuffle 68 | h: SelectLeft 69 | j: SelectNext 70 | k: SelectPrev 71 | l: SelectRight 72 | a: Add 73 | u: QueueSong 74 | C-q: QueueShown 75 | p: PlayFromModal 76 | E: OpenInEditor 77 | '!': OpenHotkeyModal 78 | C-f: Search 79 | ``` 80 | 81 | You can override shortcuts in your config file, or remove some by binding them to `Nop` like so: 82 | ```yaml 83 | A-enter: Nop 84 | ``` 85 | 86 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installing 4 | - Make sure you have the dependencies installed 5 | - Install [the Rust toolchain](https://www.rust-lang.org/tools/install) 6 | - Run `cargo install tori` 7 | 8 | Alternatively, if you use an Arch-based Linux distro, you can install tori from the AUR: `yay -S tori-bin` 9 | 10 | Prebuild binaries for Windows, Mac and other Linux distros will be available soon. 11 | 12 | ### Dependencies 13 | - [mpv](https://mpv.io/) 14 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (recommended) or youtube-dl 15 | - [cava](https://github.com/karlstav/cava) (optional) for the visualizer 16 | 17 | ### yt-dlp 18 | If you want to use yt-dlp instead of youtube-dl, edit your `mpv.conf` and paste the following line: 19 | ```conf 20 | script-opts=ytdl_hook-ytdl_path=yt-dlp 21 | ``` 22 | 23 | Either this or follow [the guide I followed :)](https://www.funkyspacemonkey.com/replace-youtube-dl-with-yt-dlp-how-to-make-mpv-work-with-yt-dlp) 24 | 25 | ## First Steps 26 | 27 | After installing, you should now be able to run `tori` in your terminal and be greeted 28 | with the main screen: 29 | 30 | ![getting started 01](./assets/getting_started_01.jpg) 31 | 32 | You can move focus between the different panes using the arrow keys, or the 33 | vim-like keybindings `h` and `l`. 34 | 35 | To add your first playlist, press `a` to open the add playlist prompt: 36 | 37 | ![getting started 02](./assets/getting_started_02.jpg) 38 | 39 | After pressing `enter`, you should see your playlist added to the list. 40 | 41 | Now, add a song by focusing the songs pane and pressing `a` again: 42 | 43 | ![getting started 03](./assets/getting_started_03.jpg) 44 | 45 | You have now added your first song! Local files are also accepted, and adding a folder will add 46 | all of the songs inside it. 47 | 48 | ![getting started 04](./assets/getting_started_04.jpg) 49 | 50 | By default, pressing `enter` will play the currently selected song (but it will replace anything 51 | that's currently playing, to append a song to the queue, press `u` instead). I also recommend 52 | pressing `v` to enable the visualizer (requires [cava](https://github.com/karlstav/cava/) to be 53 | installed). 54 | 55 | tori has many configurable commands. You can press `?` to see the current bindings ~~while listening 56 | to some Nhato tunes preferably~~: 57 | 58 | ![getting started 05](./assets/getting_started_05.jpg) 59 | 60 | ## Searching 61 | 62 | Pressing `/` will enter "search mode", which filters songs based on the input. You can also use it 63 | on the playlists pane to filter playlists. 64 | 65 | ![searching](./assets/searching.jpg) 66 | 67 | `esc` clears the filter and `enter` "commits" the filter so you can use commands while a filter 68 | is active. 69 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: tori 3 | --- 4 | 5 | # ![tori](https://raw.githubusercontent.com/LeoRiether/tori/master/assets/tori_64x60.png) tori 6 | ### The frictionless music player for the terminal 7 | 8 | tori is a terminal-based music player and playlist manager that can play music from local files 9 | and external URLs (supported by yt-dlp). 10 | 11 | ![tori screenshot](https://user-images.githubusercontent.com/8211902/233261347-f1cb6597-0d2f-41e5-88b0-32590de43946.png) 12 | 13 | ## Features 14 | - Plays songs from local files and external URLs 15 | - Configurable keybinds 16 | - Filters songs by name, artist or filepath/URL 17 | - Sorts songs by name or duration 18 | - Spectrum visualizer 19 | 20 | ## Alternatives 21 | - [musikcube](https://github.com/clangen/musikcube) is what I used before writing tori. 22 | It's a great player, but only plays from local files. 23 | - [cmus](https://cmus.github.io/) 24 | - [yewtube](https://github.com/mps-youtube/yewtube) 25 | 26 | -------------------------------------------------------------------------------- /docs/style/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #b30147; 3 | --md-primary-fg-color--light: #b30147; 4 | --md-primary-fg-color--dark: #b30147; 5 | --md-accent-fg-color: #e90758; 6 | --md-accent-fg-color--light: #e90758; 7 | --md-accent-fg-color--dark: #e90758; 8 | } 9 | 10 | [data-md-color-scheme=slate] { 11 | --md-hue: 336; 12 | --md-default-bg-color: #111111; 13 | } 14 | 15 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## [E] pw.loop [loop.c:67 pw_loop_new()] 0x7fb768010570: can't make support.system handle: No such file or directory 4 | 5 | This happens when mpv, tori's audio backend, doesn't find PipeWire installed in 6 | your system. 7 | 8 | You can fix this either by installing PipeWire or by setting 9 | `mpv_ao: ` in [the configuration file](/tori/configuration). 10 | `mpv_ao: pulse` and `mpv_ao: alsa` are popular choices. The available 11 | outputs can be listed by running `mpv --ao=help` in the terminal. 12 | 13 | ## Linking error with xcb (ld returned 1 exit status) 14 | ``` 15 | /usr/bin/ld: cannot find -lxcb: No such file or directory 16 | /usr/bin/ld: cannot find -lxcb-render: No such file or directory 17 | /usr/bin/ld: cannot find -lxcb-shape: No such file or directory 18 | /usr/bin/ld: cannot find -lxcb-xfixes: No such file or directory 19 | collect2: error: ld returned 1 exit status 20 | ``` 21 | 22 | If you're running a Debian-based distribution like Ubuntu, you can fix this by running 23 | ```bash 24 | sudo apt install -y libxcb-shape0-dev libxcb-xfixes0-dev 25 | ``` 26 | 27 | ## VersionMismatch 28 | ``` 29 | Error: VersionMismatch { linked: 131072, loaded: 65645 } 30 | ``` 31 | 32 | Your version of mpv is too old. You can fix this by installing a newer version of mpv, like v0.35.1 33 | 34 | ## The visualizer doesn't show up 35 | 36 | This may happen for a few reasons: 37 | 38 | 1. [cava](https://github.com/karlstav/cava) is not installed 39 | 2. [cava](https://github.com/karlstav/cava) is not on your `PATH`. 40 | 3. [cava](https://github.com/karlstav/cava) is throwing an error. If tori does 41 | not show you the error for some reason, you may be able to see it by running 42 | `cava` in the terminal. 43 | 44 | If the visualizer still does not show up after checking the above, please open [an issue!](https://github.com/LeoRiether/tori/issues). 45 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: tori 2 | site_url: https://leoriether.github.io/tori/ 3 | repo_url: https://github.com/LeoRiether/tori 4 | extra_css: [style/extra.css] 5 | theme: 6 | name: material 7 | palette: 8 | scheme: slate 9 | primary: custom 10 | accent: custom 11 | logo: assets/tori_assets/tori_64x64.png 12 | favicon: assets/tori_assets/tori_64x64.png 13 | plugins: 14 | - search 15 | nav: 16 | - index.md 17 | - getting_started.md 18 | - configuration.md 19 | - troubleshooting.md 20 | -------------------------------------------------------------------------------- /tori-player/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tori-player" 3 | description = "Audio player for tori" 4 | authors = ["Leonardo Riether "] 5 | license = "GPL-3.0-or-later" 6 | repository = "https://github.com/LeoRiether/tori" 7 | homepage = "https://github.com/LeoRiether/tori" 8 | keywords = ["music", "player", "audio"] 9 | version = "0.1.0" 10 | edition = "2021" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | symphonia = { version = "0.5.3", features = ["all-codecs"] } 16 | cpal = { version = "0.15.2" } 17 | rb = { version = "0.4.1" } 18 | rubato = { version = "0.13.0" } 19 | arrayvec = { version = "0.7.2" } 20 | ac-ffmpeg = { version = "0.18.1" } 21 | crossbeam-channel = { version = "0.5.8" } 22 | log = "0.4.19" 23 | -------------------------------------------------------------------------------- /tori-player/src/controller/mod.rs: -------------------------------------------------------------------------------- 1 | use super::source; 2 | use crate::Result; 3 | 4 | #[derive(Debug, Default)] 5 | pub struct Controller {} 6 | 7 | impl Controller { 8 | pub fn play(&mut self, path: &str) -> Result<()> { 9 | source::start_player_thread(path); 10 | Ok(()) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tori-player/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod controller; 2 | mod output; 3 | mod resampler; 4 | pub mod source; 5 | 6 | use controller::Controller; 7 | 8 | pub type Result = std::result::Result>; 9 | 10 | pub struct Player { 11 | pub controller: Controller, 12 | } 13 | -------------------------------------------------------------------------------- /tori-player/src/output.rs: -------------------------------------------------------------------------------- 1 | // Mostly copied from symphonia-play 2 | 3 | // Copyright (c) 2019-2022 The Project Symphonia Developers. 4 | // 5 | // This Source Code Form is subject to the terms of the Mozilla Public 6 | // License, v. 2.0. If a copy of the MPL was not distributed with this 7 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 8 | 9 | //! Platform-dependant Audio Outputs 10 | 11 | use std::{result, time}; 12 | 13 | use super::resampler::Resampler; 14 | use symphonia::core::audio::{AudioBufferRef, RawSample, SampleBuffer, SignalSpec}; 15 | use symphonia::core::conv::{ConvertibleSample, IntoSample}; 16 | use symphonia::core::units::Duration; 17 | 18 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 19 | use rb::*; 20 | 21 | use log::{error, info}; 22 | 23 | pub trait AudioOutput { 24 | fn write(&mut self, decoded: AudioBufferRef<'_>) -> Result<()>; 25 | fn flush(&mut self); 26 | } 27 | 28 | #[allow(dead_code)] 29 | #[allow(clippy::enum_variant_names)] 30 | #[derive(Debug)] 31 | pub enum AudioOutputError { 32 | OpenStreamError, 33 | PlayStreamError, 34 | StreamClosedError, 35 | } 36 | 37 | pub type Result = result::Result; 38 | 39 | pub struct CpalAudioOutput; 40 | 41 | trait AudioOutputSample: 42 | cpal::SizedSample + ConvertibleSample + IntoSample + RawSample + std::marker::Send + 'static 43 | { 44 | } 45 | 46 | impl AudioOutputSample for f32 {} 47 | impl AudioOutputSample for i16 {} 48 | impl AudioOutputSample for u16 {} 49 | 50 | impl CpalAudioOutput { 51 | pub fn try_open(spec: SignalSpec, duration: Duration) -> Result> { 52 | // Get default host. 53 | let host = cpal::default_host(); 54 | 55 | // Get the default audio output device. 56 | let device = match host.default_output_device() { 57 | Some(device) => device, 58 | _ => { 59 | error!("failed to get default audio output device"); 60 | return Err(AudioOutputError::OpenStreamError); 61 | } 62 | }; 63 | 64 | let config = match device.default_output_config() { 65 | Ok(config) => config, 66 | Err(err) => { 67 | error!("failed to get default audio output device config: {}", err); 68 | return Err(AudioOutputError::OpenStreamError); 69 | } 70 | }; 71 | 72 | // Select proper playback routine based on sample format. 73 | match config.sample_format() { 74 | cpal::SampleFormat::F32 => { 75 | CpalAudioOutputImpl::::try_open(spec, duration, &device) 76 | } 77 | cpal::SampleFormat::I16 => { 78 | CpalAudioOutputImpl::::try_open(spec, duration, &device) 79 | } 80 | cpal::SampleFormat::U16 => { 81 | CpalAudioOutputImpl::::try_open(spec, duration, &device) 82 | } 83 | _ => unimplemented!(), 84 | } 85 | } 86 | } 87 | 88 | struct CpalAudioOutputImpl 89 | where 90 | T: AudioOutputSample, 91 | { 92 | ring_buf_producer: rb::Producer, 93 | sample_buf: SampleBuffer, 94 | stream: cpal::Stream, 95 | resampler: Option>, 96 | } 97 | 98 | impl CpalAudioOutputImpl { 99 | pub fn try_open( 100 | spec: SignalSpec, 101 | duration: Duration, 102 | device: &cpal::Device, 103 | ) -> Result> { 104 | let num_channels = spec.channels.count(); 105 | 106 | // Output audio stream config. 107 | let config = if cfg!(not(target_os = "windows")) { 108 | cpal::StreamConfig { 109 | channels: num_channels as cpal::ChannelCount, 110 | sample_rate: cpal::SampleRate(spec.rate), 111 | buffer_size: cpal::BufferSize::Default, 112 | } 113 | } else { 114 | // Use the default config for Windows. 115 | device 116 | .default_output_config() 117 | .expect("Failed to get the default output config.") 118 | .config() 119 | }; 120 | 121 | // Create a ring buffer with a capacity for up-to 200ms of audio. 122 | let ring_len = ((200 * config.sample_rate.0 as usize) / 1000) * num_channels; 123 | 124 | let ring_buf = SpscRb::new(ring_len); 125 | let (ring_buf_producer, ring_buf_consumer) = (ring_buf.producer(), ring_buf.consumer()); 126 | 127 | let stream_result = device.build_output_stream( 128 | &config, 129 | move |data: &mut [T], _: &cpal::OutputCallbackInfo| { 130 | // Write out as many samples as possible from the ring buffer to the audio 131 | // output. 132 | let written = ring_buf_consumer.read(data).unwrap_or(0); 133 | 134 | // Mute any remaining samples. 135 | data[written..].iter_mut().for_each(|s| *s = T::MID); 136 | }, 137 | move |err| error!("audio output error: {}", err), 138 | Some(time::Duration::from_secs(1)), 139 | ); 140 | 141 | if let Err(err) = stream_result { 142 | error!("audio output stream open error: {}", err); 143 | 144 | return Err(AudioOutputError::OpenStreamError); 145 | } 146 | 147 | let stream = stream_result.unwrap(); 148 | 149 | // Start the output stream. 150 | if let Err(err) = stream.play() { 151 | error!("audio output stream play error: {}", err); 152 | 153 | return Err(AudioOutputError::PlayStreamError); 154 | } 155 | 156 | let sample_buf = SampleBuffer::::new(duration, spec); 157 | 158 | let resampler = if spec.rate != config.sample_rate.0 { 159 | info!("resampling {} Hz to {} Hz", spec.rate, config.sample_rate.0); 160 | Some(Resampler::new( 161 | spec, 162 | config.sample_rate.0 as usize, 163 | duration, 164 | )) 165 | } else { 166 | None 167 | }; 168 | 169 | Ok(Box::new(CpalAudioOutputImpl { 170 | ring_buf_producer, 171 | sample_buf, 172 | stream, 173 | resampler, 174 | })) 175 | } 176 | } 177 | 178 | impl AudioOutput for CpalAudioOutputImpl { 179 | fn write(&mut self, decoded: AudioBufferRef<'_>) -> Result<()> { 180 | // Do nothing if there are no audio frames. 181 | if decoded.frames() == 0 { 182 | return Ok(()); 183 | } 184 | 185 | let mut samples = if let Some(resampler) = &mut self.resampler { 186 | // Resampling is required. The resampler will return interleaved samples in the 187 | // correct sample format. 188 | match resampler.resample(decoded) { 189 | Some(resampled) => resampled, 190 | None => return Ok(()), 191 | } 192 | } else { 193 | // Resampling is not required. Interleave the sample for cpal using a sample buffer. 194 | self.sample_buf.copy_interleaved_ref(decoded); 195 | 196 | self.sample_buf.samples() 197 | }; 198 | 199 | // Write all samples to the ring buffer. 200 | while let Some(written) = self.ring_buf_producer.write_blocking(samples) { 201 | samples = &samples[written..]; 202 | } 203 | 204 | Ok(()) 205 | } 206 | 207 | fn flush(&mut self) { 208 | // If there is a resampler, then it may need to be flushed 209 | // depending on the number of samples it has. 210 | if let Some(resampler) = &mut self.resampler { 211 | let mut remaining_samples = resampler.flush().unwrap_or_default(); 212 | 213 | while let Some(written) = self.ring_buf_producer.write_blocking(remaining_samples) { 214 | remaining_samples = &remaining_samples[written..]; 215 | } 216 | } 217 | 218 | // Flush is best-effort, ignore the returned result. 219 | let _ = self.stream.pause(); 220 | } 221 | } 222 | 223 | pub fn try_open(spec: SignalSpec, duration: Duration) -> Result> { 224 | CpalAudioOutput::try_open(spec, duration) 225 | } 226 | -------------------------------------------------------------------------------- /tori-player/src/resampler.rs: -------------------------------------------------------------------------------- 1 | // Mostly copied from symphonia-play 2 | 3 | // Symphonia 4 | // Copyright (c) 2019-2022 The Project Symphonia Developers. 5 | // 6 | // This Source Code Form is subject to the terms of the Mozilla Public 7 | // License, v. 2.0. If a copy of the MPL was not distributed with this 8 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | 10 | use symphonia::core::audio::{AudioBuffer, AudioBufferRef, Signal, SignalSpec}; 11 | use symphonia::core::conv::{FromSample, IntoSample}; 12 | use symphonia::core::sample::Sample; 13 | 14 | pub struct Resampler { 15 | resampler: rubato::FftFixedIn, 16 | input: Vec>, 17 | output: Vec>, 18 | interleaved: Vec, 19 | duration: usize, 20 | } 21 | 22 | impl Resampler 23 | where 24 | T: Sample + FromSample + IntoSample, 25 | { 26 | fn resample_inner(&mut self) -> &[T] { 27 | { 28 | let mut input: arrayvec::ArrayVec<&[f32], 32> = Default::default(); 29 | 30 | for channel in self.input.iter() { 31 | input.push(&channel[..self.duration]); 32 | } 33 | 34 | // Resample. 35 | rubato::Resampler::process_into_buffer( 36 | &mut self.resampler, 37 | &input, 38 | &mut self.output, 39 | None, 40 | ) 41 | .unwrap(); 42 | } 43 | 44 | // Remove consumed samples from the input buffer. 45 | for channel in self.input.iter_mut() { 46 | channel.drain(0..self.duration); 47 | } 48 | 49 | // Interleave the planar samples from Rubato. 50 | let num_channels = self.output.len(); 51 | 52 | self.interleaved 53 | .resize(num_channels * self.output[0].len(), T::MID); 54 | 55 | for (i, frame) in self.interleaved.chunks_exact_mut(num_channels).enumerate() { 56 | for (ch, s) in frame.iter_mut().enumerate() { 57 | *s = self.output[ch][i].into_sample(); 58 | } 59 | } 60 | 61 | &self.interleaved 62 | } 63 | } 64 | 65 | impl Resampler 66 | where 67 | T: Sample + FromSample + IntoSample, 68 | { 69 | pub fn new(spec: SignalSpec, to_sample_rate: usize, duration: u64) -> Self { 70 | let duration = duration as usize; 71 | let num_channels = spec.channels.count(); 72 | 73 | let resampler = rubato::FftFixedIn::::new( 74 | spec.rate as usize, 75 | to_sample_rate, 76 | duration, 77 | 2, 78 | num_channels, 79 | ) 80 | .unwrap(); 81 | 82 | let output = rubato::Resampler::output_buffer_allocate(&resampler); 83 | 84 | let input = vec![Vec::with_capacity(duration); num_channels]; 85 | 86 | Self { 87 | resampler, 88 | input, 89 | output, 90 | duration, 91 | interleaved: Default::default(), 92 | } 93 | } 94 | 95 | /// Resamples a planar/non-interleaved input. 96 | /// 97 | /// Returns the resampled samples in an interleaved format. 98 | pub fn resample(&mut self, input: AudioBufferRef<'_>) -> Option<&[T]> { 99 | // Copy and convert samples into input buffer. 100 | convert_samples_any(&input, &mut self.input); 101 | 102 | // Check if more samples are required. 103 | if self.input[0].len() < self.duration { 104 | return None; 105 | } 106 | 107 | Some(self.resample_inner()) 108 | } 109 | 110 | /// Resample any remaining samples in the resample buffer. 111 | pub fn flush(&mut self) -> Option<&[T]> { 112 | let len = self.input[0].len(); 113 | 114 | if len == 0 { 115 | return None; 116 | } 117 | 118 | let partial_len = len % self.duration; 119 | 120 | if partial_len != 0 { 121 | // Fill each input channel buffer with silence to the next multiple of the resampler 122 | // duration. 123 | for channel in self.input.iter_mut() { 124 | channel.resize(len + (self.duration - partial_len), f32::MID); 125 | } 126 | } 127 | 128 | Some(self.resample_inner()) 129 | } 130 | } 131 | 132 | fn convert_samples_any(input: &AudioBufferRef<'_>, output: &mut [Vec]) { 133 | match input { 134 | AudioBufferRef::U8(input) => convert_samples(input, output), 135 | AudioBufferRef::U16(input) => convert_samples(input, output), 136 | AudioBufferRef::U24(input) => convert_samples(input, output), 137 | AudioBufferRef::U32(input) => convert_samples(input, output), 138 | AudioBufferRef::S8(input) => convert_samples(input, output), 139 | AudioBufferRef::S16(input) => convert_samples(input, output), 140 | AudioBufferRef::S24(input) => convert_samples(input, output), 141 | AudioBufferRef::S32(input) => convert_samples(input, output), 142 | AudioBufferRef::F32(input) => convert_samples(input, output), 143 | AudioBufferRef::F64(input) => convert_samples(input, output), 144 | } 145 | } 146 | 147 | fn convert_samples(input: &AudioBuffer, output: &mut [Vec]) 148 | where 149 | S: Sample + IntoSample, 150 | { 151 | for (c, dst) in output.iter_mut().enumerate() { 152 | let src = input.chan(c); 153 | dst.extend(src.iter().map(|&s| s.into_sample())); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tori-player/src/source.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use std::{ 3 | fs::File, 4 | io, 5 | path::Path, 6 | process::{Command, Stdio}, 7 | thread, 8 | }; 9 | 10 | use symphonia::core::{ 11 | codecs::{DecoderOptions, CODEC_TYPE_NULL}, 12 | errors::Error as SymError, 13 | formats::FormatOptions, 14 | io::{MediaSource, MediaSourceStream, ReadOnlySource}, 15 | meta::MetadataOptions, 16 | probe::Hint, 17 | }; 18 | 19 | use crate::output::CpalAudioOutput; 20 | 21 | // TODO: remove `expects` and `unwraps` 22 | pub fn start_player_thread(path: &str) { 23 | let (mss, hint) = mss_from_path(path).unwrap(); 24 | 25 | // Use the default options for metadata and format readers. 26 | let meta_opts: MetadataOptions = Default::default(); 27 | let fmt_opts = FormatOptions { 28 | enable_gapless: true, 29 | ..Default::default() 30 | }; 31 | 32 | // Probe the media source. 33 | let probed = symphonia::default::get_probe() 34 | .format(&hint, mss, &fmt_opts, &meta_opts) 35 | .expect("unsupported format"); 36 | 37 | // Get the instantiated format reader. 38 | let mut format = probed.format; 39 | 40 | // Find the first audio track with a known (decodeable) codec. 41 | let track = format 42 | .tracks() 43 | .iter() 44 | .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) 45 | .expect("no supported audio tracks"); 46 | 47 | // Use the default options for the decoder. 48 | let dec_opts: DecoderOptions = Default::default(); 49 | 50 | // Create a decoder for the track. 51 | let mut decoder = symphonia::default::get_codecs() 52 | .make(&track.codec_params, &dec_opts) 53 | .expect("unsupported codec"); 54 | 55 | // Store the track identifier, it will be used to filter packets. 56 | let track_id = track.id; 57 | 58 | // Spawn a thread to read packets from the format reader and send them to the decoder. 59 | // TODO: if the user pauses the player, this thread continues to run. Should this really be the 60 | // case? 61 | thread::spawn(move || { 62 | let mut audio_output = None; 63 | loop { 64 | let packet = match format.next_packet() { 65 | Ok(packet) => packet, 66 | Err(SymError::ResetRequired) => { 67 | // The track list has been changed. Re-examine it and create a new set of decoders, 68 | // then restart the decode loop. This is an advanced feature and it is not 69 | // unreasonable to consider this "the end." As of v0.5.0, the only usage of this is 70 | // for chained OGG physical streams. 71 | unimplemented!(); 72 | } 73 | Err(SymError::IoError(e)) 74 | if e.kind() == io::ErrorKind::UnexpectedEof 75 | && e.to_string() == "end of stream" => 76 | { 77 | // File ended 78 | break; 79 | } 80 | Err(err) => { 81 | // A unrecoverable error occurred, halt decoding. 82 | panic!("{}", err); 83 | } 84 | }; 85 | 86 | // Consume any new metadata that has been read since the last packet. 87 | while !format.metadata().is_latest() { 88 | // Pop the old head of the metadata queue. 89 | format.metadata().pop(); 90 | 91 | // Consume the new metadata at the head of the metadata queue. 92 | eprintln!("Got new metadata! {:?}", format.metadata().current()); 93 | } 94 | 95 | // If the packet does not belong to the selected track, skip over it. 96 | if packet.track_id() != track_id { 97 | continue; 98 | } 99 | 100 | match decoder.decode(&packet) { 101 | Ok(decoded) => { 102 | // If the audio output is not open, try to open it. 103 | if audio_output.is_none() { 104 | // Get the audio buffer specification. This is a description of the decoded 105 | // audio buffer's sample format and sample rate. 106 | let spec = *decoded.spec(); 107 | 108 | // Get the capacity of the decoded buffer. Note that this is capacity, not 109 | // length! The capacity of the decoded buffer is constant for the life of the 110 | // decoder, but the length is not. 111 | let duration = decoded.capacity() as u64; 112 | 113 | // Try to open the audio output. 114 | audio_output.replace(CpalAudioOutput::try_open(spec, duration).unwrap()); 115 | } else { 116 | // TODO: Check the audio spec. and duration hasn't changed. 117 | } 118 | 119 | // Write the decoded audio samples to the audio output if the presentation timestamp 120 | // for the packet is >= the seeked position (0 if not seeking). 121 | if let Some(audio_output) = audio_output.as_mut() { 122 | audio_output.write(decoded).unwrap() 123 | } 124 | } 125 | Err(SymError::IoError(_)) => { 126 | // The packet failed to decode due to an IO error, skip the packet. 127 | continue; 128 | } 129 | Err(SymError::DecodeError(_)) => { 130 | // The packet failed to decode due to invalid data, skip the packet. 131 | continue; 132 | } 133 | Err(err) => { 134 | // An unrecoverable error occurred, halt decoding. 135 | panic!("{}", err); 136 | } 137 | } 138 | } 139 | }); 140 | } 141 | 142 | fn mss_from_path(mut path: &str) -> Result<(MediaSourceStream, Hint)> { 143 | let mut force_ytdlp = false; 144 | if let Some(url) = path.strip_prefix("ytdlp://") { 145 | path = url; 146 | force_ytdlp = true; 147 | } 148 | 149 | let mut hint = Hint::default(); 150 | let src: Box = 151 | if force_ytdlp || path.starts_with("http://") || path.starts_with("https://") { 152 | // Get urls from yt-dlp 153 | let ytdlp_output = Command::new("yt-dlp") 154 | .args(["-g", path]) 155 | .output() 156 | .unwrap() 157 | .stdout; 158 | let ytdlp_output = String::from_utf8(ytdlp_output).unwrap(); 159 | let ytdlp_urls = ytdlp_output.lines(); 160 | 161 | // Get ffmpeg mpegts stream. 162 | let mut ffmpeg = Command::new("ffmpeg"); 163 | for url in ytdlp_urls { 164 | ffmpeg.args(["-i", url]); 165 | } 166 | ffmpeg 167 | .args(["-f", "mp3"]) // FIXME: don't do this. If you know how to do better please tell me how. Symphonia still doesn't support opus afaik. 168 | .arg("-") 169 | .stdout(Stdio::piped()) 170 | .stderr(Stdio::null()) 171 | .stdin(Stdio::null()); 172 | let mut ffmpeg = ffmpeg.spawn().unwrap(); 173 | let src = ffmpeg.stdout.take().unwrap(); 174 | 175 | hint.with_extension("mp3"); 176 | Box::new(ReadOnlySource::new(src)) 177 | } else { 178 | if let Some(ext) = Path::new(path).extension().and_then(|s| s.to_str()) { 179 | hint.with_extension(ext); 180 | } 181 | Box::new(File::open(path).expect("failed to open media")) 182 | }; 183 | 184 | let mss = MediaSourceStream::new(src, Default::default()); 185 | Ok((mss, hint)) 186 | } 187 | -------------------------------------------------------------------------------- /tori/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tori" 3 | description = "The frictionless music player for the terminal" 4 | authors = ["Leonardo Riether "] 5 | readme = "README.md" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/LeoRiether/tori" 8 | homepage = "https://github.com/LeoRiether/tori" 9 | keywords = ["music", "player", "tui", "terminal"] 10 | exclude = ["../assets", "../docs"] # not sure if I need these anymore 11 | version = "0.2.6" 12 | edition = "2021" 13 | build = "build.rs" 14 | 15 | [package.metadata] 16 | depends = ["mpv", "pipewire"] 17 | optdepends = ["yt-dlp", "cava"] 18 | 19 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 20 | 21 | [package.metadata.docs.rs] 22 | no-default-features = true # do not build with `clipboard` because that breaks the docs.rs build... 23 | 24 | [features] 25 | default = ["clip", "mpv"] 26 | clip = ["clipboard"] 27 | mpv = ["mpv034", "mpv035", "libmpv-sys"] 28 | tori-player = ["dep:tori-player"] 29 | 30 | [dependencies] 31 | tui = { version = "0.25", package = "ratatui" } 32 | crossterm = "0.27" 33 | 34 | # clipboard is optional because docs.rs doesn't build the xcb="0.8" dependency 35 | # also because I couldn't make any other clipboard crate work 36 | clipboard = { version = "0.5.0", optional = true } 37 | 38 | serde_json = "1.0.94" 39 | unicode-width = "0.1.10" 40 | dirs = "5.0.0" 41 | serde_yaml = "0.9.31" 42 | webbrowser = "0.8.12" 43 | serde = { version = "1.0.196", features = ["derive"] } 44 | once_cell = "1.17.1" 45 | argh = "0.1.12" 46 | lofty = "0.18.2" 47 | rand = "0.8.5" 48 | 49 | log = "0.4.19" 50 | pretty_env_logger = "0.5.0" 51 | 52 | # Player: mpv 53 | libmpv-sys = { version = "3.1.0", optional = true } 54 | mpv034 = { version = "2.0.1", package = "libmpv", optional = true } # Works with mpv <= v0.34 55 | mpv035 = { version = "2.0.2-fork.1", package = "libmpv-sirno", optional = true } # Works with mpv v0.35 56 | 57 | # Player: tori-player 58 | tori-player = { path = "../tori-player", version = "0.1.0", optional = true } 59 | 60 | [build-dependencies] 61 | winres = "0.1" 62 | 63 | -------------------------------------------------------------------------------- /tori/README.md: -------------------------------------------------------------------------------- 1 | # ![tori](https://raw.githubusercontent.com/LeoRiether/tori/master/assets/tori_64x60.png) tori 2 | ### The frictionless music player for the terminal 3 | 4 | tori is a terminal-based music player and playlist manager that can play music from local files 5 | and external URLs (supported by yt-dlp). 6 | 7 | ![tori screenshot](https://user-images.githubusercontent.com/8211902/233261347-f1cb6597-0d2f-41e5-88b0-32590de43946.png) 8 | 9 | ## Features 10 | - Plays songs from local files and external URLs 11 | - Configurable keybinds 12 | - Filters songs by name, artist or filepath/URL 13 | - Sorts songs by name or duration 14 | - Spectrum visualizer 15 | 16 | ## Documentation 17 | tori's documentation is hosted [here](https://leoriether.github.io/tori/). It includes a [Getting Started guide](https://leoriether.github.io/tori/#getting_started/) and [configuration instructions](https://leoriether.github.io/tori/#configuration/). 18 | 19 | For code-related documentation, there's also a [docs.rs entry](https://docs.rs/tori). 20 | 21 | ## Installing 22 | - Make sure you have the dependencies installed 23 | - Install [the Rust toolchain](https://www.rust-lang.org/tools/install) 24 | - Run `cargo install tori` 25 | 26 | Alternatively, if you use an Arch-based Linux distro, you can install tori from the AUR: `yay -S tori-bin` 27 | 28 | Prebuild binaries for Windows, Mac and other Linux distros will be available soon. 29 | 30 | ### Dependencies 31 | - [mpv](https://mpv.io/) 32 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (recommended) or youtube-dl 33 | - [cava](https://github.com/karlstav/cava) (optional) for the visualizer 34 | 35 | ### yt-dlp 36 | If you're using yt-dlp instead of youtube-dl, edit your `mpv.conf` and paste the following line: 37 | ```conf 38 | script-opts=ytdl_hook-ytdl_path=yt-dlp 39 | ``` 40 | 41 | Either this or follow [the guide I followed :)](https://www.funkyspacemonkey.com/replace-youtube-dl-with-yt-dlp-how-to-make-mpv-work-with-yt-dlp) 42 | ## Alternatives 43 | - [musikcube](https://github.com/clangen/musikcube) is what I used before writing tori. 44 | It's a great player, but only plays from local files. 45 | - [cmus](https://cmus.github.io/) 46 | - [yewtube](https://github.com/mps-youtube/yewtube) 47 | -------------------------------------------------------------------------------- /tori/build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | fn main() -> Result<(), Box> { 4 | // if cfg!(target_os = "windows") { 5 | // let mut res = winres::WindowsResource::new(); 6 | // res.set_icon("assets\\tori.ico"); 7 | // res.compile()?; 8 | // } 9 | 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /tori/src/app/app_screen/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{command, error::Result, events, player::Player, rect_ops::RectOps}; 2 | 3 | mod now_playing; 4 | use now_playing::NowPlaying; 5 | use tui::layout::Rect; 6 | 7 | use super::{ 8 | browse_screen::BrowseScreen, 9 | component::{Component, MouseHandler}, 10 | playlist_screen::PlaylistScreen, 11 | App, Mode, 12 | }; 13 | 14 | #[derive(Debug, Default)] 15 | pub enum Selected { 16 | #[default] 17 | Browse, 18 | Playlist, 19 | } 20 | 21 | #[derive(Debug)] 22 | pub struct AppScreen<'a> { 23 | browse: BrowseScreen<'a>, 24 | playlist: PlaylistScreen, 25 | now_playing: NowPlaying, 26 | selected: Selected, 27 | } 28 | 29 | impl<'a> AppScreen<'a> { 30 | pub fn new() -> Result { 31 | Ok(Self { 32 | browse: BrowseScreen::new()?, 33 | playlist: PlaylistScreen::default(), 34 | now_playing: NowPlaying::default(), 35 | selected: Selected::default(), 36 | }) 37 | } 38 | 39 | pub fn select(&mut self, selection: Selected) { 40 | self.selected = selection; 41 | } 42 | 43 | pub fn pass_event_down(&mut self, app: &mut App, event: events::Event) -> Result<()> { 44 | match self.selected { 45 | Selected::Browse => self.browse.handle_event(app, event), 46 | Selected::Playlist => self.playlist.handle_event(app, event), 47 | } 48 | } 49 | 50 | fn handle_command(&mut self, app: &mut App, cmd: command::Command) -> Result<()> { 51 | use command::Command::*; 52 | match cmd { 53 | Quit => { 54 | app.quit(); 55 | } 56 | SeekForward => { 57 | app.player.seek(10.)?; 58 | self.now_playing.update(&app.player); 59 | } 60 | SeekBackward => { 61 | app.player.seek(-10.)?; 62 | self.now_playing.update(&app.player); 63 | } 64 | NextSong => { 65 | app.player 66 | .playlist_next() 67 | .unwrap_or_else(|_| app.notify_err("No next song")); 68 | self.now_playing.update(&app.player); 69 | } 70 | PrevSong => { 71 | app.player 72 | .playlist_previous() 73 | .unwrap_or_else(|_| app.notify_err("No previous song")); 74 | self.now_playing.update(&app.player); 75 | } 76 | TogglePause => { 77 | app.player.toggle_pause()?; 78 | self.now_playing.update(&app.player); 79 | } 80 | ToggleLoop => { 81 | app.player.toggle_loop_file()?; 82 | self.now_playing.update(&app.player); 83 | } 84 | VolumeUp => { 85 | app.player.add_volume(5)?; 86 | self.now_playing.update(&app.player); 87 | } 88 | VolumeDown => { 89 | app.player.add_volume(-5)?; 90 | self.now_playing.update(&app.player); 91 | } 92 | Mute => { 93 | app.player.toggle_mute()?; 94 | self.now_playing.update(&app.player); 95 | } 96 | _ => self.pass_event_down(app, events::Event::Command(cmd))?, 97 | } 98 | Ok(()) 99 | } 100 | 101 | /// Returns (app chunk, now_playing chunk) 102 | fn subcomponent_chunks(frame: Rect) -> (Rect, Rect) { 103 | frame.split_bottom(2) 104 | } 105 | } 106 | 107 | impl<'a> Component for AppScreen<'a> { 108 | type RenderState = (); 109 | 110 | fn mode(&self) -> Mode { 111 | match self.selected { 112 | Selected::Browse => self.browse.mode(), 113 | Selected::Playlist => self.playlist.mode(), 114 | } 115 | } 116 | 117 | fn render(&mut self, frame: &mut tui::Frame, chunk: Rect, (): ()) { 118 | let vchunks = Self::subcomponent_chunks(chunk); 119 | 120 | match self.selected { 121 | Selected::Browse => self.browse.render(frame, vchunks.0, ()), 122 | Selected::Playlist => self.playlist.render(frame, vchunks.0, ()), 123 | } 124 | 125 | self.now_playing.render(frame, vchunks.1, ()); 126 | } 127 | 128 | fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()> { 129 | use crossterm::event::KeyCode; 130 | use events::Event::*; 131 | match &event { 132 | Command(cmd) => self.handle_command(app, *cmd)?, 133 | Terminal(crossterm::event::Event::Key(key_event)) => match key_event.code { 134 | KeyCode::Char('1') if self.mode() == Mode::Normal => { 135 | self.select(Selected::Browse); 136 | } 137 | KeyCode::Char('2') if self.mode() == Mode::Normal => { 138 | self.playlist.update(&app.player)?; 139 | self.select(Selected::Playlist); 140 | } 141 | _ => self.pass_event_down(app, event)?, 142 | }, 143 | SecondTick => { 144 | self.now_playing.update(&app.player); 145 | self.pass_event_down(app, event)?; 146 | } 147 | _ => self.pass_event_down(app, event)?, 148 | } 149 | Ok(()) 150 | } 151 | } 152 | 153 | impl<'a> MouseHandler for AppScreen<'a> { 154 | fn handle_mouse( 155 | &mut self, 156 | app: &mut App, 157 | chunk: Rect, 158 | event: crossterm::event::MouseEvent, 159 | ) -> Result<()> { 160 | let vchunks = Self::subcomponent_chunks(chunk); 161 | if vchunks.0.contains(event.column, event.row) { 162 | return match self.selected { 163 | Selected::Browse => self.browse.handle_mouse(app, vchunks.0, event), 164 | Selected::Playlist => self.playlist.handle_mouse(app, vchunks.0, event), 165 | }; 166 | } 167 | if vchunks.1.contains(event.column, event.row) { 168 | return self.now_playing.handle_mouse(app, vchunks.1, event); 169 | } 170 | 171 | Ok(()) 172 | } 173 | } 174 | 175 | #[cfg(test)] 176 | mod tests { 177 | use super::*; 178 | 179 | #[test] 180 | fn test_big_frame_size() { 181 | let frame = Rect { 182 | x: 0, 183 | y: 0, 184 | width: 128, 185 | height: 64, 186 | }; 187 | let app = Rect { 188 | x: 0, 189 | y: 0, 190 | width: 128, 191 | height: 62, 192 | }; 193 | let now_playing = Rect { 194 | x: 0, 195 | y: 62, 196 | width: 128, 197 | height: 2, 198 | }; 199 | assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); 200 | } 201 | 202 | #[test] 203 | fn test_small_frame_size() { 204 | let frame = Rect { 205 | x: 0, 206 | y: 0, 207 | width: 16, 208 | height: 10, 209 | }; 210 | let app = Rect { 211 | x: 0, 212 | y: 0, 213 | width: 16, 214 | height: 8, 215 | }; 216 | let now_playing = Rect { 217 | x: 0, 218 | y: 8, 219 | width: 16, 220 | height: 2, 221 | }; 222 | assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); 223 | } 224 | 225 | #[test] 226 | fn test_unusably_small_frame_size() { 227 | let frame = Rect { 228 | x: 0, 229 | y: 0, 230 | width: 16, 231 | height: 1, 232 | }; 233 | let app = Rect { 234 | x: 0, 235 | y: 0, 236 | width: 16, 237 | height: 0, 238 | }; 239 | let now_playing = Rect { 240 | x: 0, 241 | y: 0, 242 | width: 16, 243 | height: 1, 244 | }; 245 | assert_eq!(AppScreen::subcomponent_chunks(frame), (app, now_playing)); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /tori/src/app/app_screen/now_playing.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 3 | style::{Color, Style}, 4 | text::{Line, Span}, 5 | widgets::Paragraph, 6 | Frame, 7 | }; 8 | 9 | use crate::{ 10 | app::{ 11 | component::{Component, Mode, MouseHandler}, 12 | App, 13 | }, 14 | error::Result, 15 | events, 16 | player::Player, 17 | rect_ops::RectOps, 18 | }; 19 | 20 | #[derive(Debug)] 21 | struct SubcomponentChunks { 22 | top_line: Rect, 23 | volume: Rect, 24 | playback_left: Rect, 25 | playback_bar: Rect, 26 | playback_right: Rect, 27 | } 28 | 29 | #[derive(Debug, Default)] 30 | pub struct NowPlaying { 31 | pub media_title: String, 32 | pub percentage: i64, 33 | pub time_pos: i64, 34 | pub time_rem: i64, 35 | pub paused: bool, 36 | pub loop_file: bool, 37 | pub volume: i64, 38 | } 39 | 40 | impl NowPlaying { 41 | pub fn update(&mut self, player: &impl Player) { 42 | self.media_title = player.media_title().unwrap_or_default(); 43 | self.percentage = player.percent_pos().unwrap_or_default(); 44 | self.time_pos = player.time_pos().unwrap_or_default(); 45 | self.time_rem = player.time_remaining().unwrap_or_default(); 46 | self.paused = player.paused().unwrap_or_default(); 47 | self.loop_file = player.looping_file().unwrap_or_default(); 48 | 49 | self.volume = if player.muted().unwrap_or(false) { 50 | 0 51 | } else { 52 | player.volume().unwrap_or_default() 53 | }; 54 | } 55 | 56 | fn playback_strs(&self) -> (String, String) { 57 | let playback_left_str = format!("⏴︎ {:02}:{:02} ", self.time_pos / 60, self.time_pos % 60); 58 | let playback_right_str = format!("-{:02}:{:02} ⏵︎", self.time_rem / 60, self.time_rem % 60); 59 | (playback_left_str, playback_right_str) 60 | } 61 | 62 | pub fn click(&mut self, app: &mut App, x: u16, y: u16) -> Result<()> { 63 | let frame = app.frame_size(); 64 | let chunks = self.subcomponent_chunks(frame); 65 | 66 | if chunks.volume.contains(x, y) { 67 | let dx = (x - chunks.volume.left()) as f64; 68 | let percentage = dx / chunks.volume.width as f64; 69 | // remember that the maximum volume is 130 :) 70 | app.player.set_volume((130.0 * percentage).round() as i64)?; 71 | } 72 | 73 | if chunks.playback_bar.contains(x, y) { 74 | let dx = (x - chunks.playback_bar.left()) as f64; 75 | let percentage = dx / chunks.playback_bar.width as f64; 76 | let percentage = (percentage * 100.0).round() as usize; 77 | // this is bugged currently :/ 78 | // app.mpv.seek_percent_absolute(percentage)?; 79 | app.player.seek_absolute(percentage)?; 80 | } 81 | 82 | self.update(&app.player); 83 | Ok(()) 84 | } 85 | 86 | fn subcomponent_chunks(&self, chunk: Rect) -> SubcomponentChunks { 87 | let (playback_left_str, playback_right_str) = self.playback_strs(); 88 | let strlen = |s: &str| s.chars().count(); 89 | 90 | let lines = Layout::default() 91 | .direction(Direction::Vertical) 92 | .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) 93 | .split(chunk); 94 | 95 | let chunks = Layout::default() 96 | .direction(Direction::Horizontal) 97 | .constraints([ 98 | Constraint::Percentage(15), 99 | Constraint::Length(1), 100 | Constraint::Length(strlen(&playback_left_str) as u16), 101 | Constraint::Min(5), 102 | Constraint::Length(strlen(&playback_right_str) as u16), 103 | ]) 104 | .split(lines[1]); 105 | 106 | SubcomponentChunks { 107 | top_line: lines[0], 108 | volume: chunks[0], 109 | playback_left: chunks[2], 110 | playback_bar: chunks[3], 111 | playback_right: chunks[4], 112 | } 113 | } 114 | } 115 | 116 | impl Component for NowPlaying { 117 | type RenderState = (); 118 | 119 | fn render(&mut self, frame: &mut Frame, chunk: Rect, (): ()) { 120 | let chunks = self.subcomponent_chunks(chunk); 121 | let (playback_left_str, playback_right_str) = self.playback_strs(); 122 | 123 | /////////////////////////////// 124 | // Media title // 125 | /////////////////////////////// 126 | let media_title = { 127 | let mut parts = vec![]; 128 | 129 | if self.paused { 130 | parts.push(Span::styled( 131 | "[paused] ", 132 | Style::default().fg(Color::DarkGray), 133 | )); 134 | } 135 | 136 | if self.loop_file { 137 | parts.push(Span::styled( 138 | "[looping] ", 139 | Style::default().fg(Color::DarkGray), 140 | )); 141 | } 142 | 143 | parts.push(Span::styled( 144 | &self.media_title, 145 | Style::default().fg(Color::Yellow), 146 | )); 147 | 148 | Paragraph::new(Line::from(parts)).alignment(Alignment::Center) 149 | }; 150 | 151 | ////////////////////////// 152 | // Volume // 153 | ////////////////////////// 154 | let volume_title = Paragraph::new(Line::from(vec![ 155 | Span::raw("volume "), 156 | Span::styled( 157 | format!("{}%", self.volume), 158 | Style::default().fg(Color::DarkGray), 159 | ), 160 | ])) 161 | .alignment(Alignment::Left); 162 | 163 | let volume_paragraph = { 164 | // NOTE: the maximum volume is actually 130 165 | // NOTE: (x + 129) / 130 computes the ceiling of x/130 166 | let left_width = ((self.volume as usize * chunks.volume.width as usize + 129) / 130) 167 | .saturating_sub(1); 168 | let left = "─".repeat(left_width); 169 | let indicator = "■"; 170 | let right = "─" 171 | .repeat((chunks.volume.width as usize * 100 / 130).saturating_sub(left_width + 1)); 172 | Paragraph::new(Line::from(vec![ 173 | Span::styled(left, Style::default().fg(Color::White)), 174 | Span::styled(indicator, Style::default().fg(Color::White)), 175 | Span::styled(right, Style::default().fg(Color::DarkGray)), 176 | ])) 177 | }; 178 | 179 | /////////////////////////////////////// 180 | // Playback percentage // 181 | /////////////////////////////////////// 182 | let playback_bar_str: String = { 183 | let mut s: Vec<_> = "─" 184 | .repeat(chunks.playback_bar.width as usize) 185 | .chars() 186 | .collect(); 187 | let i = (self.percentage as usize * s.len() / 100) 188 | .min(s.len() - 1) 189 | .max(0); 190 | s[i] = '■'; 191 | s.into_iter().collect() 192 | }; 193 | 194 | let playback_left = 195 | Paragraph::new(playback_left_str).style(Style::default().fg(Color::White)); 196 | let playback_bar = 197 | Paragraph::new(playback_bar_str).style(Style::default().fg(Color::White)); 198 | let playback_right = 199 | Paragraph::new(playback_right_str).style(Style::default().fg(Color::White)); 200 | 201 | ///////////////////////////////////// 202 | // Render everything // 203 | ///////////////////////////////////// 204 | frame.render_widget(volume_title, chunks.top_line); 205 | frame.render_widget(media_title, chunks.top_line); 206 | frame.render_widget(volume_paragraph, chunks.volume); 207 | frame.render_widget(playback_left, chunks.playback_left); 208 | frame.render_widget(playback_right, chunks.playback_right); 209 | frame.render_widget(playback_bar, chunks.playback_bar); 210 | } 211 | 212 | fn mode(&self) -> Mode { 213 | Mode::Normal 214 | } 215 | 216 | fn handle_event(&mut self, _app: &mut App, _event: events::Event) -> Result<()> { 217 | Ok(()) 218 | } 219 | } 220 | 221 | impl MouseHandler for NowPlaying { 222 | fn handle_mouse( 223 | &mut self, 224 | app: &mut App, 225 | _chunk: Rect, 226 | event: crossterm::event::MouseEvent, 227 | ) -> Result<()> { 228 | use crossterm::event::{MouseButton, MouseEventKind}; 229 | if matches!( 230 | event.kind, 231 | MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) 232 | ) { 233 | self.click(app, event.column, event.row)?; 234 | } 235 | Ok(()) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /tori/src/app/browse_screen/playlists.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ 3 | component::{Component, MouseHandler}, 4 | filtered_list::FilteredList, 5 | App, Mode 6 | }, 7 | command::Command, 8 | config::Config, 9 | error::Result, 10 | events::Event, 11 | }; 12 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind}; 13 | use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; 14 | use crossterm::ExecutableCommand; 15 | use std::result::Result as StdResult; 16 | use std::io; 17 | use tui::{ 18 | layout::{self, Rect}, 19 | style::{Color, Style}, 20 | widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph, Wrap}, 21 | Frame, 22 | }; 23 | 24 | #[derive(Debug, Default)] 25 | pub struct PlaylistsPane { 26 | playlists: Vec, 27 | shown: FilteredList, 28 | filter: String, 29 | } 30 | 31 | impl PlaylistsPane { 32 | pub fn new() -> Result { 33 | let mut me = Self::default(); 34 | me.reload_from_dir()?; 35 | Ok(me) 36 | } 37 | 38 | pub fn reload_from_dir(&mut self) -> Result<()> { 39 | let dir = std::fs::read_dir(&Config::global().playlists_dir) 40 | .map_err(|e| format!("Failed to read playlists directory: {}", e))?; 41 | 42 | use std::fs::DirEntry; 43 | let extract_playlist_name = |entry: StdResult| { 44 | Ok(entry 45 | .unwrap() 46 | .file_name() 47 | .into_string() 48 | .map_err(|filename| format!("File '{:?}' has invalid UTF-8", filename))? 49 | .trim_end_matches(".m3u8") 50 | .to_string()) 51 | }; 52 | 53 | self.playlists = dir 54 | .into_iter() 55 | .map(extract_playlist_name) 56 | .collect::>()?; 57 | 58 | self.playlists.sort(); 59 | self.refresh_shown(); 60 | Ok(()) 61 | } 62 | 63 | fn refresh_shown(&mut self) { 64 | self.shown.filter( 65 | &self.playlists, 66 | |s| { 67 | self.filter.is_empty() 68 | || s.to_lowercase() 69 | .contains(&self.filter[1..].trim_end_matches('\n').to_lowercase()) 70 | }, 71 | |i, j| i.cmp(&j), 72 | ); 73 | } 74 | 75 | pub fn handle_filter_key_event(&mut self, event: KeyEvent) -> Result { 76 | match event.code { 77 | KeyCode::Char(c) => { 78 | self.filter.push(c); 79 | Ok(true) 80 | } 81 | KeyCode::Backspace => { 82 | self.filter.pop(); 83 | Ok(true) 84 | } 85 | KeyCode::Esc => { 86 | self.filter.clear(); 87 | Ok(true) 88 | } 89 | KeyCode::Enter => { 90 | self.filter.push('\n'); 91 | Ok(true) 92 | } 93 | _ => Ok(false), 94 | } 95 | } 96 | 97 | pub fn select_next(&mut self, app: &mut App) { 98 | self.shown.select_next(); 99 | app.channel.send(Event::ChangedPlaylist).unwrap(); 100 | } 101 | 102 | pub fn select_prev(&mut self, app: &mut App) { 103 | self.shown.select_prev(); 104 | app.channel.send(Event::ChangedPlaylist).unwrap(); 105 | } 106 | 107 | pub fn select_index(&mut self, app: &mut App, i: Option) { 108 | self.shown.state.select(i); 109 | app.channel.send(Event::ChangedPlaylist).unwrap(); 110 | } 111 | 112 | pub fn selected_item(&self) -> Option<&str> { 113 | self.shown 114 | .selected_item() 115 | .and_then(|i| self.playlists.get(i)) 116 | .map(|s| s.as_str()) 117 | } 118 | 119 | pub fn open_editor_for_selected(&mut self, app: &mut App) -> Result<()> { 120 | if let Some(selected) = self.selected_item() { 121 | let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); 122 | 123 | let _lock = app.channel.receiving_crossterm.lock().unwrap(); 124 | io::stdout().execute(LeaveAlternateScreen)?; 125 | 126 | let res = std::process::Command::new(&editor) 127 | .arg(Config::playlist_path(selected)) 128 | .status() 129 | .map_err(|err| format!("Failed to execute editor '{}': {}", editor, err)); 130 | 131 | io::stdout().execute(EnterAlternateScreen)?; 132 | 133 | res?; 134 | self.reload_from_dir()?; 135 | app.terminal.clear()?; 136 | } 137 | Ok(()) 138 | } 139 | 140 | fn click(&mut self, app: &mut App, frame: Rect, y: u16) { 141 | let top = frame 142 | .inner(&layout::Margin { 143 | vertical: 1, 144 | horizontal: 1, 145 | }) 146 | .top(); 147 | let line = y.saturating_sub(top) as usize; 148 | let index = line + self.shown.state.offset(); 149 | if index < self.shown.items.len() && Some(index) != self.shown.selected_item() { 150 | self.select_index(app, Some(index)); 151 | } 152 | } 153 | } 154 | 155 | impl Component for PlaylistsPane { 156 | type RenderState = bool; 157 | 158 | fn mode(&self) -> Mode { 159 | if self.filter.is_empty() || self.filter.as_bytes().last() == Some(&b'\n') { 160 | Mode::Normal 161 | } else { 162 | Mode::Insert 163 | } 164 | } 165 | 166 | fn render(&mut self, frame: &mut Frame, chunk: layout::Rect, is_focused: bool) { 167 | let title = if !self.filter.is_empty() { 168 | format!(" {} ", self.filter) 169 | } else { 170 | " playlists ".into() 171 | }; 172 | 173 | let mut block = Block::default() 174 | .title(title) 175 | .borders(Borders::LEFT | Borders::BOTTOM | Borders::TOP) 176 | .border_type(BorderType::Plain); 177 | 178 | if is_focused { 179 | block = block.border_style(Style::default().fg(Color::LightBlue)); 180 | } 181 | 182 | if !self.playlists.is_empty() { 183 | // Render playlists list 184 | let playlists: Vec<_> = self 185 | .shown 186 | .items 187 | .iter() 188 | .map(|&i| ListItem::new(self.playlists[i].as_str())) 189 | .collect(); 190 | 191 | let widget = List::new(playlists) 192 | .block(block) 193 | .highlight_style(Style::default().bg(Color::LightBlue).fg(Color::Black)); 194 | frame.render_stateful_widget(widget, chunk, &mut self.shown.state); 195 | } else { 196 | // Help message 197 | let key = Config::global() 198 | .keybindings 199 | .0 200 | .iter() 201 | .find(|&(_key, &cmd)| cmd == Command::Add) 202 | .map(|(key, _)| key.0.as_str()) 203 | .unwrap_or("a"); 204 | 205 | let widget = Paragraph::new(format!( 206 | "You don't have any playlists yet! Press '{}' to add one.", 207 | key 208 | )) 209 | .wrap(Wrap { trim: true }) 210 | .block(block) 211 | .style(Style::default().fg(Color::DarkGray)); 212 | frame.render_widget(widget, chunk); 213 | } 214 | } 215 | 216 | #[allow(clippy::collapsible_match)] 217 | #[allow(clippy::single_match)] 218 | fn handle_event(&mut self, app: &mut App, event: Event) -> Result<()> { 219 | use crate::command::Command::*; 220 | use Event::*; 221 | use KeyCode::*; 222 | 223 | match event { 224 | Command(cmd) => match cmd { 225 | SelectNext => self.select_next(app), 226 | SelectPrev => self.select_prev(app), 227 | Search => self.filter = "/".into(), 228 | _ => {} 229 | }, 230 | Terminal(event) => match event { 231 | crossterm::event::Event::Key(event) => { 232 | if self.mode() == Mode::Insert && self.handle_filter_key_event(event)? { 233 | self.refresh_shown(); 234 | app.channel.send(Event::ChangedPlaylist).unwrap(); 235 | return Ok(()); 236 | } 237 | 238 | match event.code { 239 | Up => self.select_prev(app), 240 | Down => self.select_next(app), 241 | Char('/') => self.filter = "/".into(), 242 | Esc => { 243 | self.filter.clear(); 244 | self.refresh_shown(); 245 | app.channel.send(Event::ChangedPlaylist).unwrap(); 246 | } 247 | _ => {} 248 | } 249 | } 250 | _ => {} 251 | }, 252 | _ => {} 253 | } 254 | 255 | Ok(()) 256 | } 257 | } 258 | 259 | impl MouseHandler for PlaylistsPane { 260 | fn handle_mouse( 261 | &mut self, 262 | app: &mut App, 263 | chunk: Rect, 264 | event: crossterm::event::MouseEvent, 265 | ) -> Result<()> { 266 | match event.kind { 267 | MouseEventKind::ScrollUp => self.select_prev(app), 268 | MouseEventKind::ScrollDown => self.select_next(app), 269 | MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) => { 270 | self.click(app, chunk, event.row) 271 | } 272 | _ => {} 273 | } 274 | Ok(()) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /tori/src/app/component.rs: -------------------------------------------------------------------------------- 1 | use super::App; 2 | use crate::{error::Result, events}; 3 | use std::io; 4 | use tui::{backend::CrosstermBackend, layout::Rect, Frame}; 5 | 6 | pub(crate) type MyBackend = CrosstermBackend; 7 | 8 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 9 | #[repr(i8)] 10 | pub enum Mode { 11 | #[default] 12 | Normal, 13 | Insert, 14 | } 15 | 16 | pub trait Component { 17 | type RenderState; 18 | 19 | fn mode(&self) -> Mode; 20 | fn render( 21 | &mut self, 22 | frame: &mut Frame, 23 | chunk: Rect, 24 | render_state: Self::RenderState, 25 | ); 26 | fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()>; 27 | } 28 | 29 | pub trait MouseHandler { 30 | fn handle_mouse( 31 | &mut self, 32 | app: &mut App, 33 | chunk: Rect, 34 | event: crossterm::event::MouseEvent, 35 | ) -> Result<()>; 36 | } 37 | -------------------------------------------------------------------------------- /tori/src/app/filtered_list.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::{ListState, TableState}; 2 | 3 | //////////////////////////////////// 4 | // Selectable trait // 5 | //////////////////////////////////// 6 | pub trait Selectable { 7 | fn selected(&self) -> Option; 8 | fn select(&mut self, index: Option); 9 | } 10 | 11 | impl Selectable for ListState { 12 | fn selected(&self) -> Option { 13 | self.selected() 14 | } 15 | 16 | fn select(&mut self, index: Option) { 17 | self.select(index); 18 | } 19 | } 20 | 21 | impl Selectable for TableState { 22 | fn selected(&self) -> Option { 23 | self.selected() 24 | } 25 | 26 | fn select(&mut self, index: Option) { 27 | self.select(index); 28 | } 29 | } 30 | 31 | ///////////////////////////////// 32 | // Filtered List // 33 | ///////////////////////////////// 34 | #[derive(Debug, Default)] 35 | pub struct FilteredList { 36 | /// List of indices of the original list 37 | pub items: Vec, 38 | pub state: St, 39 | } 40 | 41 | impl FilteredList { 42 | pub fn filter(&mut self, items: &[T], pred: P, sorting: S) 43 | where 44 | P: Fn(&T) -> bool, 45 | S: Fn(usize, usize) -> std::cmp::Ordering, 46 | { 47 | let previous_selection = self.selected_item(); 48 | 49 | self.items = (0..items.len()) 50 | .filter(|&i| { 51 | // SAFETY: `i` is in (0..items.len()), so no bound checking needed 52 | pred(unsafe { items.get_unchecked(i) }) 53 | }) 54 | .collect(); 55 | 56 | self.items.sort_by(|&i, &j| sorting(i, j)); 57 | 58 | let new_selection = self 59 | .items 60 | .iter() 61 | // Search for the item that was previously selected 62 | .position(|&i| Some(i) == previous_selection) 63 | // If we don't find it, select the first item 64 | .or(if self.items.is_empty() { None } else { Some(0) }); 65 | 66 | self.state.select(new_selection); 67 | } 68 | 69 | pub fn select_next(&mut self) { 70 | self.state.select(match self.state.selected() { 71 | Some(x) => Some(wrap_inc(x, self.items.len())), 72 | None if !self.items.is_empty() => Some(0), 73 | None => None, 74 | }); 75 | } 76 | 77 | pub fn select_prev(&mut self) { 78 | self.state.select(match self.state.selected() { 79 | Some(x) => Some(wrap_dec(x, self.items.len())), 80 | None if !self.items.is_empty() => Some(0), 81 | None => None, 82 | }); 83 | } 84 | 85 | pub fn selected_item(&self) -> Option { 86 | self.state.selected().map(|i| self.items[i]) 87 | } 88 | } 89 | 90 | fn wrap_inc(x: usize, modulo: usize) -> usize { 91 | if x == modulo - 1 { 92 | 0 93 | } else { 94 | x + 1 95 | } 96 | } 97 | 98 | fn wrap_dec(x: usize, modulo: usize) -> usize { 99 | if x == 0 { 100 | modulo - 1 101 | } else { 102 | x - 1 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tori/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use crossterm::{ 2 | event::{ 3 | DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, KeyEvent, KeyEventKind, 4 | KeyModifiers, 5 | }, 6 | execute, 7 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 8 | }; 9 | use std::{borrow::Cow, cell::RefCell, rc::Rc, sync::mpsc}; 10 | use std::{ 11 | io, 12 | time::{self, Duration}, 13 | }; 14 | use tui::{backend::CrosstermBackend, layout::Rect, style::Color, Terminal}; 15 | 16 | use crate::{ 17 | app::component::Mode, 18 | command, 19 | config::Config, 20 | error::Result, 21 | events::{self, Channel}, 22 | player::{DefaultPlayer, Player}, 23 | visualizer::{self, Visualizer}, 24 | widgets::notification::Notification, 25 | }; 26 | 27 | pub mod app_screen; 28 | pub mod browse_screen; 29 | pub mod component; 30 | pub mod filtered_list; 31 | pub mod modal; 32 | pub mod playlist_screen; 33 | 34 | use crate::events::Event; 35 | 36 | use self::{ 37 | app_screen::AppScreen, 38 | component::{Component, MouseHandler, MyBackend}, 39 | }; 40 | 41 | const FRAME_DELAY_MS: u16 = 16; 42 | const HIGH_EVENT_TIMEOUT: u16 = 1000; 43 | const LOW_EVENT_TIMEOUT: u16 = 17; 44 | 45 | pub struct App<'a> { 46 | pub channel: Channel, 47 | terminal: Terminal, 48 | player: DefaultPlayer, 49 | next_render: time::Instant, 50 | next_poll_timeout: u16, 51 | notification: Notification<'a>, 52 | visualizer: Option, 53 | screen: Rc>>, 54 | quit: bool, 55 | } 56 | 57 | impl<'a> App<'a> { 58 | pub fn new() -> Result> { 59 | let stdout = io::stdout(); 60 | let backend = CrosstermBackend::new(stdout); 61 | let terminal = Terminal::new(backend)?; 62 | 63 | let player = DefaultPlayer::new()?; 64 | 65 | let screen = Rc::new(RefCell::new(AppScreen::new()?)); 66 | 67 | let channel = Channel::default(); 68 | 69 | let next_render = time::Instant::now(); 70 | let next_poll_timeout = LOW_EVENT_TIMEOUT; 71 | 72 | let notification = Notification::default(); 73 | 74 | Ok(App { 75 | channel, 76 | terminal, 77 | player, 78 | next_render, 79 | next_poll_timeout, 80 | notification, 81 | visualizer: None, 82 | screen, 83 | quit: false, 84 | }) 85 | } 86 | 87 | pub fn run(&mut self) -> Result<()> { 88 | self.chain_hook(); 89 | setup_terminal()?; 90 | 91 | self.channel.spawn_terminal_event_getter(); 92 | self.channel.spawn_ticks(); 93 | 94 | while !self.quit { 95 | self.render() 96 | .map_err(|e| self.notify_err(e.to_string())) 97 | .ok(); 98 | self.recv_event() 99 | .map_err(|e| self.notify_err(e.to_string())) 100 | .ok(); 101 | } 102 | 103 | reset_terminal()?; 104 | Ok(()) 105 | } 106 | 107 | #[inline] 108 | fn render(&mut self) -> Result<()> { 109 | if time::Instant::now() >= self.next_render { 110 | self.terminal.draw(|frame| { 111 | let chunk = frame.size(); 112 | self.screen.borrow_mut().render(frame, chunk, ()); 113 | self.notification.render(frame, frame.size(), ()); 114 | })?; 115 | 116 | let mut err = None; // kind of ugly, but simplifies &mut self borrows 117 | if let Some(ref mut visualizer) = self.visualizer { 118 | match visualizer.thread_handle() { 119 | visualizer::ThreadHandle::Stopped(Ok(())) => { 120 | self.visualizer = None; 121 | } 122 | visualizer::ThreadHandle::Stopped(Err(e)) => { 123 | err = Some(format!("The visualizer process exited with error: {}", e)); 124 | self.visualizer = None; 125 | } 126 | _ => visualizer.render(self.terminal.current_buffer_mut()), 127 | } 128 | } 129 | 130 | if let Some(err) = err { 131 | self.notify_err(err); 132 | } 133 | 134 | self.next_render = time::Instant::now() 135 | .checked_add(Duration::from_millis(FRAME_DELAY_MS as u64)) 136 | .unwrap(); 137 | } 138 | Ok(()) 139 | } 140 | 141 | #[inline] 142 | fn recv_event(&mut self) -> Result<()> { 143 | // NOTE: Big timeout if the last event was long ago, small timeout otherwise. 144 | // This makes it so after a burst of events, like a Ctrl+V, we get a small timeout 145 | // immediately after the last event, which triggers a fast render. 146 | let timeout = Duration::from_millis(self.next_poll_timeout as u64); 147 | match self.channel.receiver.recv_timeout(timeout) { 148 | Ok(Event::Terminal(CrosstermEvent::Key(key))) if key.kind == KeyEventKind::Release => { 149 | // WARN: we ignore every key release event for now because of a crossterm 0.26 150 | // quirk: https://github.com/crossterm-rs/crossterm/pull/745 151 | self.next_poll_timeout = FRAME_DELAY_MS; 152 | } 153 | Ok(event) => { 154 | let event = self.transform_event(event); 155 | self.handle_event(event)?; 156 | self.next_poll_timeout = FRAME_DELAY_MS; 157 | } 158 | Err(mpsc::RecvTimeoutError::Timeout) => { 159 | self.next_poll_timeout = self.suitable_event_timeout(); 160 | } 161 | Err(e) => return Err(e.into()), 162 | } 163 | 164 | Ok(()) 165 | } 166 | 167 | #[inline] 168 | fn suitable_event_timeout(&self) -> u16 { 169 | match self.visualizer { 170 | Some(_) => LOW_EVENT_TIMEOUT, 171 | None => HIGH_EVENT_TIMEOUT, 172 | } 173 | } 174 | 175 | /// Transforms an event, according to the current app state. 176 | fn transform_event(&self, event: Event) -> Event { 177 | use Event::*; 178 | match event { 179 | Terminal(CrosstermEvent::Key(key_event)) => { 180 | let has_mods = key_event.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT) 181 | != KeyModifiers::NONE; 182 | match self.screen.borrow().mode() { 183 | // In insert mode, key events pass through untransformed, unless there's a 184 | // control or alt modifier 185 | Mode::Insert if !has_mods => event, 186 | 187 | // Otherwise, events may be transformed into commands 188 | _ => self.transform_normal_mode_key(key_event), 189 | } 190 | } 191 | _ => event, 192 | } 193 | } 194 | 195 | fn handle_event(&mut self, event: events::Event) -> Result<()> { 196 | match &event { 197 | Event::Command(command::Command::ToggleVisualizer) => { 198 | self.toggle_visualizer()?; 199 | } 200 | Event::Terminal(crossterm::event::Event::Mouse(mouse_event)) => { 201 | let screen = self.screen.clone(); 202 | let chunk = self.frame_size(); 203 | screen 204 | .borrow_mut() 205 | .handle_mouse(self, chunk, *mouse_event)?; 206 | } 207 | _otherwise => { 208 | let screen = self.screen.clone(); 209 | screen.borrow_mut().handle_event(self, event)?; 210 | } 211 | } 212 | Ok(()) 213 | } 214 | 215 | /// Transforms a key event into the corresponding command, if there is one. 216 | /// Assumes state is in normal mode 217 | fn transform_normal_mode_key(&self, key_event: KeyEvent) -> Event { 218 | use crate::command::Command::Nop; 219 | use crossterm::event::Event::Key; 220 | use Event::*; 221 | match Config::global().keybindings.get_from_event(key_event) { 222 | Some(cmd) if cmd != Nop => Command(cmd), 223 | _ => Terminal(Key(key_event)), 224 | } 225 | } 226 | 227 | fn toggle_visualizer(&mut self) -> Result<()> { 228 | if self.visualizer.take().is_none() { 229 | let opts = crate::visualizer::CavaOptions { 230 | bars: self.terminal.get_frame().size().width as usize / 2, 231 | }; 232 | self.visualizer = Some(Visualizer::new(opts)?); 233 | } 234 | Ok(()) 235 | } 236 | 237 | fn chain_hook(&mut self) { 238 | let original_hook = std::panic::take_hook(); 239 | 240 | std::panic::set_hook(Box::new(move |panic| { 241 | reset_terminal().unwrap(); 242 | original_hook(panic); 243 | std::process::exit(1); 244 | })); 245 | } 246 | 247 | pub fn select_screen(&mut self, screen: app_screen::Selected) { 248 | self.screen.borrow_mut().select(screen); 249 | } 250 | 251 | pub fn quit(&mut self) { 252 | self.quit = true; 253 | } 254 | 255 | //////////////////////////////// 256 | // Notification // 257 | //////////////////////////////// 258 | pub fn notify_err(&mut self, err: impl Into>) { 259 | self.notification = Notification::new(err, Duration::from_secs(5)).colored(Color::LightRed); 260 | } 261 | 262 | pub fn notify_info(&mut self, info: impl Into>) { 263 | self.notification = 264 | Notification::new(info, Duration::from_secs(4)).colored(Color::LightCyan); 265 | } 266 | 267 | pub fn notify_ok(&mut self, text: impl Into>) { 268 | self.notification = 269 | Notification::new(text, Duration::from_secs(4)).colored(Color::LightGreen); 270 | } 271 | 272 | ///////////////////////// 273 | // Frame // 274 | ///////////////////////// 275 | pub fn frame_size(&mut self) -> Rect { 276 | self.terminal.get_frame().size() 277 | } 278 | } 279 | 280 | pub fn setup_terminal() -> Result<()> { 281 | execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 282 | enable_raw_mode()?; 283 | Ok(()) 284 | } 285 | 286 | pub fn reset_terminal() -> Result<()> { 287 | disable_raw_mode()?; 288 | execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 289 | Ok(()) 290 | } 291 | -------------------------------------------------------------------------------- /tori/src/app/modal/confirmation_modal.rs: -------------------------------------------------------------------------------- 1 | use super::{get_modal_chunk, Message, Modal}; 2 | 3 | use crossterm::event::KeyCode; 4 | use tui::{ 5 | layout::Alignment, 6 | style::{Color, Style}, 7 | widgets::{Block, BorderType, Borders, Clear, Paragraph}, 8 | Frame, 9 | }; 10 | 11 | use crate::{ 12 | app::component::Mode, 13 | error::Result, 14 | events::Event, 15 | }; 16 | 17 | /// A confirmation modal box that asks for user yes/no input 18 | #[derive(Debug, Default)] 19 | pub struct ConfirmationModal { 20 | title: String, 21 | style: Style, 22 | } 23 | 24 | impl ConfirmationModal { 25 | pub fn new(title: &str) -> Self { 26 | Self { 27 | title: format!("\n{} (y/n)", title), 28 | style: Style::default().fg(Color::LightBlue), 29 | } 30 | } 31 | } 32 | 33 | impl Modal for ConfirmationModal { 34 | fn apply_style(&mut self, style: Style) { 35 | self.style = style; 36 | } 37 | 38 | fn handle_event(&mut self, event: Event) -> Result { 39 | use Event::*; 40 | use KeyCode::*; 41 | if let Terminal(crossterm::event::Event::Key(event)) = event { 42 | return match event.code { 43 | Backspace | Esc | Char('q') | Char('n') | Char('N') => Ok(Message::Quit), 44 | Enter | Char('y') | Char('Y') => Ok(Message::Commit("y".into())), 45 | _ => Ok(Message::Nothing), 46 | }; 47 | } 48 | Ok(Message::Nothing) 49 | } 50 | 51 | fn render(&mut self, frame: &mut Frame) { 52 | let size = frame.size(); 53 | let chunk = get_modal_chunk(size); 54 | 55 | let block = Block::default() 56 | .title_alignment(Alignment::Center) 57 | .borders(Borders::ALL) 58 | .border_type(BorderType::Double) 59 | .border_style(self.style); 60 | 61 | let paragraph = Paragraph::new(self.title.as_str()) 62 | .block(block) 63 | .style(self.style) 64 | .alignment(Alignment::Center); 65 | 66 | frame.render_widget(Clear, chunk); 67 | frame.render_widget(paragraph, chunk); 68 | } 69 | 70 | fn mode(&self) -> Mode { 71 | Mode::Insert 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tori/src/app/modal/help_modal.rs: -------------------------------------------------------------------------------- 1 | use super::{get_modal_chunk, Message, Modal}; 2 | 3 | use tui::{ 4 | layout::{Alignment, Constraint}, 5 | style::{Color, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Row, Table}, 8 | Frame, 9 | }; 10 | use unicode_width::UnicodeWidthStr; 11 | 12 | use crate::{ 13 | app::component::Mode, 14 | command::Command, 15 | config::{shortcuts::InputStr, Config}, 16 | error::Result, 17 | events::Event, 18 | }; 19 | 20 | /// A modal box that asks for user input 21 | #[derive(Debug, Default)] 22 | pub struct HelpModal { 23 | playlists_dir: String, 24 | rows: Vec>, 25 | } 26 | 27 | impl HelpModal { 28 | pub fn new() -> Self { 29 | let config = Config::global(); 30 | 31 | let playlists_dir = format!("playlists folder: {}", config.playlists_dir); 32 | 33 | let mut entries: Vec<_> = config.keybindings.0.iter().collect(); 34 | entries.sort_unstable_by(|(k0, _), (k1, _)| k0.cmp(k1)); 35 | let max_key_length = entries 36 | .iter() 37 | .map(|(k, _)| k.0.width()) 38 | .max() 39 | .unwrap_or_default(); 40 | let pad = |x: &str| format!("{}{}", " ".repeat(max_key_length - x.width()), x); 41 | 42 | let rows: Vec<_> = entries 43 | .chunks(3) 44 | .map(|chunk| { 45 | let make_cell = |(k, v): &(&InputStr, &Command)| { 46 | Line::from(vec![ 47 | Span::styled(pad(&k.0), Style::default().fg(Color::LightBlue)), 48 | Span::raw(format!(" {:?}", v)), 49 | ]) 50 | }; 51 | 52 | Row::new(chunk.iter().map(make_cell).collect::>()) 53 | }) 54 | .collect(); 55 | 56 | Self { 57 | playlists_dir, 58 | rows, 59 | } 60 | } 61 | } 62 | 63 | impl Modal for HelpModal { 64 | fn apply_style(&mut self, _style: Style) {} 65 | 66 | fn handle_event(&mut self, event: Event) -> Result { 67 | if let Event::Terminal(crossterm::event::Event::Key(_)) = event { 68 | return Ok(Message::Quit); 69 | } 70 | Ok(Message::Nothing) 71 | } 72 | 73 | fn render(&mut self, frame: &mut Frame) { 74 | let size = frame.size(); 75 | let mut chunk = get_modal_chunk(size); 76 | chunk.y = 3; 77 | chunk.height = frame.size().height.saturating_sub(6); 78 | 79 | let block = Block::default() 80 | .title(" Help ") 81 | .title_alignment(Alignment::Center) 82 | .borders(Borders::ALL) 83 | .border_type(BorderType::Rounded) 84 | .border_style(Style::default().fg(Color::LightBlue)); 85 | 86 | let paragraph = Paragraph::new(self.playlists_dir.as_str()) 87 | .block(block) 88 | .alignment(Alignment::Center); 89 | 90 | let widths = [Constraint::Ratio(1, 3); 3]; 91 | let table = Table::default() 92 | .rows(self.rows.clone()) 93 | .widths(widths) 94 | .column_spacing(1); 95 | 96 | frame.render_widget(Clear, chunk); 97 | frame.render_widget(paragraph, chunk); 98 | 99 | chunk.x += 1; 100 | chunk.y += 3; 101 | chunk.width -= 2; 102 | chunk.height -= 3; 103 | frame.render_widget(table, chunk); 104 | } 105 | 106 | fn mode(&self) -> Mode { 107 | Mode::Insert 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tori/src/app/modal/hotkey_modal.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::component::Mode, 3 | config::shortcuts::InputStr, 4 | error::Result, 5 | events::Event, 6 | }; 7 | use crossterm::event::Event as CrosstermEvent; 8 | use tui::{ 9 | layout::Alignment, 10 | style::{Color, Style}, 11 | widgets::{Block, BorderType, Borders, Clear, Paragraph}, 12 | }; 13 | 14 | use super::Modal; 15 | 16 | /////////////////////////////// 17 | // HotkeyModal // 18 | /////////////////////////////// 19 | /// Shows what keys the user is pressing 20 | #[derive(Debug, Default)] 21 | pub struct HotkeyModal { 22 | text: String, 23 | } 24 | 25 | impl Modal for HotkeyModal { 26 | fn apply_style(&mut self, _style: Style) {} 27 | 28 | fn handle_event(&mut self, event: Event) -> Result { 29 | if let Event::Terminal(CrosstermEvent::Key(key)) = event { 30 | if let crossterm::event::KeyCode::Esc = key.code { 31 | return Ok(super::Message::Quit); 32 | } 33 | self.text = InputStr::from(key).0; 34 | } 35 | Ok(super::Message::Nothing) 36 | } 37 | 38 | fn render(&mut self, frame: &mut tui::Frame) { 39 | let mut chunk = super::get_modal_chunk(frame.size()); 40 | chunk.width = chunk.width.min(30); 41 | chunk.x = frame.size().width.saturating_sub(chunk.width) / 2; 42 | 43 | let block = Block::default() 44 | .title(" Hotkey ") 45 | .title_alignment(Alignment::Center) 46 | .borders(Borders::ALL) 47 | .border_type(BorderType::Rounded) 48 | .border_style(Style::default().fg(Color::LightBlue)); 49 | 50 | let paragraph = Paragraph::new(format!("\n{}", self.text)) 51 | .block(block) 52 | .alignment(Alignment::Center); 53 | 54 | frame.render_widget(Clear, chunk); 55 | frame.render_widget(paragraph, chunk); 56 | } 57 | 58 | fn mode(&self) -> Mode { 59 | Mode::Insert 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tori/src/app/modal/input_modal.rs: -------------------------------------------------------------------------------- 1 | use super::{get_modal_chunk, Message, Modal}; 2 | 3 | use std::{borrow::Cow, mem}; 4 | 5 | use crossterm::event::KeyCode; 6 | use tui::{ 7 | layout::Alignment, 8 | style::{Color, Modifier, Style}, 9 | text::{Line, Span}, 10 | widgets::{Block, BorderType, Borders, Clear, Paragraph}, 11 | Frame, 12 | }; 13 | 14 | use crate::{ 15 | app::component::Mode, 16 | error::Result, 17 | events::Event, 18 | }; 19 | 20 | /// A modal box that asks for user input 21 | #[derive(Debug, Default)] 22 | pub struct InputModal<'t> { 23 | title: Cow<'t, str>, 24 | cursor: usize, 25 | scroll: u16, 26 | input: String, 27 | style: Style, 28 | } 29 | 30 | impl<'t> InputModal<'t> { 31 | pub fn new(title: impl Into>) -> Self { 32 | Self { 33 | title: title.into(), 34 | cursor: 0, 35 | scroll: 0, 36 | input: String::default(), 37 | style: Style::default().fg(Color::LightBlue), 38 | } 39 | } 40 | 41 | pub fn set_input(mut self, input: String) -> Self { 42 | self.input = input; 43 | self.cursor = self.input.len(); 44 | self 45 | } 46 | 47 | fn move_cursor(&mut self, x: isize) { 48 | let inc = |y: usize| (y as isize + x).min(self.input.len() as isize).max(0) as usize; 49 | self.cursor = inc(self.cursor); 50 | 51 | while !self.input.is_char_boundary(self.cursor) { 52 | self.cursor = inc(self.cursor); 53 | } 54 | } 55 | } 56 | 57 | impl<'t> Modal for InputModal<'t> { 58 | fn apply_style(&mut self, style: Style) { 59 | self.style = style; 60 | } 61 | 62 | fn handle_event(&mut self, event: Event) -> Result { 63 | use Event::*; 64 | use KeyCode::*; 65 | if let Terminal(crossterm::event::Event::Key(event)) = event { 66 | match event.code { 67 | Char(c) => { 68 | self.input.insert(self.cursor, c); 69 | self.move_cursor(1); 70 | } 71 | Backspace => { 72 | if self.cursor > 0 { 73 | self.move_cursor(-1); 74 | self.input.remove(self.cursor); 75 | } 76 | } 77 | Delete => { 78 | if self.cursor < self.input.len() { 79 | self.input.remove(self.cursor); 80 | } 81 | } 82 | Left => { 83 | self.move_cursor(-1); 84 | } 85 | Right => { 86 | self.move_cursor(1); 87 | } 88 | Home => { 89 | self.cursor = 0; 90 | } 91 | End => { 92 | self.cursor = self.input.len(); 93 | } 94 | Esc => { 95 | self.input.clear(); 96 | return Ok(Message::Quit); 97 | } 98 | Enter => { 99 | let input = mem::take(&mut self.input); 100 | return Ok(Message::Commit(input)); 101 | } 102 | _ => {} 103 | } 104 | } 105 | Ok(Message::Nothing) 106 | } 107 | 108 | fn render(&mut self, frame: &mut Frame) { 109 | let size = frame.size(); 110 | let chunk = get_modal_chunk(size); 111 | let prefix = " ❯ "; 112 | let scroll = self.calculate_scroll(chunk.width - prefix.len() as u16); 113 | 114 | let block = Block::default() 115 | .title(self.title.as_ref()) 116 | .title_alignment(Alignment::Center) 117 | .borders(Borders::ALL) 118 | .border_type(BorderType::Double) 119 | .border_style(self.style); 120 | 121 | // split input as [left, cursor, right] 122 | let (left, right) = self.input.split_at(self.cursor); 123 | let mut indices = right.char_indices(); 124 | let (in_cursor, right) = indices 125 | .next() 126 | .map(|_| right.split_at(indices.next().map(|(w, _)| w).unwrap_or(right.len()))) 127 | .unwrap_or((" ", "")); 128 | 129 | let paragraph = Paragraph::new(vec![ 130 | Line::from(vec![]), // empty first line 131 | Line::from(vec![ 132 | Span::styled(prefix, self.style), 133 | Span::raw(left), 134 | Span::styled(in_cursor, Style::default().add_modifier(Modifier::REVERSED)), 135 | Span::raw(right), 136 | ]), 137 | ]) 138 | .block(block) 139 | .scroll((0, scroll)) 140 | .alignment(Alignment::Left); 141 | 142 | frame.render_widget(Clear, chunk); 143 | frame.render_widget(paragraph, chunk); 144 | } 145 | 146 | fn mode(&self) -> Mode { 147 | Mode::Insert 148 | } 149 | } 150 | 151 | impl<'t> InputModal<'t> { 152 | /// Updates and calculates the Paragraph's scroll based on the current cursor and input 153 | fn calculate_scroll(&mut self, chunk_width: u16) -> u16 { 154 | if self.cursor as u16 > self.scroll + chunk_width - 1 { 155 | self.scroll = self.cursor as u16 + 1 - chunk_width; 156 | } 157 | 158 | if (self.cursor as u16) <= self.scroll { 159 | self.scroll = (self.cursor as u16).saturating_sub(1); 160 | } 161 | 162 | self.scroll 163 | } 164 | } 165 | 166 | #[cfg(test)] 167 | mod tests { 168 | use super::*; 169 | 170 | #[test] 171 | fn test_modal_cursor_ascii() { 172 | let mut modal = InputModal::new("modal cursor"); 173 | modal.input = "Hello World!".into(); 174 | assert_eq!(modal.cursor, 0); 175 | 176 | modal.move_cursor(1); 177 | assert_eq!(modal.cursor, 1); 178 | 179 | modal.move_cursor(1); 180 | assert_eq!(modal.cursor, 2); 181 | 182 | modal.move_cursor(-1); 183 | assert_eq!(modal.cursor, 1); 184 | 185 | modal.move_cursor(-1); 186 | assert_eq!(modal.cursor, 0); 187 | 188 | modal.move_cursor(-1); 189 | assert_eq!(modal.cursor, 0); 190 | 191 | modal.move_cursor(1000); 192 | assert_eq!(modal.cursor, modal.input.len()); 193 | } 194 | 195 | #[test] 196 | fn test_modal_cursor_unicode() { 197 | let mut modal = InputModal::new("modal cursor"); 198 | modal.input = "おはよう".into(); 199 | assert_eq!(modal.cursor, 0); 200 | 201 | modal.move_cursor(1); 202 | assert_eq!(modal.cursor, 3); 203 | 204 | modal.move_cursor(1); 205 | assert_eq!(modal.cursor, 6); 206 | 207 | modal.move_cursor(-1); 208 | assert_eq!(modal.cursor, 3); 209 | 210 | modal.move_cursor(-1); 211 | assert_eq!(modal.cursor, 0); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /tori/src/app/modal/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod confirmation_modal; 2 | pub mod help_modal; 3 | pub mod hotkey_modal; 4 | pub mod input_modal; 5 | 6 | pub use confirmation_modal::ConfirmationModal; 7 | pub use help_modal::HelpModal; 8 | pub use hotkey_modal::HotkeyModal; 9 | pub use input_modal::InputModal; 10 | 11 | use tui::{layout::Rect, style::Style, Frame}; 12 | 13 | use crate::{ 14 | app::component::Mode, 15 | error::Result, 16 | events::Event, 17 | }; 18 | 19 | /////////////////////////////////////////////////// 20 | // Message // 21 | /////////////////////////////////////////////////// 22 | /// The return type for [Modal::handle_event]. 23 | #[derive(Debug, Default, PartialEq)] 24 | pub enum Message { 25 | /// Nothing changed 26 | #[default] 27 | Nothing, 28 | 29 | /// User has quit the modal (by pressing Esc) 30 | Quit, 31 | 32 | /// User has written something (the String) in the modal and pressed Enter 33 | Commit(String), 34 | } 35 | 36 | ///////////////////////////////////////////////// 37 | // Modal // 38 | ///////////////////////////////////////////////// 39 | pub trait Modal { 40 | fn apply_style(&mut self, style: Style); 41 | fn handle_event(&mut self, event: Event) -> Result; 42 | fn render(&mut self, frame: &mut Frame); 43 | fn mode(&self) -> Mode; 44 | } 45 | 46 | impl Default for Box { 47 | fn default() -> Self { 48 | Box::new(InputModal::new(String::default())) 49 | } 50 | } 51 | 52 | pub fn get_modal_chunk(frame: Rect) -> Rect { 53 | let width = (frame.width / 3).max(70).min(frame.width); 54 | let height = 5; 55 | 56 | Rect { 57 | x: frame.width.saturating_sub(width) / 2, 58 | width, 59 | y: frame.height.saturating_sub(height) / 2, 60 | height, 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use crate::events::Event; 68 | use crossterm::event::{ 69 | Event::Key, 70 | KeyCode::{self, Backspace, Char, Enter, Esc}, 71 | KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, 72 | }; 73 | 74 | fn key_event(code: KeyCode) -> crossterm::event::Event { 75 | Key(KeyEvent { 76 | code, 77 | modifiers: KeyModifiers::NONE, 78 | kind: KeyEventKind::Press, 79 | state: KeyEventState::NONE, 80 | }) 81 | } 82 | 83 | #[test] 84 | fn test_modal_commit_lifecycle() { 85 | let mut modal = InputModal::new("commit lifecycle"); 86 | assert_eq!( 87 | modal 88 | .handle_event(Event::Terminal(key_event(Char('h')))) 89 | .ok(), 90 | Some(Message::Nothing) 91 | ); 92 | assert_eq!( 93 | modal 94 | .handle_event(Event::Terminal(key_event(Char('i')))) 95 | .ok(), 96 | Some(Message::Nothing) 97 | ); 98 | assert_eq!( 99 | modal 100 | .handle_event(Event::Terminal(key_event(Char('!')))) 101 | .ok(), 102 | Some(Message::Nothing) 103 | ); 104 | assert_eq!( 105 | modal 106 | .handle_event(Event::Terminal(key_event(Backspace))) 107 | .ok(), 108 | Some(Message::Nothing) 109 | ); 110 | assert_eq!( 111 | modal.handle_event(Event::Terminal(key_event(Enter))).ok(), 112 | Some(Message::Commit("hi".into())) 113 | ); 114 | } 115 | 116 | #[test] 117 | fn test_modal_quit_lifecycle() { 118 | let mut modal = InputModal::new("commit lifecycle"); 119 | assert_eq!( 120 | modal 121 | .handle_event(Event::Terminal(key_event(Char('h')))) 122 | .ok(), 123 | Some(Message::Nothing) 124 | ); 125 | assert_eq!( 126 | modal 127 | .handle_event(Event::Terminal(key_event(Char('i')))) 128 | .ok(), 129 | Some(Message::Nothing) 130 | ); 131 | assert_eq!( 132 | modal.handle_event(Event::Terminal(key_event(Esc))).ok(), 133 | Some(Message::Quit) 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tori/src/app/playlist_screen/centered_list.rs: -------------------------------------------------------------------------------- 1 | /// Mostly copied from the [tui-rs List](tui::widgets::List), but this list has 2 | /// centered items! 3 | /// Still waiting for ratatui to support centered list items... Dunno why it doesn't, even when 4 | /// Line::alignment exists... 5 | use tui::{ 6 | buffer::Buffer, 7 | layout::{Corner, Rect}, 8 | style::Style, 9 | widgets::{Block, StatefulWidget}, 10 | }; 11 | 12 | use tui::{text::Text, widgets::Widget}; 13 | use unicode_width::UnicodeWidthStr; 14 | 15 | #[derive(Debug, Clone, Default)] 16 | pub struct CenteredListState { 17 | offset: usize, 18 | selected: Option, 19 | } 20 | 21 | impl CenteredListState { 22 | #[allow(dead_code)] 23 | pub fn selected(&self) -> Option { 24 | self.selected 25 | } 26 | 27 | pub fn select(&mut self, index: Option) { 28 | self.selected = index; 29 | if index.is_none() { 30 | self.offset = 0; 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug, Clone, PartialEq, Eq)] 36 | pub struct CenteredListItem<'a> { 37 | content: Text<'a>, 38 | style: Style, 39 | } 40 | 41 | impl<'a> CenteredListItem<'a> { 42 | pub fn new(content: T) -> CenteredListItem<'a> 43 | where 44 | T: Into>, 45 | { 46 | CenteredListItem { 47 | content: content.into(), 48 | style: Style::default(), 49 | } 50 | } 51 | 52 | #[allow(dead_code)] 53 | pub fn style(mut self, style: Style) -> CenteredListItem<'a> { 54 | self.style = style; 55 | self 56 | } 57 | 58 | pub fn height(&self) -> usize { 59 | self.content.height() 60 | } 61 | } 62 | 63 | #[derive(Debug, Clone)] 64 | pub struct CenteredList<'a> { 65 | block: Option>, 66 | items: Vec>, 67 | /// Style used as a base style for the widget 68 | style: Style, 69 | start_corner: Corner, 70 | /// Style used to render selected item 71 | highlight_style: Style, 72 | /// Symbol in front of the selected item (Shift all items to the right) 73 | highlight_symbol: Option<&'a str>, 74 | /// Symbol to the right of the selected item 75 | highlight_symbol_right: Option<&'a str>, 76 | /// Whether to repeat the highlight symbol for each line of the selected item 77 | repeat_highlight_symbol: bool, 78 | } 79 | 80 | impl<'a> CenteredList<'a> { 81 | pub fn new(items: T) -> Self 82 | where 83 | T: Into>>, 84 | { 85 | Self { 86 | block: None, 87 | style: Style::default(), 88 | items: items.into(), 89 | start_corner: Corner::TopLeft, 90 | highlight_style: Style::default(), 91 | highlight_symbol: None, 92 | highlight_symbol_right: None, 93 | repeat_highlight_symbol: false, 94 | } 95 | } 96 | 97 | pub fn block(mut self, block: Block<'a>) -> Self { 98 | self.block = Some(block); 99 | self 100 | } 101 | 102 | #[allow(dead_code)] 103 | pub fn style(mut self, style: Style) -> Self { 104 | self.style = style; 105 | self 106 | } 107 | 108 | pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self { 109 | self.highlight_symbol = Some(highlight_symbol); 110 | self 111 | } 112 | 113 | pub fn highlight_symbol_right(mut self, highlight_symbol: &'a str) -> Self { 114 | self.highlight_symbol_right = Some(highlight_symbol); 115 | self 116 | } 117 | 118 | pub fn highlight_style(mut self, style: Style) -> Self { 119 | self.highlight_style = style; 120 | self 121 | } 122 | 123 | #[allow(dead_code)] 124 | pub fn repeat_highlight_symbol(mut self, repeat: bool) -> Self { 125 | self.repeat_highlight_symbol = repeat; 126 | self 127 | } 128 | 129 | #[allow(dead_code)] 130 | pub fn start_corner(mut self, corner: Corner) -> Self { 131 | self.start_corner = corner; 132 | self 133 | } 134 | 135 | fn get_items_bounds( 136 | &self, 137 | selected: Option, 138 | offset: usize, 139 | max_height: usize, 140 | ) -> (usize, usize) { 141 | let offset = offset.min(self.items.len().saturating_sub(1)); 142 | let mut start = offset; 143 | let mut end = offset; 144 | let mut height = 0; 145 | for item in self.items.iter().skip(offset) { 146 | if height + item.height() > max_height { 147 | break; 148 | } 149 | height += item.height(); 150 | end += 1; 151 | } 152 | 153 | let selected = selected.unwrap_or(0).min(self.items.len() - 1); 154 | while selected >= end { 155 | height = height.saturating_add(self.items[end].height()); 156 | end += 1; 157 | while height > max_height { 158 | height = height.saturating_sub(self.items[start].height()); 159 | start += 1; 160 | } 161 | } 162 | while selected < start { 163 | start -= 1; 164 | height = height.saturating_add(self.items[start].height()); 165 | while height > max_height { 166 | end -= 1; 167 | height = height.saturating_sub(self.items[end].height()); 168 | } 169 | } 170 | (start, end) 171 | } 172 | } 173 | 174 | impl<'a> StatefulWidget for CenteredList<'a> { 175 | type State = CenteredListState; 176 | 177 | fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 178 | buf.set_style(area, self.style); 179 | let list_area = match self.block.take() { 180 | Some(b) => { 181 | let inner_area = b.inner(area); 182 | b.render(area, buf); 183 | inner_area 184 | } 185 | None => area, 186 | }; 187 | 188 | if list_area.width < 1 || list_area.height < 1 { 189 | return; 190 | } 191 | 192 | if self.items.is_empty() { 193 | return; 194 | } 195 | let list_height = list_area.height as usize; 196 | 197 | let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); 198 | state.offset = start; 199 | 200 | let highlight_symbol = self.highlight_symbol.unwrap_or(""); 201 | let highlight_symbol_right = self.highlight_symbol_right.unwrap_or(""); 202 | let blank_symbol = " ".repeat(highlight_symbol.width()); 203 | 204 | let mut current_height = 0; 205 | let has_selection = state.selected.is_some(); 206 | for (i, item) in self 207 | .items 208 | .iter_mut() 209 | .enumerate() 210 | .skip(state.offset) 211 | .take(end - start) 212 | { 213 | let (x, y) = match self.start_corner { 214 | Corner::BottomLeft => { 215 | current_height += item.height() as u16; 216 | (list_area.left(), list_area.bottom() - current_height) 217 | } 218 | _ => { 219 | let pos = (list_area.left(), list_area.top() + current_height); 220 | current_height += item.height() as u16; 221 | pos 222 | } 223 | }; 224 | let area = Rect { 225 | x, 226 | y, 227 | width: list_area.width, 228 | height: item.height() as u16, 229 | }; 230 | let item_style = self.style; //.patch(item.style); 231 | buf.set_style(area, item_style); 232 | 233 | let is_selected = state.selected.map(|s| s == i).unwrap_or(false); 234 | for (j, line) in item.content.lines.iter().enumerate() { 235 | // if the item is selected, we need to display the hightlight symbol: 236 | // - either for the first line of the item only, 237 | // - or for each line of the item if the appropriate option is set 238 | let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) { 239 | highlight_symbol 240 | } else { 241 | &blank_symbol 242 | }; 243 | 244 | let offset = list_area.width.saturating_sub(line.width() as u16) / 2; 245 | let offset = offset.saturating_sub(1); // idk why either 246 | 247 | let (elem_x, max_element_width) = if has_selection { 248 | let (elem_x, _) = buf.set_stringn( 249 | x + offset, 250 | y + j as u16, 251 | symbol, 252 | list_area.width as usize, 253 | item_style, 254 | ); 255 | (elem_x, list_area.width - (elem_x - x)) 256 | } else { 257 | (x + offset, list_area.width) 258 | }; 259 | 260 | let (x_after, _) = buf.set_line(elem_x, y + j as u16, line, max_element_width); 261 | 262 | if has_selection { 263 | let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) { 264 | highlight_symbol_right 265 | } else { 266 | &blank_symbol 267 | }; 268 | buf.set_stringn( 269 | x_after, 270 | y + j as u16, 271 | symbol, 272 | list_area.width as usize, 273 | item_style, 274 | ); 275 | } 276 | } 277 | if is_selected { 278 | buf.set_style(area, self.highlight_style); 279 | } 280 | } 281 | } 282 | } 283 | 284 | impl<'a> Widget for CenteredList<'a> { 285 | fn render(self, area: Rect, buf: &mut Buffer) { 286 | let mut state = CenteredListState::default(); 287 | StatefulWidget::render(self, area, buf, &mut state); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /tori/src/app/playlist_screen/mod.rs: -------------------------------------------------------------------------------- 1 | use self::centered_list::{CenteredList, CenteredListItem, CenteredListState}; 2 | use super::{ 3 | component::{Component, MouseHandler}, 4 | App, Mode, 5 | }; 6 | use crate::{command, error::Result, events, player::Player, widgets::Scrollbar}; 7 | use std::{thread, time::Duration}; 8 | use tui::{ 9 | layout::{Alignment, Rect}, 10 | style::{Color, Style}, 11 | widgets::{Block, BorderType, Borders}, 12 | }; 13 | 14 | mod centered_list; 15 | 16 | /// Screen that shows the current mpv playlist. You can press '2' to access it. 17 | #[derive(Debug, Default)] 18 | pub struct PlaylistScreen { 19 | songs: Vec, 20 | playing: CenteredListState, 21 | } 22 | 23 | impl PlaylistScreen { 24 | /// See 25 | pub fn update(&mut self, player: &impl Player) -> Result<&mut Self> { 26 | let n = player.playlist_count()?; 27 | 28 | self.songs = (0..n) 29 | .map(|i| player.playlist_track_title(i)) 30 | .collect::>()?; 31 | 32 | self.playing.select(player.playlist_position().ok()); 33 | 34 | Ok(self) 35 | } 36 | 37 | /// Waits a couple of milliseconds, then calls [update](PlaylistScreen::update). It's used 38 | /// primarily by [select_next](PlaylistScreen::select_next) and 39 | /// [select_prev](PlaylistScreen::select_prev) because mpv takes a while to update the playlist 40 | /// properties after changing the selection. 41 | pub fn update_after_delay(&self, app: &App) { 42 | let sender = app.channel.sender.clone(); 43 | thread::spawn(move || { 44 | thread::sleep(Duration::from_millis(16)); 45 | sender.send(events::Event::SecondTick).ok(); 46 | }); 47 | } 48 | 49 | fn handle_command(&mut self, app: &mut App, cmd: command::Command) -> Result<()> { 50 | use command::Command::*; 51 | match cmd { 52 | SelectNext | NextSong => self.select_next(app), 53 | SelectPrev | PrevSong => self.select_prev(app), 54 | _ => {} 55 | } 56 | Ok(()) 57 | } 58 | 59 | fn handle_terminal_event( 60 | &mut self, 61 | app: &mut App, 62 | event: crossterm::event::Event, 63 | ) -> Result<()> { 64 | use crossterm::event::{Event, KeyCode}; 65 | if let Event::Key(key_event) = event { 66 | match key_event.code { 67 | KeyCode::Up => self.select_prev(app), 68 | KeyCode::Down => self.select_next(app), 69 | _ => {} 70 | } 71 | } 72 | Ok(()) 73 | } 74 | 75 | pub fn select_next(&self, app: &mut App) { 76 | app.player 77 | .playlist_next() 78 | .unwrap_or_else(|_| app.notify_err("No next song")); 79 | self.update_after_delay(app); 80 | } 81 | 82 | pub fn select_prev(&self, app: &mut App) { 83 | app.player 84 | .playlist_previous() 85 | .unwrap_or_else(|_| app.notify_err("No previous song")); 86 | self.update_after_delay(app); 87 | } 88 | } 89 | 90 | impl Component for PlaylistScreen { 91 | type RenderState = (); 92 | 93 | fn mode(&self) -> Mode { 94 | Mode::Normal 95 | } 96 | 97 | fn render(&mut self, frame: &mut tui::Frame, chunk: Rect, (): ()) { 98 | let block = Block::default() 99 | .title(" Playlist ") 100 | .title_alignment(Alignment::Center) 101 | .borders(Borders::ALL) 102 | .border_type(BorderType::Rounded) 103 | .border_style(Style::default().fg(Color::LightRed)); 104 | 105 | let items: Vec<_> = self 106 | .songs 107 | .iter() 108 | .map(|x| CenteredListItem::new(x.as_str())) 109 | .collect(); 110 | let list = CenteredList::new(items) 111 | .block(block) 112 | .highlight_style(Style::default().bg(Color::Red).fg(Color::White)) 113 | .highlight_symbol("›") 114 | .highlight_symbol_right("‹"); 115 | 116 | frame.render_stateful_widget(list, chunk, &mut self.playing); 117 | 118 | if self.songs.len() > chunk.height as usize - 2 { 119 | if let Some(index) = self.playing.selected() { 120 | let scrollbar = Scrollbar::new(index as u16, self.songs.len() as u16) 121 | .with_style(Style::default().fg(Color::Red)); 122 | frame.render_widget(scrollbar, chunk); 123 | } 124 | } 125 | } 126 | 127 | fn handle_event(&mut self, app: &mut App, event: events::Event) -> Result<()> { 128 | use events::Event::*; 129 | match event { 130 | Command(cmd) => self.handle_command(app, cmd)?, 131 | Terminal(event) => self.handle_terminal_event(app, event)?, 132 | SecondTick => { 133 | self.update(&app.player)?; 134 | } 135 | _ => {} 136 | } 137 | Ok(()) 138 | } 139 | } 140 | 141 | impl MouseHandler for PlaylistScreen { 142 | fn handle_mouse( 143 | &mut self, 144 | _app: &mut App, 145 | _chunk: Rect, 146 | _event: crossterm::event::MouseEvent, 147 | ) -> Result<()> { 148 | Ok(()) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tori/src/command.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Default, Clone, Copy, PartialEq, Hash, Serialize, Deserialize)] 4 | pub enum Command { 5 | #[default] 6 | Nop, 7 | Quit, 8 | NextSong, 9 | PrevSong, 10 | TogglePause, 11 | ToggleLoop, 12 | SeekForward, 13 | SeekBackward, 14 | OpenInBrowser, 15 | CopyUrl, 16 | CopyTitle, 17 | VolumeUp, 18 | VolumeDown, 19 | Mute, 20 | ToggleVisualizer, 21 | NextSortingMode, 22 | OpenHelpModal, 23 | OpenHotkeyModal, 24 | 25 | /// Rename selected song or playlist 26 | Rename, 27 | 28 | /// Delete selected song or playlist 29 | Delete, 30 | 31 | /// Swap the selected song with the one below it 32 | SwapSongDown, 33 | 34 | /// Swap the selected song with the one above it 35 | SwapSongUp, 36 | 37 | /// Shuffle current playlist 38 | Shuffle, 39 | 40 | /// Select next item (like a song or playlist) 41 | SelectNext, 42 | 43 | /// Select previous item (like a song or playlist) 44 | SelectPrev, 45 | 46 | /// Select the pane to the right (the same as pressing the \ key) 47 | SelectRight, 48 | 49 | /// Select the pane to the left (the same as pressing the \ key) 50 | SelectLeft, 51 | 52 | /// Add a new song or playlist 53 | Add, 54 | 55 | /// Add song to the queue 56 | QueueSong, 57 | 58 | /// Add all shown songs to the queue 59 | QueueShown, 60 | 61 | /// Queries the user for a song to play, without adding it to a playlist 62 | PlayFromModal, 63 | 64 | /// Open the playlist file in the configured by the environment variable `EDITOR`. 65 | OpenInEditor, 66 | 67 | /// Filter/search the selected pane (playlists or songs). 68 | /// The same as pressing '/' 69 | Search, 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | #[test] 77 | fn test_serialization() { 78 | // not sure why serde_yaml puts a newline there ¯\_(ツ)_/¯ 79 | assert_eq!(serde_yaml::to_string(&Command::Quit).unwrap(), "Quit\n"); 80 | assert_eq!( 81 | serde_yaml::to_string(&Command::TogglePause).unwrap(), 82 | "TogglePause\n" 83 | ); 84 | } 85 | 86 | #[test] 87 | fn test_deserialization() { 88 | assert_eq!( 89 | serde_yaml::from_str::("Quit").unwrap(), 90 | Command::Quit 91 | ); 92 | assert_eq!( 93 | serde_yaml::from_str::("VolumeUp").unwrap(), 94 | Command::VolumeUp 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tori/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use once_cell::sync::OnceCell; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{io, path::PathBuf}; 5 | 6 | pub mod shortcuts; 7 | use shortcuts::Shortcuts; 8 | 9 | #[derive(Debug, Serialize, Deserialize)] 10 | pub struct Config { 11 | pub playlists_dir: String, 12 | pub visualizer_gradient: [(u8, u8, u8); 2], 13 | pub keybindings: Shortcuts, 14 | pub mpv_ao: Option, 15 | } 16 | 17 | static INSTANCE: OnceCell = OnceCell::new(); 18 | 19 | impl Config { 20 | pub fn global() -> &'static Self { 21 | INSTANCE.get().expect("Config instance not loaded!") 22 | } 23 | 24 | pub fn set_global(instance: Self) { 25 | INSTANCE.set(instance).unwrap(); 26 | } 27 | 28 | pub fn playlist_path(playlist_name: &str) -> PathBuf { 29 | PathBuf::from(&Config::global().playlists_dir).join(format!("{}.m3u8", playlist_name)) 30 | } 31 | 32 | pub fn merge(mut self, other: OptionalConfig) -> Self { 33 | if let Some(playlists_dir) = other.playlists_dir { 34 | self.playlists_dir = playlists_dir; 35 | } 36 | 37 | if let Some(keybindings) = other.keybindings { 38 | for (k, v) in keybindings.0 { 39 | self.keybindings.0.insert(k, v); 40 | } 41 | } 42 | 43 | if let Some(visualizer_gradient) = other.visualizer_gradient { 44 | let color_at = |i: usize| { 45 | visualizer_gradient[i].to_rgb().unwrap_or_else(|| { 46 | eprintln!( 47 | "Your tori.yaml configuration file has an invalid color in visualizer_gradient: {:?}", 48 | visualizer_gradient[i] 49 | ); 50 | std::process::exit(1); 51 | }) 52 | }; 53 | self.visualizer_gradient = [color_at(0), color_at(1)]; 54 | } 55 | 56 | self.mpv_ao = other.mpv_ao; 57 | 58 | self 59 | } 60 | } 61 | 62 | impl Default for Config { 63 | fn default() -> Self { 64 | let mut me: Self = serde_yaml::from_str(std::include_str!("../default_config.yaml")) 65 | .expect("src/default_config.yaml is not valid yaml!"); 66 | 67 | let audio_dir = dirs::audio_dir().filter(|p| p.exists()); 68 | let music_dir = dirs::home_dir() 69 | .map(|p| p.join("Music")) 70 | .filter(|p| p.exists()); 71 | 72 | me.playlists_dir = audio_dir 73 | .or(music_dir) 74 | .map(|p| p.join("tori")) 75 | .and_then(|p| p.to_str().map(str::to_string)) 76 | .unwrap_or("playlists".into()); 77 | 78 | me 79 | } 80 | } 81 | 82 | #[derive(Debug, Serialize, Deserialize)] 83 | #[serde(untagged)] 84 | pub enum Color { 85 | Rgb(u8, u8, u8), 86 | Str(String), 87 | } 88 | 89 | impl Color { 90 | pub fn to_rgb(&self) -> Option<(u8, u8, u8)> { 91 | match self { 92 | Color::Rgb(r, g, b) => Some((*r, *g, *b)), 93 | Color::Str(s) => { 94 | let s = s.trim_start_matches('#'); 95 | if s.len() != 6 { 96 | return None; 97 | } 98 | let r = u8::from_str_radix(&s[0..2], 16).ok()?; 99 | let g = u8::from_str_radix(&s[2..4], 16).ok()?; 100 | let b = u8::from_str_radix(&s[4..6], 16).ok()?; 101 | Some((r, g, b)) 102 | } 103 | } 104 | } 105 | } 106 | 107 | #[derive(Debug, Default, Serialize, Deserialize)] 108 | pub struct OptionalConfig { 109 | pub playlists_dir: Option, 110 | pub visualizer_gradient: Option<[Color; 2]>, 111 | pub keybindings: Option, 112 | pub mpv_ao: Option, 113 | } 114 | 115 | impl OptionalConfig { 116 | /// Loads the shortcuts from some path 117 | pub fn from_path>(path: P) -> Result { 118 | match std::fs::File::open(path) { 119 | Ok(file) => serde_yaml::from_reader(file).map_err(|e| { 120 | format!("Couldn't parse your tori.yaml config file. Reason: {}", e).into() 121 | }), 122 | Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()), 123 | Err(e) => Err(e.into()), 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tori/src/config/shortcuts.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crossterm::event::{KeyCode, KeyModifiers}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// Encapsulates a string representing some key event. 7 | /// 8 | /// For example: 9 | /// ``` 10 | /// use tori::config::shortcuts::InputStr; 11 | /// use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; 12 | /// 13 | /// fn key_event(modifiers: KeyModifiers, code: KeyCode) -> KeyEvent { 14 | /// KeyEvent { 15 | /// code, 16 | /// modifiers, 17 | /// kind: KeyEventKind::Press, 18 | /// state: KeyEventState::NONE, 19 | /// } 20 | /// } 21 | /// 22 | /// assert_eq!( 23 | /// InputStr::from(key_event(KeyModifiers::NONE, KeyCode::Char('a'))), 24 | /// InputStr("a".into()) 25 | /// ); 26 | /// assert_eq!( 27 | /// InputStr::from(key_event(KeyModifiers::CONTROL, KeyCode::Char('a'))), 28 | /// InputStr("C-a".into()) 29 | /// ); 30 | /// assert_eq!( 31 | /// InputStr::from(key_event(KeyModifiers::SHIFT, KeyCode::Char('B'))), 32 | /// InputStr("B".into()) 33 | /// ); 34 | /// assert_eq!( 35 | /// InputStr::from(key_event(KeyModifiers::ALT, KeyCode::Enter)), 36 | /// InputStr("A-enter".into()) 37 | /// ); 38 | /// assert_eq!( 39 | /// InputStr::from(key_event(KeyModifiers::CONTROL | KeyModifiers::SHIFT, KeyCode::Tab)), 40 | /// InputStr("C-S-tab".into()) 41 | /// ); 42 | /// ``` 43 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 44 | pub struct InputStr(pub String); 45 | 46 | impl From for InputStr { 47 | fn from(event: crossterm::event::KeyEvent) -> Self { 48 | let mut s = String::new(); 49 | let is_char = |e: crossterm::event::KeyEvent| matches!(e.code, KeyCode::Char(_)); 50 | 51 | // Modifiers 52 | if event.modifiers & KeyModifiers::CONTROL != KeyModifiers::NONE { 53 | s.push_str("C-"); 54 | } 55 | if event.modifiers & KeyModifiers::SHIFT != KeyModifiers::NONE && !is_char(event) { 56 | s.push_str("S-"); 57 | } 58 | if event.modifiers & KeyModifiers::ALT != KeyModifiers::NONE { 59 | s.push_str("A-"); 60 | } 61 | 62 | // Actual key 63 | use KeyCode::*; 64 | match event.code { 65 | Char(c) => { 66 | s.push(c); 67 | } 68 | other => s.push_str(&format!("{:?}", other).to_lowercase()), 69 | } 70 | 71 | InputStr(s) 72 | } 73 | } 74 | 75 | /// Stores a table of [Command](crate::command::Command) shortcuts. 76 | #[derive(Debug, Default, Serialize, Deserialize)] 77 | pub struct Shortcuts(pub HashMap); 78 | 79 | impl Shortcuts { 80 | pub fn new(map: HashMap) -> Self { 81 | Self(map) 82 | } 83 | 84 | pub fn get_from_event( 85 | &self, 86 | event: crossterm::event::KeyEvent, 87 | ) -> Option { 88 | self.0.get(&event.into()).cloned() 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use super::*; 95 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; 96 | 97 | fn key_event(modifiers: KeyModifiers, code: KeyCode) -> KeyEvent { 98 | KeyEvent { 99 | code, 100 | modifiers, 101 | kind: KeyEventKind::Press, 102 | state: KeyEventState::NONE, 103 | } 104 | } 105 | 106 | #[test] 107 | fn test_input_str() { 108 | assert_eq!( 109 | InputStr::from(key_event(KeyModifiers::NONE, KeyCode::Char('a'))), 110 | InputStr("a".into()) 111 | ); 112 | assert_eq!( 113 | InputStr::from(key_event(KeyModifiers::CONTROL, KeyCode::Char('a'))), 114 | InputStr("C-a".into()) 115 | ); 116 | assert_eq!( 117 | InputStr::from(key_event(KeyModifiers::SHIFT, KeyCode::Char('B'))), 118 | InputStr("B".into()) 119 | ); 120 | assert_eq!( 121 | // ctrl+shift+1, but shift+1 is ! 122 | InputStr::from(key_event( 123 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, 124 | KeyCode::Char('!') 125 | )), 126 | InputStr("C-!".into()) 127 | ); 128 | assert_eq!( 129 | InputStr::from(key_event(KeyModifiers::ALT, KeyCode::Enter)), 130 | InputStr("A-enter".into()) 131 | ); 132 | assert_eq!( 133 | InputStr::from(key_event( 134 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, 135 | KeyCode::Tab 136 | )), 137 | InputStr("C-S-tab".into()) 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tori/src/dbglog.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | #[macro_export] 4 | macro_rules! log { 5 | ($what:expr) => { 6 | $crate::dbglog::write(format!(concat!(stringify!($what), " = {:?}"), $what).as_str()); 7 | }; 8 | } 9 | 10 | #[allow(dead_code)] 11 | pub fn write(s: &str) { 12 | let mut f = std::fs::OpenOptions::new() 13 | .create(true) 14 | .write(true) 15 | .append(true) 16 | .open("dbg.log") 17 | .unwrap(); 18 | f.write_all(s.as_bytes()).unwrap(); 19 | f.write_all(b"\n").unwrap(); 20 | } 21 | -------------------------------------------------------------------------------- /tori/src/default_config.yaml: -------------------------------------------------------------------------------- 1 | playlists_dir: this is a placeholder value, src/config.rs overrides it 2 | visualizer_gradient: 3 | - [46, 20, 66] 4 | - [16, 30, 71] 5 | keybindings: 6 | '?': OpenHelpModal 7 | C-c: Quit 8 | C-d: Quit 9 | q: Quit 10 | ">": NextSong 11 | "<": PrevSong 12 | " ": TogglePause 13 | L: ToggleLoop 14 | S-right: SeekForward 15 | S-left: SeekBackward 16 | o: OpenInBrowser 17 | y: CopyUrl 18 | t: CopyTitle 19 | A-up: VolumeUp 20 | A-down: VolumeDown 21 | m: Mute 22 | v: ToggleVisualizer 23 | s: NextSortingMode 24 | R: Rename 25 | X: Delete 26 | S-down: SwapSongDown 27 | S-up: SwapSongUp 28 | J: SwapSongDown 29 | K: SwapSongUp 30 | ",": Shuffle 31 | h: SelectLeft 32 | j: SelectNext 33 | k: SelectPrev 34 | l: SelectRight 35 | a: Add 36 | u: QueueSong 37 | C-q: QueueShown 38 | p: PlayFromModal 39 | E: OpenInEditor 40 | '!': OpenHotkeyModal 41 | C-f: Search 42 | -------------------------------------------------------------------------------- /tori/src/error.rs: -------------------------------------------------------------------------------- 1 | pub type Error = Box; 2 | pub type Result = std::result::Result; 3 | -------------------------------------------------------------------------------- /tori/src/events.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::Event as CrosstermEvent; 2 | use std::sync::{mpsc, Arc, Mutex}; 3 | use std::time; 4 | use std::{ 5 | sync::mpsc::{channel, Receiver, Sender}, 6 | thread, 7 | }; 8 | 9 | use super::command::Command; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum Event { 13 | SecondTick, 14 | SongAdded { playlist: String, song: String }, 15 | ChangedPlaylist, 16 | Command(Command), 17 | Terminal(CrosstermEvent), 18 | } 19 | 20 | pub struct Channel { 21 | pub sender: Sender, 22 | pub receiver: Receiver, 23 | 24 | /// Whoever owns this mutex can receive events from crossterm::event::read(). 25 | /// 26 | /// Sometimes, like when the editor is open for editing a playlist, we dont' want to call 27 | /// `crosssterm::event::read()` because it will intercept the keypresses we want to send to the 28 | /// editor. In this case, the thread that opened the editor will hold the mutex until the user 29 | /// closes the editor. 30 | pub receiving_crossterm: Arc>, 31 | } 32 | 33 | impl Default for Channel { 34 | fn default() -> Self { 35 | let (sender, receiver) = channel(); 36 | let receiving_crossterm = Arc::new(Mutex::new(())); 37 | Self { 38 | sender, 39 | receiver, 40 | receiving_crossterm, 41 | } 42 | } 43 | } 44 | 45 | impl Channel { 46 | pub fn spawn_terminal_event_getter(&self) -> thread::JoinHandle<()> { 47 | let sender = self.sender.clone(); 48 | let receiving_crossterm = self.receiving_crossterm.clone(); 49 | thread::spawn(move || loop { 50 | { 51 | // WARNING: very short-lived lock. 52 | // Otherwise this mutex keeps relocking and starving the other thread. 53 | // I'm sure this will work in all cases (spoiler: no it doesn't (but maybe it does)) 54 | let _lock = receiving_crossterm.lock().unwrap(); 55 | }; 56 | 57 | if let Ok(event) = crossterm::event::read() { 58 | sender.send(Event::Terminal(event)).unwrap(); 59 | } 60 | }) 61 | } 62 | 63 | pub fn spawn_ticks(&self) -> thread::JoinHandle<()> { 64 | let sender = self.sender.clone(); 65 | thread::spawn(move || loop { 66 | thread::sleep(time::Duration::from_secs(1)); 67 | let sent = sender.send(Event::SecondTick); 68 | 69 | // Stop spawning ticks if the receiver has been dropped. This prevents a 70 | // 'called `Result::unwrap()` on an `Err` value: SendError { .. }' panic when Ctrl+C is 71 | // pressed and the receiver is dropped right before the sender tries to send the 72 | // tick 73 | if sent.is_err() { 74 | return; 75 | } 76 | }) 77 | } 78 | 79 | pub fn send(&mut self, event: Event) -> Result<(), mpsc::SendError> { 80 | self.sender.send(event) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tori/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | pub mod app; 4 | pub mod command; 5 | pub mod config; 6 | pub mod error; 7 | pub mod m3u; 8 | pub mod player; 9 | pub mod visualizer; 10 | 11 | mod dbglog; 12 | mod events; 13 | mod rect_ops; 14 | mod util; 15 | mod widgets; 16 | -------------------------------------------------------------------------------- /tori/src/m3u/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, ErrorKind, Read, Seek, Write}; 2 | 3 | use std::time::Duration; 4 | 5 | use crate::{config::Config, error::Result}; 6 | 7 | pub mod stringreader; 8 | pub use stringreader::StringReader; 9 | 10 | pub mod parser; 11 | pub use parser::Parser; 12 | 13 | pub mod playlist_management; 14 | 15 | #[derive(Debug, Default, Clone, PartialEq)] 16 | pub struct Song { 17 | pub title: String, 18 | pub duration: Duration, 19 | pub path: String, 20 | } 21 | 22 | impl Song { 23 | /// Parse a song from a given path. The path can be a url or a local file. 24 | pub fn from_path(path: &str) -> Result { 25 | if let Some(path) = path.strip_prefix("ytdl://") { 26 | let mut song = Song::parse_ytdlp(path)?; 27 | song.path = format!("ytdl://{}", song.path); 28 | Ok(song) 29 | } else if path.starts_with("http://") || path.starts_with("https://") { 30 | Song::parse_ytdlp(path) 31 | } else { 32 | Song::parse_local_file(path) 33 | } 34 | } 35 | 36 | /// Parses the song using yt-dlp 37 | pub fn parse_ytdlp(url: &str) -> Result { 38 | // TODO: maybe the user doesn't want to use yt-dlp? 39 | let output = std::process::Command::new("yt-dlp") 40 | .arg("--dump-single-json") 41 | .arg("--flat-playlist") 42 | .arg(url) 43 | .output() 44 | .map_err(|e| format!("Could not execute yt-dlp. Error: {}", e))?; 45 | 46 | if !output.status.success() { 47 | return Err(format!( 48 | "yt-dlp exited with status {}: {}", 49 | output.status, 50 | String::from_utf8_lossy(&output.stderr) 51 | ) 52 | .into()); 53 | } 54 | 55 | let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?; 56 | let title = metadata["title"].as_str().unwrap_or("?").into(); 57 | let duration = Duration::from_secs_f64(metadata["duration"].as_f64().unwrap_or(0.0)); 58 | Ok(Song { 59 | title, 60 | duration, 61 | path: url.into(), 62 | }) 63 | } 64 | 65 | /// Parses song from a local file using lofty. 66 | pub fn parse_local_file(path: &str) -> Result { 67 | use lofty::{ 68 | error::ErrorKind::{NotAPicture, UnknownFormat, UnsupportedPicture, UnsupportedTag}, 69 | Accessor, AudioFile, TaggedFileExt, 70 | }; 71 | 72 | let default_title = || { 73 | path.trim_end_matches('/') 74 | .rsplit('/') 75 | .next() 76 | .unwrap_or("Unknown title") 77 | .to_string() 78 | }; 79 | 80 | let tagged_file = match lofty::read_from_path(path) { 81 | Ok(tf) => Some(tf), 82 | Err(e) => match e.kind() { 83 | UnknownFormat | NotAPicture | UnsupportedPicture | UnsupportedTag => None, 84 | _ => return Err(e.into()), 85 | }, 86 | }; 87 | 88 | let tag = tagged_file 89 | .as_ref() 90 | .and_then(|t| t.primary_tag().or(t.first_tag())); 91 | 92 | let title = match ( 93 | tag.and_then(Accessor::artist), 94 | tag.and_then(Accessor::title), 95 | ) { 96 | (Some(artist), Some(title)) => format!("{} - {}", artist, title), 97 | (Some(artist), None) => format!("{} - ?", artist), 98 | (None, Some(title)) => title.to_string(), 99 | (None, None) => default_title(), 100 | }; 101 | 102 | let duration = tagged_file 103 | .map(|t| t.properties().duration()) 104 | .unwrap_or_default(); 105 | 106 | Ok(Song { 107 | title, 108 | duration, 109 | path: path.into(), 110 | }) 111 | } 112 | 113 | pub fn serialize(&self) -> String { 114 | let duration = self.duration.as_secs(); 115 | format!("#EXTINF:{},{}\n{}\n", duration, self.title, self.path) 116 | } 117 | 118 | pub fn add_to_playlist(&self, playlist_name: &str) -> Result<()> { 119 | let path = Config::playlist_path(playlist_name); 120 | let mut file = std::fs::OpenOptions::new() 121 | .create(true) 122 | .read(true) 123 | .append(true) 124 | .open(path)?; 125 | 126 | // Write a #EXTM3U header if it doesn't exist 127 | file.rewind()?; 128 | let mut buf = vec![0u8; "#EXTM3U".len()]; 129 | let has_extm3u = match file.read_exact(&mut buf) { 130 | Ok(()) if buf == b"#EXTM3U" => true, 131 | Ok(()) => false, 132 | Err(e) if e.kind() == ErrorKind::UnexpectedEof => false, 133 | Err(e) => { 134 | return Err(Box::new(e)); 135 | } 136 | }; 137 | 138 | if !has_extm3u { 139 | let mut buf = Vec::new(); 140 | file.rewind()?; 141 | file.read_to_end(&mut buf)?; 142 | file.set_len(0)?; 143 | file.write_all(b"#EXTM3U\n")?; 144 | file.write_all(&buf)?; 145 | } 146 | 147 | // Check if the file ends in a newline 148 | file.seek(io::SeekFrom::End(-1))?; 149 | buf = vec![0u8]; 150 | let ends_with_newline = match file.read_exact(&mut buf) { 151 | Ok(()) if buf == b"\n" => true, 152 | Ok(()) => false, 153 | Err(e) if e.kind() == ErrorKind::UnexpectedEof => false, 154 | Err(e) => { 155 | return Err(Box::new(e)); 156 | } 157 | }; 158 | 159 | if !ends_with_newline { 160 | file.write_all(b"\n")?; 161 | } 162 | 163 | // Write the serialized song 164 | file.write_all(self.serialize().as_bytes())?; 165 | Ok(()) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tori/src/m3u/parser.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fs, 4 | io::{self, BufRead, BufReader, Read}, 5 | time::Duration, 6 | }; 7 | 8 | use super::Song; 9 | use super::StringReader; 10 | 11 | ///////////////////////// 12 | // Error // 13 | ///////////////////////// 14 | #[derive(Debug)] 15 | pub enum ParserError { 16 | Io(io::Error), 17 | UnknownExtline(String), 18 | } 19 | 20 | impl Error for ParserError {} 21 | 22 | impl From for ParserError { 23 | fn from(e: io::Error) -> Self { 24 | ParserError::Io(e) 25 | } 26 | } 27 | 28 | impl std::fmt::Display for ParserError { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | match self { 31 | ParserError::Io(e) => write!(f, "IO error: {}", e), 32 | ParserError::UnknownExtline(s) => write!(f, "Unknown EXT directive: {}", s), 33 | } 34 | } 35 | } 36 | 37 | pub type Result = std::result::Result; 38 | 39 | ////////////////////////////////// 40 | // Ext Directives // 41 | ////////////////////////////////// 42 | #[derive(Debug, PartialEq)] 43 | enum Ext { 44 | Extm3u, 45 | Extinf(Duration, String), 46 | } 47 | 48 | ////////////////////////////// 49 | // LineReader // 50 | ////////////////////////////// 51 | pub trait LineReader { 52 | fn next_line(&mut self) -> std::result::Result<(String, usize), io::Error>; 53 | } 54 | 55 | impl LineReader for BufReader { 56 | fn next_line(&mut self) -> std::result::Result<(String, usize), io::Error> { 57 | let mut buf = String::new(); 58 | let count = self.read_line(&mut buf)?; 59 | Ok((buf, count)) 60 | } 61 | } 62 | 63 | ////////////////////////// 64 | // Parser // 65 | ////////////////////////// 66 | pub struct Parser { 67 | reader: L, 68 | line_buf: Option, 69 | cursor: usize, 70 | } 71 | 72 | impl Parser> { 73 | pub fn from_file(reader: fs::File) -> Self { 74 | Self { 75 | reader: BufReader::new(reader), 76 | line_buf: None, 77 | cursor: 0, 78 | } 79 | } 80 | 81 | pub fn from_path(path: impl AsRef) -> Result { 82 | let file = fs::File::open(path)?; 83 | Ok(Self::from_file(file)) 84 | } 85 | } 86 | 87 | impl<'s> Parser> { 88 | pub fn from_string(s: &'s str) -> Self { 89 | Self { 90 | reader: StringReader::new(s), 91 | line_buf: None, 92 | cursor: 0, 93 | } 94 | } 95 | } 96 | 97 | impl Parser> { 98 | pub fn from_reader(reader: R) -> Self { 99 | Self { 100 | reader: BufReader::new(reader), 101 | line_buf: None, 102 | cursor: 0, 103 | } 104 | } 105 | } 106 | 107 | impl Parser { 108 | pub fn cursor(&self) -> usize { 109 | self.cursor 110 | } 111 | 112 | fn peek_line(&mut self) -> Result> { 113 | if self.line_buf.is_none() { 114 | let (mut line, bytes) = self.reader.next_line()?; 115 | if bytes == 0 { 116 | return Ok(None); 117 | } 118 | 119 | self.cursor += bytes; 120 | 121 | let is_nl = |c| c == Some(b'\n') || c == Some(b'\r'); 122 | while is_nl(line.as_bytes().last().copied()) { 123 | line.pop(); 124 | } 125 | self.line_buf = Some(line); 126 | } 127 | 128 | Ok(self.line_buf.as_deref()) 129 | } 130 | 131 | fn consume_line(&mut self) -> Result> { 132 | self.peek_line()?; 133 | Ok(self.line_buf.take()) 134 | } 135 | 136 | pub fn next_header(&mut self) -> Result { 137 | match self.peek_line()? { 138 | Some(line) if line.starts_with("#EXTM3U") => { 139 | self.consume_line()?; 140 | Ok(true) 141 | } 142 | _otherwise => Ok(false), 143 | } 144 | } 145 | 146 | pub fn next_song(&mut self) -> Result> { 147 | let mut song = Song::default(); 148 | while let Some(line) = self.consume_line()? { 149 | let line = line.trim(); 150 | if line.is_empty() { 151 | } else if line.starts_with("#EXT") { 152 | use Ext::*; 153 | match parse_extline(line)? { 154 | Extm3u => {} 155 | Extinf(d, t) => { 156 | song.duration = d; 157 | song.title = t; 158 | } 159 | } 160 | } else { 161 | song.path = line.into(); 162 | if song.title.is_empty() { 163 | song.title = song.path.clone(); 164 | } 165 | return Ok(Some(song)); 166 | } 167 | } 168 | Ok(None) 169 | } 170 | 171 | pub fn all_songs(&mut self) -> Result> { 172 | let mut songs = Vec::new(); 173 | while let Some(song) = self.next_song()? { 174 | songs.push(song); 175 | } 176 | Ok(songs) 177 | } 178 | } 179 | 180 | fn parse_extline(line: &str) -> Result { 181 | use Ext::*; 182 | if line.starts_with("#EXTM3U") { 183 | return Ok(Extm3u); 184 | } 185 | 186 | if let Some(line) = line.strip_prefix("#EXTINF:") { 187 | let mut parts = line.splitn(2, ','); 188 | let duration = Duration::from_secs( 189 | parts 190 | .next() 191 | .and_then(|p| p.parse::().ok()) 192 | .unwrap_or_default() as u64, 193 | ); 194 | let title = parts.next().unwrap_or_default().to_string(); 195 | return Ok(Extinf(duration, title)); 196 | } 197 | 198 | Err(ParserError::UnknownExtline(line.to_string())) 199 | } 200 | 201 | #[cfg(test)] 202 | mod tests { 203 | use super::*; 204 | 205 | #[test] 206 | fn test_extline_parsing() { 207 | assert_eq!(parse_extline("#EXTM3U").ok(), Some(Ext::Extm3u)); 208 | assert_eq!( 209 | parse_extline("#EXTINF:10,Artist - Title").ok(), 210 | Some(Ext::Extinf( 211 | Duration::from_secs_f64(10.), 212 | "Artist - Title".into() 213 | )) 214 | ); 215 | assert_eq!( 216 | parse_extline("#EXTINF:").ok(), 217 | Some(Ext::Extinf(Duration::default(), String::default())) 218 | ); 219 | } 220 | 221 | #[test] 222 | fn test_parser() { 223 | let mut parser = Parser::from_string( 224 | r#" 225 | #EXTM3U 226 | 227 | #EXTINF:10,Artist - Title 228 | https://www.youtube.com/watch?v=dQw4w9WgXcQ 229 | #EXTINF:0,Yup 230 | /path/to/local/song 231 | "#, 232 | ); 233 | 234 | use super::Song; 235 | assert_eq!( 236 | parser.all_songs().ok(), 237 | Some(vec![ 238 | Song { 239 | title: "Artist - Title".into(), 240 | duration: Duration::from_secs_f64(10.), 241 | path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ".into() 242 | }, 243 | Song { 244 | title: "Yup".into(), 245 | duration: Duration::from_secs_f64(0.), 246 | path: "/path/to/local/song".into() 247 | } 248 | ]), 249 | ); 250 | 251 | let mut parser = Parser::from_string( 252 | r#" 253 | #EXTM3U 254 | #DOESNOTBEGINWITHEXT 255 | something.mp3 256 | "#, 257 | ); 258 | 259 | assert_eq!( 260 | parser.all_songs().ok(), 261 | Some(vec![ 262 | Song { 263 | title: "#DOESNOTBEGINWITHEXT".into(), 264 | duration: Duration::default(), 265 | path: "#DOESNOTBEGINWITHEXT".into(), 266 | }, 267 | Song { 268 | title: "something.mp3".into(), 269 | duration: Duration::default(), 270 | path: "something.mp3".into() 271 | }, 272 | ]), 273 | ); 274 | } 275 | 276 | #[test] 277 | fn test_extline_errors() { 278 | let mut parser = Parser::from_string( 279 | r#" 280 | #EXTM3U 281 | 282 | #EXTINF:10,Artist - Title 283 | https://www.youtube.com/watch?v=dQw4w9WgXcQ 284 | #EXTNOTSUPPORTED 285 | /path/to/local/song 286 | "#, 287 | ); 288 | 289 | assert!(matches!( 290 | parser.all_songs(), 291 | Err(ParserError::UnknownExtline(s)) if s == "#EXTNOTSUPPORTED" 292 | )); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /tori/src/m3u/playlist_management.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | io::{self, Write}, 4 | path, 5 | result::Result as StdResult, 6 | thread, 7 | }; 8 | 9 | use crate::{app::App, config::Config, error::Result, events::Event, m3u}; 10 | 11 | /// Adds a song to an existing playlist 12 | pub fn add_song(app: &mut App, playlist: &str, song_path: String) { 13 | app.notify_info(format!("Adding {}...", song_path)); 14 | 15 | if surely_invalid_path(&song_path) { 16 | app.notify_err(format!("Failed to add song path '{}'. Doesn't look like a URL and is not a valid path in your filesystem.", song_path)); 17 | return; 18 | } 19 | 20 | let sender = app.channel.sender.clone(); 21 | let playlist = playlist.to_string(); 22 | thread::spawn(move || { 23 | add_song_recursively(&song_path, &playlist); 24 | 25 | // Extract last part (separated by '/') of the song_path 26 | let mut rsplit = song_path.trim_end_matches('/').rsplit('/'); 27 | let song = rsplit.next().unwrap_or(&song_path).to_string(); 28 | 29 | let event = Event::SongAdded { playlist, song }; 30 | sender.send(event).expect("Failed to send internal event"); 31 | }); 32 | } 33 | 34 | /// Adds songs from some path. If the path points to a directory, it'll traverse the directory 35 | /// recursively, adding all songs inside it. If the path points to a file, it'll add that file. 36 | /// If it points to a URL, it adds the url. 37 | /// We do not traverse symlinks, to avoid infinite loops. 38 | fn add_song_recursively(path: &str, playlist_name: &str) { 39 | let file = std::path::Path::new(&path); 40 | if file.is_dir() && !file.is_symlink() { 41 | let mut entries: Vec<_> = fs::read_dir(path) 42 | .unwrap_or_else(|e| panic!("Failed to read directory '{}'. Error: {}", path, e)) 43 | .map(|entry| entry.expect("Failed to read entry").path()) 44 | .collect(); 45 | 46 | entries.sort(); 47 | 48 | for path in entries { 49 | let path = path.to_str().unwrap_or_else(|| { 50 | panic!( 51 | "Failed to add '{}' to playlist. Path is not valid UTF-8", 52 | path.display() 53 | ) 54 | }); 55 | add_song_recursively(path, playlist_name); 56 | } 57 | } else if !image_file(file) { 58 | let song = m3u::Song::from_path(path) 59 | .unwrap_or_else(|e| panic!("Failed to add '{}' to playlist. Error: {}", path, e)); 60 | song.add_to_playlist(playlist_name) 61 | .unwrap_or_else(|e| panic!("Failed to add '{}' to playlist. Error: {}", path, e)); 62 | } 63 | } 64 | 65 | fn surely_invalid_path(path: &str) -> bool { 66 | let file = std::path::Path::new(&path); 67 | !file.is_dir() // not a directory... 68 | && !file.exists() // ...or a valid filepath... 69 | && !path.starts_with("http://") // ...or a URL... 70 | && !path.starts_with("https://") 71 | && !path.starts_with("ytdl://") 72 | } 73 | 74 | fn image_file(file: &std::path::Path) -> bool { 75 | matches!( 76 | file.extension().and_then(|s| s.to_str()), 77 | Some("png") | Some("jpg") | Some("jpeg") | Some("webp") | Some("svg") 78 | ) 79 | } 80 | 81 | #[derive(Debug)] 82 | pub enum CreatePlaylistError { 83 | PlaylistAlreadyExists, 84 | InvalidChar(char), 85 | IOError(io::Error), 86 | } 87 | 88 | #[derive(Debug)] 89 | pub enum RenamePlaylistError { 90 | PlaylistAlreadyExists, 91 | EmptyPlaylistName, 92 | InvalidChar(char), 93 | IOError(io::Error), 94 | } 95 | 96 | impl From for CreatePlaylistError { 97 | fn from(value: io::Error) -> Self { 98 | Self::IOError(value) 99 | } 100 | } 101 | 102 | /// Creates the corresponding .m3u8 file for a new playlist 103 | pub fn create_playlist(playlist_name: &str) -> StdResult<(), CreatePlaylistError> { 104 | if playlist_name.contains('/') { 105 | return Err(CreatePlaylistError::InvalidChar('/')); 106 | } 107 | if playlist_name.contains('\\') { 108 | return Err(CreatePlaylistError::InvalidChar('\\')); 109 | } 110 | 111 | let path = Config::playlist_path(playlist_name); 112 | 113 | // TODO: when it's stabilized, use std::fs::File::create_new 114 | if path.try_exists()? { 115 | Err(CreatePlaylistError::PlaylistAlreadyExists) 116 | } else { 117 | fs::File::create(path)?; 118 | Ok(()) 119 | } 120 | } 121 | 122 | pub fn delete_song(playlist_name: &str, index: usize) -> Result<()> { 123 | let path = Config::playlist_path(playlist_name); 124 | let content = fs::read_to_string(&path)?; 125 | let mut parser = m3u::Parser::from_string(&content); 126 | 127 | parser.next_header()?; 128 | for _ in 0..index { 129 | parser.next_song()?; 130 | } 131 | 132 | let start_pos = parser.cursor(); 133 | let _song = parser.next_song()?; 134 | let end_pos = parser.cursor(); 135 | 136 | let mut file = fs::OpenOptions::new() 137 | .write(true) 138 | .truncate(true) 139 | .open(&path)?; 140 | file.write_all(content[..start_pos].as_bytes())?; 141 | file.write_all(content[end_pos..].as_bytes())?; 142 | 143 | Ok(()) 144 | } 145 | 146 | pub fn rename_song(playlist_name: &str, index: usize, new_name: &str) -> Result<()> { 147 | let path = Config::playlist_path(playlist_name); 148 | let content = fs::read_to_string(&path)?; 149 | let mut parser = m3u::Parser::from_string(&content); 150 | 151 | parser.next_header()?; 152 | for _ in 0..index { 153 | parser.next_song()?; 154 | } 155 | 156 | let start_pos = parser.cursor(); 157 | let song = parser.next_song()?; 158 | let end_pos = parser.cursor(); 159 | 160 | if let Some(mut song) = song { 161 | song.title = new_name.to_string(); 162 | let mut file = fs::OpenOptions::new() 163 | .write(true) 164 | .truncate(true) 165 | .open(&path)?; 166 | file.write_all(content[..start_pos].as_bytes())?; 167 | file.write_all(song.serialize().as_bytes())?; 168 | file.write_all(content[end_pos..].as_bytes())?; 169 | } 170 | 171 | Ok(()) 172 | } 173 | 174 | /// Swaps `index`-th song with the `index+1`-th (0-indexed) 175 | pub fn swap_song(playlist_name: &str, index: usize) -> Result<()> { 176 | let path = Config::playlist_path(playlist_name); 177 | let content = fs::read_to_string(&path)?; 178 | let mut parser = m3u::Parser::from_string(&content); 179 | 180 | parser.next_header()?; 181 | for _ in 0..index { 182 | parser.next_song()?; 183 | } 184 | 185 | let start_pos = parser.cursor(); 186 | let song1 = parser.next_song()?; 187 | let song2 = parser.next_song()?; 188 | let end_pos = parser.cursor(); 189 | 190 | if let (Some(song1), Some(song2)) = (song1, song2) { 191 | let mut file = fs::OpenOptions::new() 192 | .write(true) 193 | .truncate(true) 194 | .open(&path)?; 195 | file.write_all(content[..start_pos].as_bytes())?; 196 | file.write_all(song2.serialize().as_bytes())?; 197 | file.write_all(song1.serialize().as_bytes())?; 198 | file.write_all(content[end_pos..].as_bytes())?; 199 | } 200 | 201 | Ok(()) 202 | } 203 | 204 | pub fn rename_playlist(playlist_name: &str, new_name: &str) -> StdResult<(), RenamePlaylistError> { 205 | if new_name.is_empty() { 206 | return Err(RenamePlaylistError::EmptyPlaylistName); 207 | } 208 | 209 | if new_name.contains('/') { 210 | return Err(RenamePlaylistError::InvalidChar('/')); 211 | } 212 | 213 | if new_name.contains('\\') { 214 | return Err(RenamePlaylistError::InvalidChar('\\')); 215 | } 216 | 217 | let old_path: path::PathBuf = Config::playlist_path(playlist_name); 218 | let new_path: path::PathBuf = Config::playlist_path(new_name); 219 | 220 | if let Ok(metadata) = fs::metadata(new_path.clone()) { 221 | if metadata.is_file() || metadata.is_dir() { 222 | return Err(RenamePlaylistError::PlaylistAlreadyExists); 223 | } 224 | } 225 | 226 | match fs::rename(&old_path, &new_path) { 227 | Err(e) => Err(RenamePlaylistError::IOError(e)), 228 | Ok(_) => Ok(()), 229 | } 230 | } 231 | 232 | pub fn delete_playlist(playlist_name: &str) -> Result<()> { 233 | let path = Config::playlist_path(playlist_name); 234 | fs::remove_file(path)?; 235 | Ok(()) 236 | } 237 | -------------------------------------------------------------------------------- /tori/src/m3u/stringreader.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use super::parser::LineReader; 4 | 5 | /// Implements io::Read and parser::LineReader for strings 6 | pub struct StringReader<'s> { 7 | string: &'s str, 8 | cursor: usize, 9 | } 10 | 11 | impl<'s> StringReader<'s> { 12 | pub fn new(string: &'s str) -> Self { 13 | Self { string, cursor: 0 } 14 | } 15 | } 16 | 17 | impl<'s> Read for StringReader<'s> { 18 | fn read(&mut self, buf: &mut [u8]) -> Result { 19 | let bytes = self.string.as_bytes(); 20 | let bytes = &bytes[self.cursor..]; 21 | let bytecount = bytes.len().min(buf.len()); 22 | buf[..bytecount].copy_from_slice(&bytes[..bytecount]); 23 | self.cursor += bytecount; 24 | Ok(bytecount) 25 | } 26 | } 27 | 28 | impl<'s> LineReader for StringReader<'s> { 29 | fn next_line(&mut self) -> Result<(String, usize), std::io::Error> { 30 | let slice = &self.string[self.cursor..]; 31 | for i in 0..slice.len() { 32 | if slice.as_bytes()[i] == b'\n' { 33 | self.cursor += i + 1; 34 | return Ok((slice[..i].to_owned(), i + 1)); 35 | } 36 | } 37 | self.cursor += slice.len(); 38 | Ok((slice.to_owned(), slice.len())) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tori/src/main.rs: -------------------------------------------------------------------------------- 1 | pub use tori::*; 2 | 3 | use app::App; 4 | use argh::FromArgs; 5 | use config::{Config, OptionalConfig}; 6 | pub use error::{Error, Result}; 7 | use std::path::{Path, PathBuf}; 8 | 9 | #[derive(FromArgs)] 10 | /// The frictionless music player for the terminal 11 | struct Args { 12 | #[argh(option, short = 'c')] 13 | /// the path to an alternative config file. If not present, the config is loaded from 14 | /// $CONFIG_DIR/tori.yaml, where $CONFIG_DIR is $HOME/.config on Linux, 15 | /// $HOME/Library/Application Support on macOS, and %appdata% on Windows. 16 | config: Option, 17 | } 18 | 19 | fn main() -> Result<()> { 20 | pretty_env_logger::init(); 21 | 22 | let args: Args = argh::from_env(); 23 | Config::set_global({ 24 | let opt_conf = OptionalConfig::from_path( 25 | args.config 26 | .map(PathBuf::from) 27 | .unwrap_or(dirs::config_dir().unwrap_or_default().join("tori.yaml")), 28 | )?; 29 | 30 | Config::default().merge(opt_conf) 31 | }); 32 | 33 | make_sure_playlist_dir_exists(); 34 | 35 | let mut app = App::new()?; 36 | app.run() 37 | } 38 | 39 | fn make_sure_playlist_dir_exists() { 40 | let dir_str = &Config::global().playlists_dir; 41 | let dir = Path::new(dir_str); 42 | 43 | if !dir.exists() { 44 | print!( 45 | r"It seems your playlist directory ({dir_str}) does not exist! 46 | Would you like to create it? (Y/n) " 47 | ); 48 | std::io::Write::flush(&mut std::io::stdout()).unwrap(); 49 | let mut input = String::new(); 50 | std::io::stdin().read_line(&mut input).unwrap(); 51 | 52 | if input.trim().to_lowercase() != "n" { 53 | std::fs::create_dir_all(dir).unwrap(); 54 | } else { 55 | println!( 56 | r" 57 | tori cannot run without a playlists directory! 58 | You can either create the directory manually, or configure another path 59 | for the playlists by editing the config file. 60 | More information can be found in the docs: https://leoriether.github.io/tori/#configuration/" 61 | ); 62 | std::process::exit(1); 63 | } 64 | } 65 | 66 | if dir.is_file() { 67 | println!( 68 | r"The path to your playlists directory ({dir_str}) is a file, not a directory! 69 | To avoid data loss, tori will not delete it, but it will also not run until you fix this :) 70 | You can either delete the file and let tori create the directory, or configure another path 71 | for the playlists by editing the config file. 72 | More information can be found in the docs: https://leoriether.github.io/tori/#configuration/" 73 | ); 74 | std::process::exit(1); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tori/src/player/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | 3 | #[cfg(feature = "mpv")] 4 | mod mpv; 5 | #[cfg(feature = "mpv")] 6 | pub type DefaultPlayer = mpv::MpvPlayer; 7 | 8 | #[cfg(feature = "tori-player")] 9 | mod tori_player_glue; 10 | #[cfg(feature = "tori-player")] 11 | pub type DefaultPlayer = tori_player::Player; 12 | 13 | pub trait Player: Sized { 14 | fn new() -> Result; 15 | fn play(&mut self, path: &str) -> Result<()>; 16 | fn queue(&mut self, path: &str) -> Result<()>; 17 | fn seek(&mut self, seconds: f64) -> Result<()>; 18 | fn seek_absolute(&mut self, percent: usize) -> Result<()>; 19 | fn playlist_next(&mut self) -> Result<()>; 20 | fn playlist_previous(&mut self) -> Result<()>; 21 | fn toggle_pause(&mut self) -> Result<()>; 22 | fn toggle_loop_file(&mut self) -> Result<()>; 23 | fn looping_file(&self) -> Result; 24 | fn volume(&self) -> Result; 25 | fn add_volume(&mut self, x: isize) -> Result<()>; 26 | fn set_volume(&mut self, x: i64) -> Result<()>; 27 | fn toggle_mute(&mut self) -> Result<()>; 28 | fn muted(&self) -> Result; 29 | fn media_title(&self) -> Result; 30 | fn percent_pos(&self) -> Result; 31 | fn time_pos(&self) -> Result; 32 | fn time_remaining(&self) -> Result; 33 | fn paused(&self) -> Result; 34 | fn shuffle(&mut self) -> Result<()>; 35 | 36 | // Playlist-related: 37 | fn playlist_count(&self) -> Result; 38 | fn playlist_track_title(&self, i: usize) -> Result; 39 | fn playlist_position(&self) -> Result; 40 | } 41 | -------------------------------------------------------------------------------- /tori/src/player/mpv/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::error::Result; 3 | 4 | mod select; 5 | use select::Mpv; 6 | 7 | #[repr(transparent)] 8 | pub struct MpvPlayer { 9 | pub(crate) mpv: Mpv, 10 | } 11 | 12 | impl super::Player for MpvPlayer { 13 | fn new() -> Result { 14 | let mpv = Mpv::with_initializer(|mpv| { 15 | mpv.set_bool("video", false)?; 16 | mpv.set_i64("volume", 100)?; 17 | if let Some(ao) = &Config::global().mpv_ao { 18 | mpv.set_str("ao", ao.as_str())?; 19 | } 20 | Ok(()) 21 | })?; 22 | 23 | Ok(Self { mpv }) 24 | } 25 | 26 | fn play(&mut self, path: &str) -> Result<()> { 27 | self.mpv.play(path)?; 28 | Ok(()) 29 | } 30 | 31 | fn queue(&mut self, path: &str) -> Result<()> { 32 | self.mpv.queue(path)?; 33 | Ok(()) 34 | } 35 | 36 | fn seek(&mut self, seconds: f64) -> Result<()> { 37 | if seconds >= 0.0 { 38 | self.mpv.seek_forward(seconds)? 39 | } else { 40 | self.mpv.seek_backward(-seconds)? 41 | } 42 | Ok(()) 43 | } 44 | 45 | fn seek_absolute(&mut self, percent: usize) -> Result<()> { 46 | // this is bugged currently :/ 47 | // self.mpv.seek_percent_absolute(percent)?; 48 | self.mpv 49 | .command("seek", &[&format!("{}", percent), "absolute-percent"])?; 50 | Ok(()) 51 | } 52 | 53 | fn playlist_next(&mut self) -> Result<()> { 54 | self.mpv.playlist_next_weak()?; 55 | Ok(()) 56 | } 57 | 58 | fn playlist_previous(&mut self) -> Result<()> { 59 | self.mpv.playlist_previous_weak()?; 60 | Ok(()) 61 | } 62 | 63 | fn toggle_pause(&mut self) -> Result<()> { 64 | self.mpv.command("cycle", &["pause"])?; 65 | Ok(()) 66 | } 67 | 68 | fn toggle_loop_file(&mut self) -> Result<()> { 69 | let status = self.mpv.get_str("loop-file"); 70 | let next_status = match status.as_deref() { 71 | Ok("no") => "inf", 72 | _ => "no", 73 | }; 74 | self.mpv.set_str("loop-file", next_status)?; 75 | Ok(()) 76 | } 77 | 78 | fn looping_file(&self) -> Result { 79 | let status = self.mpv.get_str("loop-file")?; 80 | Ok(status == "inf") 81 | } 82 | 83 | fn volume(&self) -> Result { 84 | Ok(self.mpv.get_i64("volume")?) 85 | } 86 | 87 | fn add_volume(&mut self, x: isize) -> Result<()> { 88 | self.mpv.add_isize("volume", x)?; 89 | Ok(()) 90 | } 91 | 92 | fn set_volume(&mut self, x: i64) -> Result<()> { 93 | self.mpv.set_i64("volume", x)?; 94 | Ok(()) 95 | } 96 | 97 | fn toggle_mute(&mut self) -> Result<()> { 98 | self.mpv.command("cycle", &["mute"])?; 99 | Ok(()) 100 | } 101 | 102 | fn muted(&self) -> Result { 103 | Ok(self.mpv.get_bool("mute")?) 104 | } 105 | 106 | fn media_title(&self) -> Result { 107 | Ok(self.mpv.get_str("media-title")?) 108 | } 109 | 110 | fn percent_pos(&self) -> Result { 111 | Ok(self.mpv.get_i64("percent-pos")?) 112 | } 113 | 114 | fn time_pos(&self) -> Result { 115 | Ok(self.mpv.get_i64("time-pos")?) 116 | } 117 | 118 | fn time_remaining(&self) -> Result { 119 | Ok(self.mpv.get_i64("time-remaining")?) 120 | } 121 | 122 | fn paused(&self) -> Result { 123 | Ok(self.mpv.get_bool("pause")?) 124 | } 125 | 126 | fn shuffle(&mut self) -> Result<()> { 127 | Ok(self.mpv.command("playlist-shuffle", &[])?) 128 | } 129 | 130 | fn playlist_count(&self) -> Result { 131 | Ok(self.mpv.get_i64("playlist/count")? as usize) 132 | } 133 | 134 | fn playlist_track_title(&self, i: usize) -> Result { 135 | Ok(self 136 | .mpv 137 | .get_str(&format!("playlist/{}/title", i)) 138 | .or_else(|_| self.mpv.get_str(&format!("playlist/{}/filename", i)))?) 139 | } 140 | 141 | fn playlist_position(&self) -> Result { 142 | Ok(self.mpv.get_i64("playlist-playing-pos")? as usize) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tori/src/player/mpv/select.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt; 3 | use std::result::Result as StdResult; 4 | 5 | type Result = StdResult; 6 | 7 | macro_rules! define_method { 8 | (fn $name:ident($( $id:ident: $ty:ty ),*) -> $ret:ty) => { 9 | pub fn $name(&self, $($id: $ty),*) -> Result<$ret> { 10 | match self { 11 | Self::V034(mpv) => Ok(mpv.$name($($id),*)?), 12 | Self::V035(mpv) => Ok(mpv.$name($($id),*)?), 13 | } 14 | } 15 | }; 16 | } 17 | 18 | macro_rules! define_data { 19 | (set $name:ident $typ:ty) => { 20 | pub fn $name(&self, name: &str, data: $typ) -> Result<()> { 21 | match self { 22 | Self::V034(initializer) => Ok(initializer.set_property(name, data)?), 23 | Self::V035(initializer) => Ok(initializer.set_property(name, data)?), 24 | } 25 | } 26 | }; 27 | (get $name:ident $typ:ty) => { 28 | pub fn $name(&self, name: &str) -> Result<$typ> { 29 | match self { 30 | Self::V034(initializer) => Ok(initializer.get_property::<$typ>(name)?), 31 | Self::V035(initializer) => Ok(initializer.get_property::<$typ>(name)?), 32 | } 33 | } 34 | }; 35 | (add $name:ident $typ:ty) => { 36 | pub fn $name(&self, name: &str, data: $typ) -> Result<()> { 37 | match self { 38 | Self::V034(initializer) => Ok(initializer.add_property(name, data)?), 39 | Self::V035(initializer) => Ok(initializer.add_property(name, data)?), 40 | } 41 | } 42 | }; 43 | } 44 | 45 | /// A wrapper around mpv v0.34 or mpv v0.35, depending on which is installed. 46 | /// Uses libmpv-rs or libmpv-sirno depending on the mpv version. 47 | pub enum Mpv { 48 | V034(mpv034::Mpv), 49 | V035(mpv035::Mpv), 50 | } 51 | 52 | impl Mpv { 53 | pub fn with_initializer(init: F) -> Result 54 | where 55 | F: FnOnce(MpvInitializer) -> Result<()>, 56 | { 57 | let api_version = unsafe { libmpv_sys::mpv_client_api_version() }; 58 | 59 | if api_version >> 16 == mpv035::MPV_CLIENT_API_MAJOR { 60 | let mpv = mpv035::Mpv::with_initializer(|mpv| { 61 | let initializer = MpvInitializer::V035(mpv); 62 | init(initializer).map_err(|e| e.unwrap_v035())?; 63 | Ok(()) 64 | })?; 65 | Ok(Self::V035(mpv)) 66 | } else { 67 | let mpv = mpv034::Mpv::with_initializer(|mpv| { 68 | let initializer = MpvInitializer::V034(mpv); 69 | init(initializer).map_err(|e| e.unwrap_v034())?; 70 | Ok(()) 71 | })?; 72 | Ok(Self::V034(mpv)) 73 | } 74 | } 75 | 76 | pub fn play(&self, path: &str) -> Result<()> { 77 | match self { 78 | Self::V034(mpv) => { 79 | mpv.playlist_load_files(&[(path, mpv034::FileState::Replace, None)])?; 80 | Ok(()) 81 | } 82 | Self::V035(mpv) => { 83 | mpv.playlist_load_files(&[(path, mpv035::FileState::Replace, None)])?; 84 | Ok(()) 85 | } 86 | } 87 | } 88 | 89 | pub fn queue(&self, path: &str) -> Result<()> { 90 | match self { 91 | Self::V034(mpv) => { 92 | mpv.playlist_load_files(&[(path, mpv034::FileState::AppendPlay, None)])?; 93 | Ok(()) 94 | } 95 | Self::V035(mpv) => { 96 | mpv.playlist_load_files(&[(path, mpv035::FileState::AppendPlay, None)])?; 97 | Ok(()) 98 | } 99 | } 100 | } 101 | 102 | define_method! { fn seek_forward(s: f64) -> () } 103 | define_method! { fn seek_backward(s: f64) -> () } 104 | define_method! { fn playlist_next_weak() -> () } 105 | define_method! { fn playlist_previous_weak() -> () } 106 | define_method! { fn command(name: &str, args: &[&str]) -> () } 107 | 108 | define_data! { get get_bool bool } 109 | define_data! { get get_str String } 110 | define_data! { get get_i64 i64 } 111 | 112 | define_data! { set set_str &str } 113 | define_data! { set set_i64 i64 } 114 | 115 | define_data! { add add_isize isize } 116 | } 117 | 118 | /// Wrapper around the other MpvInitializers 119 | pub enum MpvInitializer { 120 | V034(mpv034::MpvInitializer), 121 | V035(mpv035::MpvInitializer), 122 | } 123 | 124 | impl MpvInitializer { 125 | define_data! { set set_bool bool } 126 | define_data! { set set_i64 i64 } 127 | define_data! { set set_str &str } 128 | } 129 | 130 | /// Wrapper around an mpv error 131 | #[derive(Debug, Clone)] 132 | pub enum MpvError { 133 | V034(mpv034::Error), 134 | V035(mpv035::Error), 135 | } 136 | 137 | impl MpvError { 138 | pub fn unwrap_v034(self) -> mpv034::Error { 139 | match self { 140 | Self::V034(err) => err, 141 | Self::V035(_) => panic!("Expected mpv v0.34, found v0.35"), 142 | } 143 | } 144 | 145 | pub fn unwrap_v035(self) -> mpv035::Error { 146 | match self { 147 | Self::V034(_) => panic!("Expected mpv v0.35, found v0.34"), 148 | Self::V035(err) => err, 149 | } 150 | } 151 | } 152 | 153 | impl From for MpvError { 154 | fn from(err: mpv034::Error) -> Self { 155 | Self::V034(err) 156 | } 157 | } 158 | 159 | impl From for MpvError { 160 | fn from(err: mpv035::Error) -> Self { 161 | Self::V035(err) 162 | } 163 | } 164 | 165 | impl fmt::Display for MpvError { 166 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 167 | match self { 168 | Self::V034(err) => err.fmt(f), 169 | Self::V035(err) => err.fmt(f), 170 | } 171 | } 172 | } 173 | 174 | impl Error for MpvError { 175 | fn source(&self) -> Option<&(dyn Error + 'static)> { 176 | match self { 177 | Self::V034(err) => Some(err), 178 | Self::V035(err) => Some(err), 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tori/src/player/tori_player_glue.rs: -------------------------------------------------------------------------------- 1 | use tori_player::{Result, controller::Controller}; 2 | 3 | macro_rules! my_todo { 4 | () => { 5 | Ok(Default::default()) 6 | }; 7 | } 8 | 9 | impl super::Player for tori_player::Player { 10 | fn new() -> Result { 11 | let controller = Controller::default(); 12 | Ok(Self { controller }) 13 | } 14 | 15 | fn play(&mut self, path: &str) -> Result<()> { 16 | self.controller.play(path) 17 | } 18 | 19 | fn queue(&mut self, path: &str) -> Result<()> { 20 | my_todo!() 21 | } 22 | 23 | fn seek(&mut self, seconds: f64) -> Result<()> { 24 | my_todo!() 25 | } 26 | 27 | fn seek_absolute(&mut self, percent: usize) -> Result<()> { 28 | my_todo!() 29 | } 30 | 31 | fn playlist_next(&mut self) -> Result<()> { 32 | my_todo!() 33 | } 34 | 35 | fn playlist_previous(&mut self) -> Result<()> { 36 | my_todo!() 37 | } 38 | 39 | fn toggle_pause(&mut self) -> Result<()> { 40 | my_todo!() 41 | } 42 | 43 | fn toggle_loop_file(&mut self) -> Result<()> { 44 | my_todo!() 45 | } 46 | 47 | fn looping_file(&self) -> Result { 48 | my_todo!() 49 | } 50 | 51 | fn volume(&self) -> Result { 52 | my_todo!() 53 | } 54 | 55 | fn add_volume(&mut self, x: isize) -> Result<()> { 56 | my_todo!() 57 | } 58 | 59 | fn set_volume(&mut self, x: i64) -> Result<()> { 60 | my_todo!() 61 | } 62 | 63 | fn toggle_mute(&mut self) -> Result<()> { 64 | my_todo!() 65 | } 66 | 67 | fn muted(&self) -> Result { 68 | my_todo!() 69 | } 70 | 71 | fn media_title(&self) -> Result { 72 | my_todo!() 73 | } 74 | 75 | fn percent_pos(&self) -> Result { 76 | my_todo!() 77 | } 78 | 79 | fn time_pos(&self) -> Result { 80 | my_todo!() 81 | } 82 | 83 | fn time_remaining(&self) -> Result { 84 | my_todo!() 85 | } 86 | 87 | fn paused(&self) -> Result { 88 | my_todo!() 89 | } 90 | 91 | fn shuffle(&mut self) -> Result<()> { 92 | my_todo!() 93 | } 94 | 95 | fn playlist_count(&self) -> Result { 96 | my_todo!() 97 | } 98 | 99 | fn playlist_track_title(&self, i: usize) -> Result { 100 | my_todo!() 101 | } 102 | 103 | fn playlist_position(&self) -> Result { 104 | my_todo!() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tori/src/rect_ops.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | use tui::layout::Rect; 3 | 4 | pub trait RectOps { 5 | fn contains(&self, x: u16, y: u16) -> bool; 6 | fn split_top(&self, n: u16) -> (Rect, Rect); 7 | fn split_bottom(&self, n: u16) -> (Rect, Rect); 8 | fn split_left(&self, n: u16) -> (Rect, Rect); 9 | fn split_right(&self, n: u16) -> (Rect, Rect); 10 | } 11 | 12 | impl RectOps for Rect { 13 | fn contains(&self, x: u16, y: u16) -> bool { 14 | x >= self.left() && x <= self.right() && y >= self.top() && y <= self.bottom() 15 | } 16 | 17 | fn split_top(&self, n: u16) -> (Rect, Rect) { 18 | let top = Rect { 19 | x: self.x, 20 | y: self.y, 21 | width: self.width, 22 | height: min(n, self.height), 23 | }; 24 | let bottom = Rect { 25 | x: self.x, 26 | y: self.y.saturating_add(n), 27 | width: self.width, 28 | height: self.height.saturating_sub(n), 29 | }; 30 | (top, bottom) 31 | } 32 | 33 | fn split_bottom(&self, n: u16) -> (Rect, Rect) { 34 | let top = Rect { 35 | x: self.x, 36 | y: self.y, 37 | width: self.width, 38 | height: self.height.saturating_sub(n), 39 | }; 40 | let bottom = Rect { 41 | x: self.x, 42 | y: self.y.saturating_add(self.height.saturating_sub(n)), 43 | width: self.width, 44 | height: min(n, self.height), 45 | }; 46 | (top, bottom) 47 | } 48 | 49 | fn split_left(&self, n: u16) -> (Rect, Rect) { 50 | let left = Rect { 51 | x: self.x, 52 | y: self.y, 53 | width: min(n, self.width), 54 | height: self.height, 55 | }; 56 | let right = Rect { 57 | x: self.x.saturating_add(n), 58 | y: self.y, 59 | width: self.width.saturating_sub(n), 60 | height: self.height, 61 | }; 62 | (left, right) 63 | } 64 | 65 | fn split_right(&self, n: u16) -> (Rect, Rect) { 66 | let left = Rect { 67 | x: self.x, 68 | y: self.y, 69 | width: self.width.saturating_sub(n), 70 | height: self.height, 71 | }; 72 | let right = Rect { 73 | x: self.x.saturating_add(self.width.saturating_sub(n)), 74 | y: self.y, 75 | width: min(n, self.width), 76 | height: self.height, 77 | }; 78 | (left, right) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tori/src/util.rs: -------------------------------------------------------------------------------- 1 | //! I don't really know where to put these... 2 | 3 | use std::time::{Duration, Instant}; 4 | 5 | ///////////////////////////// 6 | // ClickInfo // 7 | ///////////////////////////// 8 | #[derive(Debug)] 9 | pub struct ClickUpdateSummary { 10 | pub double_click: bool, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct ClickInfo { 15 | pub instant: Instant, 16 | pub y: u16, 17 | } 18 | 19 | impl ClickInfo { 20 | /// Updates the ClickInfo with another click 21 | pub fn update(last_click: &mut Option, y: u16) -> ClickUpdateSummary { 22 | let this_click = ClickInfo { 23 | instant: Instant::now(), 24 | y, 25 | }; 26 | 27 | let summary = if let Some(s_last_click) = last_click { 28 | let double_click = s_last_click.instant.elapsed() <= Duration::from_millis(200) 29 | && this_click.y == s_last_click.y; 30 | 31 | ClickUpdateSummary { double_click } 32 | } else { 33 | ClickUpdateSummary { 34 | double_click: false, 35 | } 36 | }; 37 | 38 | *last_click = Some(this_click); 39 | summary 40 | } 41 | } 42 | 43 | ///////////////////////////// 44 | // Clipboard // 45 | ///////////////////////////// 46 | #[cfg(feature = "clip")] 47 | pub fn copy_to_clipboard(text: String) { 48 | use clipboard::{ClipboardContext, ClipboardProvider}; 49 | let mut ctx: ClipboardContext = ClipboardContext::new().unwrap(); 50 | ctx.set_contents(text).unwrap(); 51 | } 52 | 53 | #[cfg(not(feature = "clip"))] 54 | pub fn copy_to_clipboard(_text: String) {} 55 | -------------------------------------------------------------------------------- /tori/src/visualizer/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error as StdError, 3 | fs::File, 4 | io::{self, Read, Write}, 5 | mem, 6 | path::PathBuf, 7 | process::Stdio, 8 | result::Result as StdResult, 9 | sync::{ 10 | atomic::{self, AtomicBool}, 11 | Arc, Mutex, 12 | }, 13 | thread, 14 | }; 15 | 16 | use rand::{thread_rng, Rng}; 17 | use tui::{ 18 | layout::Rect, 19 | style::{Color, Style}, 20 | }; 21 | 22 | use crate::{config::Config, error::Result}; 23 | 24 | macro_rules! cava_config { 25 | () => { 26 | r#" 27 | [general] 28 | bars = {0} 29 | framerate = 45 30 | lower_cutoff_freq = 30 31 | higher_cutoff_freq = 16000 32 | 33 | [output] 34 | method = raw 35 | channels = mono 36 | data_format = binary 37 | bit_format = 16bit 38 | reverse = 0 39 | 40 | [smoothing] 41 | ; monstercat = 1 42 | noise_reduction = 0.3 43 | "# 44 | }; 45 | } 46 | 47 | static MAX_BAR_VALUE: u16 = ((1 << 16_u32) - 1) as u16; 48 | 49 | pub struct CavaOptions { 50 | pub bars: usize, 51 | } 52 | 53 | type ThreadResult = StdResult<(), Box>; 54 | 55 | #[derive(Debug)] 56 | pub enum ThreadHandle { 57 | Stopped(ThreadResult), 58 | Running(thread::JoinHandle), 59 | } 60 | 61 | impl Default for ThreadHandle { 62 | fn default() -> Self { 63 | Self::Stopped(Ok(())) 64 | } 65 | } 66 | 67 | pub struct Visualizer { 68 | tmp_path: PathBuf, 69 | data: Arc>>, 70 | stop_flag: Arc, 71 | handle: ThreadHandle, 72 | } 73 | 74 | impl Visualizer { 75 | pub fn new(opts: CavaOptions) -> Result { 76 | let tmp_path = tori_tempfile(&opts)?; 77 | 78 | let mut process = std::process::Command::new("cava") 79 | .arg("-p") 80 | .arg(&tmp_path) 81 | .stdout(Stdio::piped()) 82 | .stderr(Stdio::piped()) 83 | .stdin(Stdio::null()) 84 | .spawn() 85 | .map_err(|e| { 86 | format!("Failed to spawn the visualizer process. Is `cava` installed? The received error was: {}", e) 87 | })?; 88 | 89 | let data = Arc::new(Mutex::new(vec![0_u16; opts.bars])); 90 | let stop_flag = Arc::new(AtomicBool::new(false)); 91 | let handle: thread::JoinHandle; 92 | 93 | { 94 | let data = data.clone(); 95 | let stop_flag = stop_flag.clone(); 96 | handle = thread::spawn(move || { 97 | let mut buf = vec![0_u8; 2 * opts.bars]; 98 | while !stop_flag.load(atomic::Ordering::Relaxed) { 99 | let stdout = process.stdout.as_mut().unwrap(); 100 | let read_res = stdout.read_exact(&mut buf); 101 | 102 | if let Err(e) = read_res { 103 | let mut stderr_contents = String::new(); 104 | let stderr = process.stderr.as_mut().unwrap(); 105 | stderr.read_to_string(&mut stderr_contents).unwrap(); 106 | return Err(format!("'{}'. Process stderr: {}", e, stderr_contents).into()); 107 | } 108 | 109 | let mut data = data.lock().unwrap(); 110 | for i in 0..data.len() { 111 | data[i] = u16::from_le_bytes([buf[2 * i], buf[2 * i + 1]]); 112 | } 113 | } 114 | process.kill()?; 115 | Ok(()) 116 | }); 117 | } 118 | 119 | Ok(Self { 120 | tmp_path, 121 | data, 122 | stop_flag, 123 | handle: ThreadHandle::Running(handle), 124 | }) 125 | } 126 | 127 | pub fn render(&self, buffer: &mut tui::buffer::Buffer) { 128 | let lerp = |from: u8, to: u8, perc: f64| { 129 | (from as f64 + perc * (to as f64 - from as f64)).round() as u8 130 | }; 131 | let lerp_grad = |gradient: [(u8, u8, u8); 2], perc| { 132 | Color::Rgb( 133 | lerp(gradient[0].0, gradient[1].0, perc), 134 | lerp(gradient[0].1, gradient[1].1, perc), 135 | lerp(gradient[0].2, gradient[1].2, perc), 136 | ) 137 | }; 138 | 139 | let gradient = Config::global().visualizer_gradient; 140 | 141 | let data = self.data.lock().unwrap(); 142 | let columns = std::cmp::min(data.len(), buffer.area().width as usize / 2); 143 | let size = *buffer.area(); 144 | for i in 0..columns { 145 | let perc = i as f64 / columns as f64; 146 | let style = Style::default().bg(lerp_grad(gradient, perc)); 147 | let height = (data[i] as u64 * size.height as u64 / MAX_BAR_VALUE as u64) as u16; 148 | 149 | let area = Rect { 150 | x: 2 * i as u16, 151 | y: size.height.saturating_sub(height), 152 | width: 1, 153 | height, 154 | }; 155 | buffer.set_style(area, style); 156 | } 157 | } 158 | 159 | pub fn thread_handle(&mut self) -> &ThreadHandle { 160 | match mem::take(&mut self.handle) { 161 | ThreadHandle::Running(handle) if handle.is_finished() => { 162 | self.handle = ThreadHandle::Stopped(handle.join().unwrap()); 163 | } 164 | other => { 165 | self.handle = other; 166 | } 167 | } 168 | 169 | &self.handle 170 | } 171 | } 172 | 173 | impl Drop for Visualizer { 174 | fn drop(&mut self) { 175 | // ~~hopefully~~ stop thread execution 176 | self.stop_flag.store(true, atomic::Ordering::Relaxed); 177 | std::fs::remove_file(&self.tmp_path).ok(); 178 | } 179 | } 180 | 181 | fn tori_tempfile(opts: &CavaOptions) -> StdResult { 182 | let path = std::env::temp_dir().join(format!("tori-{:x}", thread_rng().gen::())); 183 | 184 | if path.is_file() { 185 | return Err(io::ErrorKind::AlreadyExists.into()); 186 | } 187 | 188 | let cava_config = format!(cava_config!(), opts.bars); 189 | 190 | let mut temp = File::create(&path)?; 191 | temp.write_all(cava_config.as_bytes()).unwrap(); 192 | temp.flush().unwrap(); 193 | 194 | Ok(path) 195 | } 196 | -------------------------------------------------------------------------------- /tori/src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod scrollbar; 2 | pub use scrollbar::Scrollbar; 3 | 4 | pub mod notification; 5 | pub use notification::Notification; 6 | -------------------------------------------------------------------------------- /tori/src/widgets/notification.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ 3 | component::{Component, Mode}, 4 | App, 5 | }, 6 | error::Result, 7 | events, 8 | }; 9 | use std::{ 10 | borrow::Cow, 11 | time::{Duration, Instant}, 12 | }; 13 | use tui::{ 14 | layout::Rect, 15 | style::{Color, Style}, 16 | text::Span, 17 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, 18 | Frame, 19 | }; 20 | 21 | const WIDTH: u16 = 40; 22 | 23 | #[derive(Debug)] 24 | pub struct Notification<'t> { 25 | pub text: Cow<'t, str>, 26 | pub show_until: Instant, 27 | pub color: Color, 28 | height: u16, 29 | } 30 | 31 | impl<'t> Default for Notification<'t> { 32 | fn default() -> Self { 33 | Self { 34 | text: Cow::default(), 35 | show_until: Instant::now(), 36 | color: Color::White, 37 | height: 0, 38 | } 39 | } 40 | } 41 | 42 | impl<'t> Notification<'t> { 43 | pub fn new(text: T, duration: Duration) -> Self 44 | where 45 | T: Into>, 46 | { 47 | let text = text.into(); 48 | let height = count_lines(&text) + 2; 49 | Self { 50 | text, 51 | show_until: Instant::now() + duration, 52 | height, 53 | ..Default::default() 54 | } 55 | } 56 | 57 | pub fn colored(mut self, c: Color) -> Self { 58 | self.color = c; 59 | self 60 | } 61 | 62 | pub fn is_expired(&self) -> bool { 63 | Instant::now() > self.show_until 64 | } 65 | } 66 | 67 | impl<'t> Component for Notification<'t> { 68 | type RenderState = (); 69 | 70 | fn mode(&self) -> Mode { 71 | Mode::Normal 72 | } 73 | 74 | fn render(&mut self, frame: &mut Frame, size: Rect, (): ()) { 75 | if self.is_expired() { 76 | return; 77 | } 78 | 79 | let chunk = Rect { 80 | x: size.width - WIDTH - 3, 81 | y: size.height - self.height - 1, 82 | width: WIDTH + 2, 83 | height: self.height, 84 | }; 85 | 86 | let block = Block::default() 87 | .borders(Borders::ALL) 88 | .border_type(BorderType::Rounded) 89 | .border_style(Style::default().fg(self.color)); 90 | 91 | let text = Paragraph::new(self.text.as_ref()) 92 | .block(block) 93 | .style(Style::default().fg(self.color)) 94 | .wrap(Wrap { trim: true }); 95 | 96 | frame.render_widget(Clear, chunk); 97 | frame.render_widget(text, chunk); 98 | } 99 | 100 | /// No-op 101 | fn handle_event(&mut self, _app: &mut App, _event: events::Event) -> Result<()> { 102 | Ok(()) 103 | } 104 | } 105 | 106 | /// Copied from tui::widgets::reflow because the module is private :( 107 | mod reflow { 108 | use tui::text::StyledGrapheme; 109 | use unicode_width::UnicodeWidthStr; 110 | 111 | const NBSP: &str = "\u{00a0}"; 112 | 113 | /// A state machine to pack styled symbols into lines. 114 | /// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming 115 | /// iterators for that). 116 | pub trait LineComposer<'a> { 117 | fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>; 118 | } 119 | 120 | /// A state machine that wraps lines on word boundaries. 121 | pub struct WordWrapper<'a, 'b> { 122 | symbols: &'b mut dyn Iterator>, 123 | max_line_width: u16, 124 | current_line: Vec>, 125 | next_line: Vec>, 126 | /// Removes the leading whitespace from lines 127 | trim: bool, 128 | } 129 | 130 | impl<'a, 'b> WordWrapper<'a, 'b> { 131 | pub fn new( 132 | symbols: &'b mut dyn Iterator>, 133 | max_line_width: u16, 134 | trim: bool, 135 | ) -> WordWrapper<'a, 'b> { 136 | WordWrapper { 137 | symbols, 138 | max_line_width, 139 | current_line: vec![], 140 | next_line: vec![], 141 | trim, 142 | } 143 | } 144 | } 145 | 146 | impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { 147 | fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { 148 | if self.max_line_width == 0 { 149 | return None; 150 | } 151 | std::mem::swap(&mut self.current_line, &mut self.next_line); 152 | self.next_line.truncate(0); 153 | 154 | let mut current_line_width = self 155 | .current_line 156 | .iter() 157 | .map(|StyledGrapheme { symbol, .. }| symbol.width() as u16) 158 | .sum(); 159 | 160 | let mut symbols_to_last_word_end: usize = 0; 161 | let mut width_to_last_word_end: u16 = 0; 162 | let mut prev_whitespace = false; 163 | let mut symbols_exhausted = true; 164 | for StyledGrapheme { symbol, style } in &mut self.symbols { 165 | symbols_exhausted = false; 166 | let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP; 167 | 168 | // Ignore characters wider that the total max width. 169 | if symbol.width() as u16 > self.max_line_width 170 | // Skip leading whitespace when trim is enabled. 171 | || self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0 172 | { 173 | continue; 174 | } 175 | 176 | // Break on newline and discard it. 177 | if symbol == "\n" { 178 | if prev_whitespace { 179 | current_line_width = width_to_last_word_end; 180 | self.current_line.truncate(symbols_to_last_word_end); 181 | } 182 | break; 183 | } 184 | 185 | // Mark the previous symbol as word end. 186 | if symbol_whitespace && !prev_whitespace { 187 | symbols_to_last_word_end = self.current_line.len(); 188 | width_to_last_word_end = current_line_width; 189 | } 190 | 191 | self.current_line.push(StyledGrapheme { symbol, style }); 192 | current_line_width += symbol.width() as u16; 193 | 194 | if current_line_width > self.max_line_width { 195 | // If there was no word break in the text, wrap at the end of the line. 196 | let (truncate_at, truncated_width) = if symbols_to_last_word_end != 0 { 197 | (symbols_to_last_word_end, width_to_last_word_end) 198 | } else { 199 | (self.current_line.len() - 1, self.max_line_width) 200 | }; 201 | 202 | // Push the remainder to the next line but strip leading whitespace: 203 | { 204 | let remainder = &self.current_line[truncate_at..]; 205 | if let Some(remainder_nonwhite) = 206 | remainder.iter().position(|StyledGrapheme { symbol, .. }| { 207 | !symbol.chars().all(&char::is_whitespace) 208 | }) 209 | { 210 | self.next_line 211 | .extend_from_slice(&remainder[remainder_nonwhite..]); 212 | } 213 | } 214 | self.current_line.truncate(truncate_at); 215 | current_line_width = truncated_width; 216 | break; 217 | } 218 | 219 | prev_whitespace = symbol_whitespace; 220 | } 221 | 222 | // Even if the iterator is exhausted, pass the previous remainder. 223 | if symbols_exhausted && self.current_line.is_empty() { 224 | None 225 | } else { 226 | Some((&self.current_line[..], current_line_width)) 227 | } 228 | } 229 | } 230 | } 231 | 232 | fn count_lines(text: &str) -> u16 { 233 | use reflow::LineComposer; 234 | 235 | let mut count = 0; 236 | let span = Span::raw(text); 237 | let mut graphemes = span.styled_graphemes(Style::default()); 238 | let mut word_wrapper = reflow::WordWrapper::new(&mut graphemes, WIDTH, true); 239 | while let Some(_line) = word_wrapper.next_line() { 240 | count += 1; 241 | } 242 | count 243 | } 244 | 245 | #[cfg(test)] 246 | mod tests { 247 | use super::*; 248 | 249 | #[test] 250 | fn test_count_lines() { 251 | assert_eq!(count_lines("!"), 1); 252 | assert_eq!(count_lines(&"a".repeat(WIDTH as usize)), 1); 253 | assert_eq!(count_lines(&"b".repeat(WIDTH as usize + 1)), 2); 254 | assert_eq!(count_lines(&"c".repeat(WIDTH as usize * 2)), 2); 255 | assert_eq!(count_lines(&"d".repeat(WIDTH as usize * 2 + 1)), 3); 256 | // TODO: this test fails :( 257 | // assert_eq!(count_lines("a\nb\nc\nd"), 4); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /tori/src/widgets/scrollbar.rs: -------------------------------------------------------------------------------- 1 | use tui::{buffer::Buffer, layout::Rect, style::Style, widgets::Widget}; 2 | 3 | /// Widget that draws a scrollbar at the right side of a chunk 4 | #[derive(Debug, Default)] 5 | pub struct Scrollbar { 6 | /// Line/position of the scrollable component that's currently selected 7 | pub position: u16, 8 | 9 | /// Total height of the scrollable component 10 | pub total_height: u16, 11 | 12 | pub style: Style, 13 | } 14 | 15 | impl Scrollbar { 16 | pub fn new(position: u16, total_height: u16) -> Self { 17 | Self { 18 | position, 19 | total_height, 20 | ..Default::default() 21 | } 22 | } 23 | 24 | pub fn with_style(mut self, style: Style) -> Self { 25 | self.style = style; 26 | self 27 | } 28 | } 29 | 30 | impl Widget for Scrollbar { 31 | fn render(mut self, area: Rect, buf: &mut Buffer) { 32 | self.total_height = std::cmp::max(1, self.total_height); 33 | let scrollbar_height = (area.height / self.total_height).max(2).min(6); 34 | let pos = (self.position as f64 / self.total_height as f64 * area.height as f64).round() 35 | as u16 36 | + area.top(); 37 | 38 | for line in pos..(pos + scrollbar_height).min(area.bottom()) { 39 | buf.set_string(area.right().saturating_sub(1), line, "█", self.style); 40 | } 41 | } 42 | } 43 | --------------------------------------------------------------------------------