├── .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
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
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 | 
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
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 | 
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