mirror of
https://github.com/talwat/lowfi
synced 2026-01-08 15:03:29 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bffe77b2f | ||
|
|
7e1a97fa7b | ||
|
|
a50e9a9453 | ||
|
|
1b3aa5617e | ||
|
|
6f6a552de5 | ||
|
|
9acfcdcf9d | ||
|
|
cd002ef9ab | ||
|
|
6b65b7d952 | ||
|
|
6802db1a1e | ||
|
|
fa236439e3 | ||
|
|
0dc3eddab7 | ||
|
|
297172b18a | ||
|
|
99ef0a5f85 | ||
|
|
7fa9a712a9 | ||
|
|
8a1f6dd214 | ||
|
|
8ff0f477ac | ||
|
|
287a61ca80 | ||
|
|
4b2fac3529 | ||
|
|
89da41c9ff | ||
|
|
0382a9dcbb | ||
|
|
ea24b7d8b3 | ||
|
|
11a6debcc4 | ||
|
|
905e0ee098 | ||
|
|
556e6881d1 | ||
|
|
9e1edc06eb | ||
|
|
abc88b2c86 | ||
|
|
af8d45905f | ||
|
|
4f3fa02cb4 | ||
|
|
702f29978f | ||
|
|
3ce4e0b8fc | ||
|
|
e44a8b85c4 | ||
|
|
a26623f9c0 | ||
|
|
4c874e9fef | ||
|
|
cba68de6ff | ||
|
|
f88b46ec56 | ||
|
|
c8fd6d5537 | ||
|
|
ab2d559129 | ||
|
|
535ba788f9 | ||
|
|
a87a8cc59e | ||
|
|
1c8c788d76 | ||
|
|
b36cd89638 | ||
|
|
cd57068f02 | ||
|
|
ebce85cae9 | ||
|
|
95ce4f2352 | ||
|
|
486f6ddd32 | ||
|
|
b0a1a1e399 | ||
|
|
bd0e0968ae | ||
|
|
81401575de | ||
|
|
6a155f3cd3 | ||
|
|
8fcc2213c9 | ||
|
|
f8b39da92f | ||
|
|
1f3a751a90 | ||
|
|
20a87d5363 | ||
|
|
37ca383e6a | ||
|
|
2dd165d562 | ||
|
|
b035061fd0 | ||
|
|
9439866f52 | ||
|
|
bd525b0813 | ||
|
|
bf1b5f4f4e | ||
|
|
09dd58664b | ||
|
|
adde9c42ab | ||
|
|
174002688a | ||
|
|
d8e4891968 | ||
|
|
563dc67b35 | ||
|
|
59d9d61272 | ||
|
|
0841e509e1 | ||
|
|
e702c1de00 | ||
|
|
8f837302c3 | ||
|
|
226c674295 | ||
|
|
66f2243b2c | ||
|
|
05fe8069ea | ||
|
|
9b61dffb12 | ||
|
|
c2530453fb | ||
|
|
e4fd542edf | ||
|
|
632b298de2 | ||
|
|
41ba98b9cf | ||
|
|
0162421db4 | ||
|
|
4d4f5e0920 | ||
|
|
d41bd16069 | ||
|
|
d2c8bdb8aa | ||
|
|
f6ec3bb1fe | ||
|
|
6f679055ea | ||
|
|
1bb3ee2e02 | ||
|
|
d60dc362ca | ||
|
|
3e0cbf9871 | ||
|
|
6f15f9226f | ||
|
|
bdd508bfbb | ||
|
|
19ca315509 | ||
|
|
620b568926 | ||
|
|
ad1fe84480 | ||
|
|
d81c4cced8 | ||
|
|
1884d2ebed | ||
|
|
9f7c895154 | ||
|
|
0b7638468c | ||
|
|
2c9a8229da | ||
|
|
f6bea9f048 | ||
|
|
cd4a371da1 | ||
|
|
05747b6537 | ||
|
|
399ee25ef5 | ||
|
|
3713056f70 | ||
|
|
0dc1f72d97 | ||
|
|
cb886b344e | ||
|
|
69747ff8b4 | ||
|
|
0b15ce8e1b | ||
|
|
6fadfe6304 | ||
|
|
b6a81c9634 | ||
|
|
1af976ad77 | ||
|
|
e8b4b17f98 | ||
|
|
1a76699afc | ||
|
|
2ccf073646 | ||
|
|
315fa105bf | ||
|
|
7cdd2e7694 | ||
|
|
a89854e46f | ||
|
|
f1c6cbf026 | ||
|
|
d24c6b1a74 | ||
|
|
a83a052ae9 | ||
|
|
a9cd30550c | ||
|
|
29dab7a77a | ||
|
|
fe70800502 | ||
|
|
d05f36a0bb | ||
|
|
5db5146b8e | ||
|
|
34577efe8f | ||
|
|
968c1ee670 | ||
|
|
bbdcfdd6f2 | ||
|
|
8e843c12a2 | ||
|
|
adcb20f2d0 | ||
|
|
27fc505830 | ||
|
|
66ccc44099 |
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@ -1,6 +1,7 @@
|
|||||||
name: Release Build
|
name: Release Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
release:
|
release:
|
||||||
types: [created]
|
types: [created]
|
||||||
|
|
||||||
|
|||||||
27
.github/workflows/tests.yml
vendored
Normal file
27
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
name: Rust Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: sudo apt install libasound2-dev
|
||||||
|
|
||||||
|
- name: Setup rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --all --verbose
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
|
/cache
|
||||||
|
.DS_Store
|
||||||
30
CONTRIBUTING.md
Normal file
30
CONTRIBUTING.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Contributing to lowfi
|
||||||
|
|
||||||
|
[[Version française](./docs/fr/CONTRIBUER.md)]
|
||||||
|
|
||||||
|
There are a few guidelines outlined here that will make it more likely for your PR to be accepted.
|
||||||
|
Only ones that are less obvious are going to be listed. If you need to ask, it's probably a no.
|
||||||
|
|
||||||
|
## 1. AI
|
||||||
|
|
||||||
|
You can use AI for searching or if there's something minor and tedious (eg. tests) that you'd like
|
||||||
|
to avoid having to do manually.
|
||||||
|
|
||||||
|
With that said, if it is noticeable that you used AI then it is way too much.
|
||||||
|
AI generated PR's do not help maintainers, it's just a hassle and frequently wastes their time.
|
||||||
|
|
||||||
|
## 2. Smaller is better
|
||||||
|
|
||||||
|
Try and make it so that each PR is one contained feature. Adding multiple features in a PR is usually a bad idea.
|
||||||
|
This is also so that individual features can be approved or denied, rather than that having to be for a more significant
|
||||||
|
chunk of code.
|
||||||
|
|
||||||
|
## 3. Keep lowfi simple
|
||||||
|
|
||||||
|
lowfi is supposed simple program. For now, no changes to the initial user-facing UI will be accepted.
|
||||||
|
The UI of lowfi playing a song has stayed identical since the first versions, since complicating it
|
||||||
|
detracts from it's purpose.
|
||||||
|
|
||||||
|
More complex features, like fancy colors or cover art, will not be accepted ever. Implementations of
|
||||||
|
acceptable features should also be simple and not too obtrusive. Even if a feature is simple,
|
||||||
|
if it is very complex to implement, then it won't be accepted.
|
||||||
2465
Cargo.lock
generated
2465
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
73
Cargo.toml
73
Cargo.toml
@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "1.6.2-dev"
|
version = "2.0.1"
|
||||||
|
rust-version = "1.83.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "An extremely simple lofi player."
|
description = "An extremely simple lofi player."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -17,37 +18,65 @@ homepage = "https://github.com/talwat/lowfi"
|
|||||||
repository = "https://github.com/talwat/lowfi"
|
repository = "https://github.com/talwat/lowfi"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
mpris = ["dep:mpris-server"]
|
mpris = ["dep:mpris-server", "dep:arc-swap"]
|
||||||
|
extra-audio-formats = ["rodio/default"]
|
||||||
|
scrape = [
|
||||||
|
"dep:serde",
|
||||||
|
"dep:serde_json",
|
||||||
|
"dep:html-escape",
|
||||||
|
"dep:scraper",
|
||||||
|
"dep:indicatif",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Basics
|
# Basics
|
||||||
clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
||||||
eyre = { version = "0.6.12" }
|
eyre = "0.6.12"
|
||||||
rand = "0.8.5"
|
fastrand = "2.3.0"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
tokio = { version = "1.41.1", features = [
|
tokio = { version = "1.41.1", features = ["macros", "rt", "fs", "io-util", "sync", "time"], default-features = false }
|
||||||
"macros",
|
futures-util = { version = "0.3.31", default-features = false }
|
||||||
"rt-multi-thread",
|
arc-swap = { version = "1.7.1", optional = true }
|
||||||
"fs"
|
|
||||||
], default-features = false }
|
|
||||||
futures = "0.3.31"
|
|
||||||
arc-swap = "1.7.1"
|
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
reqwest = "0.12.9"
|
reqwest = { version = "0.13.1", features = ["stream", "http2", "default-tls"], default-features = false }
|
||||||
|
chrono = { version = "0.4.42", features = ["clock"], default-features = false }
|
||||||
bytes = "1.9.0"
|
bytes = "1.9.0"
|
||||||
|
|
||||||
# I/O
|
# I/O
|
||||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
crossterm = { version = "0.29.0", features = ["event-stream", "windows"], default-features = false }
|
||||||
rodio = { version = "0.20.1", features = ["symphonia-mp3"], default-features = false }
|
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
|
||||||
mpris-server = { version = "0.8.1", optional = true }
|
mpris-server = { version = "0.9.0", optional = true }
|
||||||
dirs = "5.0.1"
|
dirs = "6.0.0"
|
||||||
|
|
||||||
# Misc
|
# Text processing
|
||||||
scraper = "0.21.0"
|
|
||||||
Inflector = "0.11.4"
|
|
||||||
lazy_static = "1.5.0"
|
|
||||||
libc = "0.2.167"
|
|
||||||
url = "2.5.4"
|
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
|
url = { version = "2.5.4", default-features = false }
|
||||||
|
|
||||||
|
# Scraper
|
||||||
|
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||||
|
serde_json = { version = "1.0.142", optional = true }
|
||||||
|
scraper = { version = "0.25.0", optional = true }
|
||||||
|
html-escape = { version = "0.2.13", optional = true }
|
||||||
|
indicatif = { version = "0.18.0", optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libc = "0.2.167"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = { level = "warn", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
nursery = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
|
unwrap_in_result = "warn"
|
||||||
|
missing_docs_in_private_items = "warn"
|
||||||
|
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
struct_excessive_bools = "allow"
|
||||||
|
|||||||
183
README.md
183
README.md
@ -1,30 +1,26 @@
|
|||||||
# lowfi
|
# lowfi
|
||||||
|
|
||||||
|
[[Version française](./docs/fr/README.md)]
|
||||||
|
|
||||||
lowfi is a tiny rust app that serves a single purpose: play lofi.
|
lowfi is a tiny rust app that serves a single purpose: play lofi.
|
||||||
It'll do this as simply as it can: no albums, no ads, just lofi.
|
It'll do this as simply as it can: no albums, no ads, just lofi.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
|
As of the 1.7.0 version of lowfi, **all** of the audio files embedded
|
||||||
under their [licensing guidelines](https://form.lofigirl.com/CommercialLicense).
|
by default are from [chillhop](https://chillhop.com/). Read
|
||||||
|
[MUSIC](./docs/MUSIC.md) for more information.
|
||||||
If god forbid you're planning to use this in a commercial setting, please
|
|
||||||
follow their rules.
|
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
I really hate modern music platforms, and I wanted a small, "suckless"
|
I really hate modern music platforms, and I wanted a small, simple
|
||||||
app that would just play random lofi without video.
|
app that would just play random ambient music without video and other fluff.
|
||||||
|
|
||||||
It was also designed to be fairly resilient to inconsistent networks,
|
Beyond that, it was also designed to be fairly resilient to inconsistent networks,
|
||||||
and as such it buffers 5 whole songs at a time instead of parts of the same song.
|
and as such it buffers 5 whole songs at a time instead of parts of the same song.
|
||||||
|
|
||||||
Although, lowfi is yet to be properly tested in difficult conditions,
|
|
||||||
so don't rely on it too much until I do that. See [Scraping](#scraping) if
|
|
||||||
you're interested in downloading the tracks. Beware, there's a lot of them.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@ -34,14 +30,14 @@ you're interested in downloading the tracks. Beware, there's a lot of them.
|
|||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
You'll need Rust 1.74.0+.
|
You'll need Rust 1.83.0+.
|
||||||
|
|
||||||
On MacOS & Windows, no extra dependencies are needed.
|
On MacOS & Windows, no extra dependencies are needed.
|
||||||
|
|
||||||
On Linux, you'll also need openssl & alsa, as well as their headers.
|
On Linux, you'll also need openssl & alsa, as well as their headers.
|
||||||
|
|
||||||
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu.
|
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu, `alsa-lib-devel` on Fedora.
|
||||||
- `openssl` on Arch, `libssl-dev` on Ubuntu.
|
- `openssl` on Arch, `libssl-dev` on Ubuntu, `openssl-devel` on Fedora.
|
||||||
|
|
||||||
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
||||||
|
|
||||||
@ -57,16 +53,15 @@ cargo install lowfi --features mpris
|
|||||||
```
|
```
|
||||||
|
|
||||||
and making sure `$HOME/.cargo/bin` is added to `$PATH`.
|
and making sure `$HOME/.cargo/bin` is added to `$PATH`.
|
||||||
|
Also see [Extra Features](#extra-features) for extended functionality.
|
||||||
|
|
||||||
### Release Binaries
|
### Release Binaries
|
||||||
|
|
||||||
If you're struggling or unwilling to use cargo, you can just download
|
If you're struggling or unwilling to use cargo, you can just download the
|
||||||
precompiled binaries from the [latest release](https://github.com/talwat/lowfi/releases/latest).
|
precompiled binaries from the [latest release](https://github.com/talwat/lowfi/releases/latest).
|
||||||
|
|
||||||
### AUR
|
### AUR
|
||||||
|
|
||||||
If you're on Arch, you can also use the AUR:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yay -S lowfi
|
yay -S lowfi
|
||||||
```
|
```
|
||||||
@ -77,6 +72,27 @@ yay -S lowfi
|
|||||||
zypper install lowfi
|
zypper install lowfi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Debian
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This uses an unofficial Debian repository maintained by [Dario Griffo](https://github.com/dariogriffo).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||||
|
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||||
|
sudo apt install -y lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora (COPR)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo dnf copr enable furqanhun/lowfi
|
||||||
|
sudo dnf install lowfi
|
||||||
|
```
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
This is good for debugging, especially in issues.
|
This is good for debugging, especially in issues.
|
||||||
@ -110,76 +126,112 @@ Yeah, that's it.
|
|||||||
| `-`, `_`, `j`, `↓` | Volume Down 10% |
|
| `-`, `_`, `j`, `↓` | Volume Down 10% |
|
||||||
| `←` | Volume Down 1% |
|
| `←` | Volume Down 1% |
|
||||||
| `q`, CTRL+C | Quit |
|
| `q`, CTRL+C | Quit |
|
||||||
|
| `b` | Bookmark |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
||||||
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`)
|
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
|
||||||
>
|
>
|
||||||
> MPRIS is currently optional feature in cargo (enabled with `--features mpris`)
|
> MPRIS is currently an [optional feature](#extra-features) in cargo (enabled with `--features mpris`)
|
||||||
> due to it being only for Linux, as well as the fact that the main point of
|
> due to it being only for Linux, as well as the fact that the main point of
|
||||||
> lowfi is it's unique & minimal interface.
|
> lowfi is it's unique & minimal interface.
|
||||||
|
|
||||||
|
### Bookmarks
|
||||||
|
|
||||||
|
Bookmarks are lowfi's extremely simple answer to "what about if I'd like to save a track."
|
||||||
|
You can bookmark/unbookmark tracks with `b`, and play them with `lowfi -t bookmarks`.
|
||||||
|
|
||||||
|
From a technical perspective, your bookmarks are no different to any other track list,
|
||||||
|
and as such are also stored in the same directory.
|
||||||
|
|
||||||
### Extra Flags
|
### Extra Flags
|
||||||
|
|
||||||
If you have something you'd like to tweak about lowfi, you use additional flags which
|
If you have something you'd like to tweak about lowfi, you use additional flags which
|
||||||
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
|
slightly tweak the UI or behavior of the menu. The flags can be viewed with `lowfi --help`.
|
||||||
|
|
||||||
| Flag | Function |
|
| Flag | Function |
|
||||||
| ------------------------------- | ---------------------------------------------- |
|
| ----------------------------------- | --------------------------------------------------- |
|
||||||
| `-a`, `--alternate` | Use an alternate terminal screen |
|
| `-a`, `--alternate` | Use an alternate terminal screen |
|
||||||
| `-m`, `--minimalist` | Hide the bottom control bar |
|
| `-m`, `--minimalist` | Hide the bottom control bar |
|
||||||
| `-b`, `--borderless` | Exclude borders in UI |
|
| `-b`, `--borderless` | Exclude borders in UI |
|
||||||
| `-p`, `--paused` | Start lowfi paused |
|
| `-p`, `--paused` | Start lowfi paused |
|
||||||
| `-d`, `--debug` | Include ALSA & other logs |
|
| `-f`, `--fps` | FPS of the UI [default: 12] |
|
||||||
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
|
||||||
| `-t`, `--tracklist <TRACKLIST>` | Use a [custom track list](#custom-track-lists) |
|
| `-d`, `--debug` | Include ALSA & other logs |
|
||||||
|
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
||||||
|
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
|
||||||
|
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
|
||||||
|
|
||||||
### Scraping
|
If you need something even more specific, see [ENVIRONMENT_VARS](./docs/ENVIRONMENT_VARS.md).
|
||||||
|
|
||||||
lowfi also has a `scrape` command which is usually not relevant, but
|
### Extra Features
|
||||||
if you're trying to download some files from Lofi Girls' website,
|
|
||||||
it can be useful.
|
|
||||||
|
|
||||||
An example of scrape is as follows,
|
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,
|
||||||
|
like those which are only expected to be used by a handful of users.
|
||||||
|
|
||||||
`lowfi scrape --extension zip --include-full`
|
#### `scrape` - Scraping
|
||||||
|
|
||||||
where more information can be found by running `lowfi help scrape`.
|
This feature provides the `scrape` command.
|
||||||
|
It's usually not very useful, but is included for transparency's sake.
|
||||||
|
|
||||||
|
More information can be found by running `lowfi help scrape`.
|
||||||
|
|
||||||
|
#### `mpris` - MPRIS
|
||||||
|
|
||||||
|
Enables MPRIS. It's not rocket science.
|
||||||
|
|
||||||
|
#### `extra-audio-formats` - Extra Audio Formats
|
||||||
|
|
||||||
|
This is only relevant to those using a custom track list, in which case
|
||||||
|
it allows for more formats than just MP3. Those are FLAC, Vorbis, and WAV.
|
||||||
|
|
||||||
|
These should be sufficient for some 99% of music files people might want to play.
|
||||||
|
If you are dealing with the 1% using another audio format which is in
|
||||||
|
[this list](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#codecs-decoders), open an issue.
|
||||||
|
|
||||||
### Custom Track Lists
|
### Custom Track Lists
|
||||||
|
|
||||||
> [!WARNING]
|
> [!NOTE]
|
||||||
|
> Some nice users, especially [danielwerg](https://github.com/danielwerg),
|
||||||
|
> have already made alternative track lists located in the [data](https://github.com/talwat/lowfi/blob/main/data/)
|
||||||
|
> directory of this repo. You can use them with lowfi by using the `--track-list` flag.
|
||||||
>
|
>
|
||||||
> Custom track lists are going to be pretty particular.
|
> Feel free to contribute your own list with a PR.
|
||||||
> This is because I still want to keep `lowfi` as simple as possible,
|
|
||||||
> so custom lists will be very similar to how the built in list functions.
|
|
||||||
>
|
|
||||||
> This also means that there will be no added flexibility to these lists,
|
|
||||||
> so you'll have to work that out on your own.
|
|
||||||
|
|
||||||
lowfi also supports custom track lists, although the default one from Lofi Girl
|
lowfi also supports custom track lists, although the default one from chillhop
|
||||||
is embedded into the binary.
|
is embedded into the binary.
|
||||||
|
|
||||||
To use a custom list, use the `--tracks` flag. This can either be a path to some file,
|
To use a custom list, use the `--track-list` flag. This can either be a path to some file,
|
||||||
or it could also be the name of a file (without the `.txt` extension) in the data
|
or it could also be the name of a file (without the `.txt` extension) in the data
|
||||||
directory, so on Linux it's `~/.local/share/lowfi`.
|
directory.
|
||||||
|
|
||||||
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
> [!NOTE]
|
||||||
Whereas if you did `lowfi --tracks ~/Music/minipop.txt` it would load from that
|
> Data directories:
|
||||||
|
>
|
||||||
|
> - Linux - `~/.local/share/lowfi`
|
||||||
|
> - macOS - `~/Library/Application Support/lowfi`
|
||||||
|
> - Windows - `%appdata%\Roaming\lowfi`
|
||||||
|
|
||||||
|
For example, `lowfi --track-list minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||||
|
Whereas if you did `lowfi --track-list ~/Music/minipop.txt` it would load from that
|
||||||
specified directory.
|
specified directory.
|
||||||
|
|
||||||
|
All tracks must be in the MP3 format, unless lowfi has been compiled with the
|
||||||
|
`extra-audio-formats` feature which includes support for some others.
|
||||||
|
|
||||||
#### The Format
|
#### The Format
|
||||||
|
|
||||||
In lists, the first line should be the base URL, followed by the rest of the tracks.
|
In lists, the first line is what's known as the header, followed by the rest of the tracks.
|
||||||
This is also known as the "header", because it comes first.
|
Each track will be first appended to the header, and then use the combination to download
|
||||||
|
the track.
|
||||||
|
|
||||||
Each track will be first appended to the base URL, and then the result use to download
|
> [!NOTE]
|
||||||
the track. All tracks must be in the MP3 format, as lowfi doesn't support any others currently.
|
> lowfi *will not* put a `/` between the base & track for added flexibility,
|
||||||
|
> so for most cases you should have a trailing `/` in your header.
|
||||||
|
|
||||||
Additionally, lowfi _won't_ put a `/` between the base & track for added flexibility,
|
The exception to this is if the track name begins with a protocol like `https://`,
|
||||||
so for most cases you should have a trailing `/` in your base url.
|
in which case the base will not be prepended to it. If all of your tracks are like this,
|
||||||
The exception to this is if the track name begins with something like `https://`,
|
then you can put `noheader` as the first line and not have a header at all.
|
||||||
where in that case the base will not be prepended to it.
|
|
||||||
|
|
||||||
For example, in this list:
|
For example, in this list:
|
||||||
|
|
||||||
@ -187,13 +239,13 @@ For example, in this list:
|
|||||||
https://lofigirl.com/wp-content/uploads/
|
https://lofigirl.com/wp-content/uploads/
|
||||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
2023/04/2-In-Front-Of-Me.mp3
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3
|
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
|
||||||
```
|
```
|
||||||
|
|
||||||
lowfi would download these three URLs:
|
lowfi would download these three URLs:
|
||||||
|
|
||||||
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
|
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
|
||||||
- `https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3`
|
- `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
|
||||||
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
|
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
|
||||||
|
|
||||||
Additionally, you may also specify a custom display name for the track which is indicated by a `!`.
|
Additionally, you may also specify a custom display name for the track which is indicated by a `!`.
|
||||||
@ -204,3 +256,14 @@ For example, if you had an entry like this:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then lowfi would download from the first section, and display the second as the track name.
|
Then lowfi would download from the first section, and display the second as the track name.
|
||||||
|
|
||||||
|
`file://` can be used in front a track/header to make lowfi treat it as a local file.
|
||||||
|
This is useful if you want to use a local file as the base URL, for example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
file:///home/user/Music/
|
||||||
|
file.mp3
|
||||||
|
file:///home/user/Other Music/second-file.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
||||||
|
|||||||
2459
data/archive.txt
Normal file
2459
data/archive.txt
Normal file
File diff suppressed because it is too large
Load Diff
2488
data/chillhop.txt
2488
data/chillhop.txt
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,2 @@
|
|||||||
file:///home/user/Music/
|
file:///home/user/Music/
|
||||||
Anomaly.mp3
|
Test.mp3
|
||||||
@ -1,16 +0,0 @@
|
|||||||
https://archive.org/download/jack-stauber-s-micropop-extended-micropops/Jack%20Stauber-%27s%20Micropop%20-%20
|
|
||||||
Al%20Dente.mp3
|
|
||||||
Baby%20Hotline.mp3
|
|
||||||
Cupid.mp3
|
|
||||||
Deploy.mp3
|
|
||||||
Dinner%20Is%20Not%20Over.mp3
|
|
||||||
Fighter.mp3
|
|
||||||
Inchman.mp3
|
|
||||||
Keyman.mp3
|
|
||||||
Out%20the%20Ox.mp3
|
|
||||||
Tea%20Errors.mp3
|
|
||||||
The%20Ballad%20of%20Hamantha.mp3
|
|
||||||
There%27s%20Something%20Happening.mp3
|
|
||||||
Those%20Eggs%20Aren%27t%20Dippy%20.mp3
|
|
||||||
Today%20Today.mp3
|
|
||||||
Two%20Time.mp3
|
|
||||||
2
data/noheader.txt
Normal file
2
data/noheader.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
noheader
|
||||||
|
https://stream.chillhop.com/mp3/9476
|
||||||
115
data/old/synthboy.txt
Normal file
115
data/old/synthboy.txt
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
https://lofigirl.com/wp-content/uploads/
|
||||||
|
2024/01/1.-i_m-alone-out-here-ft.-Outgrown-master.mp3
|
||||||
|
2024/01/2.-aurora-ft.-Outgrown-master.mp3
|
||||||
|
2024/01/3.-dusk-master.mp3
|
||||||
|
2024/01/4.-aria-ft.-after-noon-master.mp3
|
||||||
|
2024/01/5.-dreamscape-ft.-Luke-Tidbury-master.mp3
|
||||||
|
2023/11/Le-Metroid-Crystal-Children.mp3
|
||||||
|
2023/11/Le-Metroid-Frequencies.mp3
|
||||||
|
2023/11/Le-Metroid-Space-Echoes.mp3
|
||||||
|
2023/11/Le-Metroid-Voyager.mp3
|
||||||
|
2023/11/Le-Metroid-Orion.mp3
|
||||||
|
2023/11/Le-Metroid-Saturne.mp3
|
||||||
|
2023/11/Le-Metroid-Speed-Light.wav
|
||||||
|
2023/11/Le-Metroid-Earth-21.wav
|
||||||
|
2023/11/Le-Metroid-Sleepwalker.mp3
|
||||||
|
2023/11/Le-Metroid-Blackhole.mp3
|
||||||
|
2023/09/01-Akraa-Lightyears-Kupla-master.mp3
|
||||||
|
2023/09/02-Virtua-_-Robert-Iver-Analogue-Pulsar-Kupla-master.mp3
|
||||||
|
2023/09/03-Starwave-Soundgo-Kupla-Master.mp3
|
||||||
|
2023/09/04-Forhill-_-Eagle-Eyed-Tiger-Infuse-Kupla-Master.mp3
|
||||||
|
2023/09/05-VIQ-x-Krosia-Echodrift-Kupla-Master.mp3
|
||||||
|
2023/09/06-Tbeauthetraveler-Soare-Kupla-master.mp3
|
||||||
|
2023/09/07-Boy-From-Nowhere-Bleue-Astrale-Kupla-Master-.mp3
|
||||||
|
2023/09/08-Virtua-Searching-Kupla-Master.mp3
|
||||||
|
2023/09/09-Electrosky-Soundgo-Kupla-Master.mp3
|
||||||
|
2023/09/10-Protocols-Orion-Kupla-Master.mp3
|
||||||
|
2023/09/11-Hotel_Pools_Satin_Kupla-Master.mp3
|
||||||
|
2023/09/12-Downtown-Binary-Icarus-Kupla-Master.mp3
|
||||||
|
2023/09/13-A.L.I.S.O.N-Erebus-Kupla-Master.mp3
|
||||||
|
2023/09/14-Girl-From-Nowhere-x-Boy-From-Nowhere-Diamond-Kupla-Master.mp3
|
||||||
|
2023/09/15-Kabes-x-Protocols-Static-Kupla-Master.mp3
|
||||||
|
2023/09/16-VIQ-Somewhere-Kupla-Master.mp3
|
||||||
|
2023/09/17-Akraa-Virtua-Portal-Kupla-Master-2.mp3
|
||||||
|
2023/09/18-Nitewalk-x-SwuM-Last-Cosmo-Kupla-Master.mp3
|
||||||
|
2023/09/19-Nitewalk-Prismatic-Waves-Kupla-Master3.mp3
|
||||||
|
2023/09/20-Theo-Aabel-Night-Owl-Kupla-Master.mp3
|
||||||
|
2023/06/Foudroie-Journey-2023.mp3
|
||||||
|
2023/06/Foudroie-Odyssey-MASTER.mp3
|
||||||
|
2023/06/Foudroie-Solar-Wind-MASTER.mp3
|
||||||
|
2023/06/Foudroie-x-Forhill-Interstellar-MASTER.mp3
|
||||||
|
2023/06/Foudroie-The-Traveler-2023.mp3
|
||||||
|
2023/06/Foudroie-Moon-Dust-2023.mp3
|
||||||
|
2023/06/Foudroie-Departure-2023.mp3
|
||||||
|
2023/06/Foudroie-Interpolation-2023.mp3
|
||||||
|
2023/06/Foudroie-Lunar-2023.mp3
|
||||||
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
|
2023/06/A1Descend.mp3
|
||||||
|
2023/06/A3S.O.L.O.mp3
|
||||||
|
2023/06/B4K.E.Y.S.mp3
|
||||||
|
2023/06/EmilRottmayerW.A.V.E.mp3
|
||||||
|
2023/06/EmilRottmayerMEGA.mp3
|
||||||
|
2023/06/1.-steezy-prime-better-days-w_-devon-rea-_-fnonose.mp3
|
||||||
|
2023/06/2.-steezy-prime-deep-thoughts-w_-beyond-pluto.mp3
|
||||||
|
2023/06/3.-steezy-prime-in-the-air.mp3
|
||||||
|
2023/06/4.-steezy-prime-gemini-w_-laffey.mp3
|
||||||
|
2023/06/5.-steezy-prime-it_s-getting-late-w_-mildred-_-devon-rea.mp3
|
||||||
|
2023/06/6.-steezy-prime-slow-blink-w_-fnonose.mp3
|
||||||
|
2023/05/Polaris.mp3
|
||||||
|
2023/05/Taking-Flight.mp3
|
||||||
|
2023/05/Astral.mp3
|
||||||
|
2023/05/Atlantis.mp3
|
||||||
|
2023/05/Cirrus.mp3
|
||||||
|
2023/05/Light-Cycles.mp3
|
||||||
|
2023/05/Umbra.mp3
|
||||||
|
2023/05/Shuto-Expressway.mp3
|
||||||
|
2023/05/Fantasia.mp3
|
||||||
|
2023/05/Winter.mp3
|
||||||
|
2023/05/Pandora.mp3
|
||||||
|
2023/05/4am.mp3
|
||||||
|
2023/05/Aurora.mp3
|
||||||
|
2023/05/Lost-Signal.mp3
|
||||||
|
2023/05/Reflect.mp3
|
||||||
|
2023/05/City-Lights.mp3
|
||||||
|
2023/05/Pools.mp3
|
||||||
|
2023/05/Void.mp3
|
||||||
|
2023/05/Distant.mp3
|
||||||
|
2023/05/1.-Voyage-Beyond.mp3
|
||||||
|
2023/05/2.-A.L.I.S.O.N-always-in-my-dreams.mp3
|
||||||
|
2023/05/3.Hotel-Pools-Limits.mp3
|
||||||
|
2023/05/4.-Downtown-Binary-Atlantis.mp3
|
||||||
|
2023/05/5.-Voyage-Moon-Phase.mp3
|
||||||
|
2023/05/6.Downtown-Binary-_-The-Present-Sound-Polaris.mp3
|
||||||
|
2023/05/7.-Hotel-Pools-_-A.L.I.S.O.N_Lunar.mp3
|
||||||
|
2023/05/8.Hotel-Pools-Snowfall.mp3
|
||||||
|
2023/05/9.-oDDling-Drifting.mp3
|
||||||
|
2023/05/10.-Hotel-Pools-_-oDDling-Remain.mp3
|
||||||
|
2023/05/11.-A.L.I.S.O.N-Subtract.mp3
|
||||||
|
2023/05/13.-oDDling-Void.mp3
|
||||||
|
2023/05/14.-EmilRottmayer-Elevate.mp3
|
||||||
|
2023/05/15.-Transparent-transparent.mp3
|
||||||
|
2023/05/16.-Neon-Galaxy-Portal.mp3
|
||||||
|
2023/05/17.-KING-PALM-Luxury.mp3
|
||||||
|
2023/05/18.-Departure-Imagine.mp3
|
||||||
|
2023/05/19.-Voyage-Center-Point.mp3
|
||||||
|
2023/05/20.-A.L.I.S.O.N-Nightride-Revisited.mp3
|
||||||
|
2023/05/21.-VIQ-Orbit.mp3
|
||||||
|
2023/05/22.-KING-PALM-Journey.mp3
|
||||||
|
2023/05/23.-Krosia-Crystal-Bells.mp3
|
||||||
|
2023/05/24.-EVANS-third-planet.mp3
|
||||||
|
2023/05/25.-Hotel-Pools-Evolve.mp3
|
||||||
|
2023/05/26.-KING-PALM-Coast.mp3
|
||||||
|
2023/05/27.-Monolism-infinitespace.mp3
|
||||||
|
2023/05/28.-oDDling-Divide.mp3
|
||||||
|
2023/05/29.-GRAEDA-Deluge.mp3
|
||||||
|
2023/05/30.-Xtract-Audiotool-Day-2016.mp3
|
||||||
|
2023/05/31.-MEGAS-Stargazer.mp3
|
||||||
|
2023/05/32.-Hotel-Pools-_-Forhill-Descent.mp3
|
||||||
|
2023/05/33.-Hotel-Pool-_-Memorex-Memories-Distance.mp3
|
||||||
|
2023/05/34.-GRAEDA-Barranca.mp3
|
||||||
|
2023/05/35.-Davz-Mindless.mp3
|
||||||
|
2023/05/36.-Unfound-Rise.mp3
|
||||||
|
2023/05/37.-oDDling-Reverie.mp3
|
||||||
|
2023/05/38.-Unfound-Heaven.mp3
|
||||||
|
2023/05/39.-Novus-Sana-Sound-of-the-Sky.mp3
|
||||||
|
2023/05/40.-Krosia-Sonar.mp3
|
||||||
@ -1,4 +1,4 @@
|
|||||||
https://lofigirl.com/wp-content/uploads/
|
https://lofigirl.com/wp-content/uploads/
|
||||||
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
2023/04/2-In-Front-Of-Me.mp3
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3
|
https://stream.chillhop.com/mp3/9476
|
||||||
9
docs/ENVIRONMENT_VARS.md
Normal file
9
docs/ENVIRONMENT_VARS.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
[[Version française](./fr/ENVIRONMENT_VARS.md)]
|
||||||
|
|
||||||
|
lowfi has some more specific options, usually as a result of minor feature requests, which are only documented here.
|
||||||
|
If you have some behavior you'd like to change, which is quite specific, then see if one of these options suits you.
|
||||||
|
|
||||||
|
* `LOWFI_FIXED_MPRIS_NAME` - Limits the number of lowfi instances to one, but ensures the player name is always `lowfi`.
|
||||||
|
* `LOWFI_DISABLE_UI` - Disables the UI. This requires MPRIS, so that you can still actually control lowfi.
|
||||||
77
docs/MUSIC.md
Normal file
77
docs/MUSIC.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# The State of lowfi's Music
|
||||||
|
|
||||||
|
[[Version française](./fr/MUSIQUE.md)]
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This document will be a bit long and has almost nothing to do with the actual
|
||||||
|
> usage of lowfi, just the music embedded by default.
|
||||||
|
|
||||||
|
Before that though, some context. lowfi includes an extensive track list
|
||||||
|
embedded into the software, so you can download it and have it "just work"
|
||||||
|
out of the box.
|
||||||
|
|
||||||
|
I always hated apps that required extensive configuration just to be usable.
|
||||||
|
Sometimes it's justified, but often, it's just pointless when most will end up
|
||||||
|
with the same set of "defaults" that aren't really defaults.
|
||||||
|
|
||||||
|
lowfi is so nice and simple because of the "plug and play" aspect,
|
||||||
|
but it's become a lot harder to continue it as of late.
|
||||||
|
|
||||||
|
## The Lofi Girl List
|
||||||
|
|
||||||
|
Originally, it was planned that lowfi would use music scraped from Lofi Girl's own
|
||||||
|
website. The scraper actually came before the rest of the program, believe it or not.
|
||||||
|
|
||||||
|
However, after a long period of downtime, the Lofi Girl website was redone without the
|
||||||
|
mp3 track files. Those are now pretty much inaccessible aside from paying for individual
|
||||||
|
albums on bandcamp which gets very expensive very quickly.
|
||||||
|
|
||||||
|
Doing this was never actually disallowed, but it is now simply impossible. So, the question was,
|
||||||
|
what to do next after losing lowfi's primary source of music?
|
||||||
|
|
||||||
|
## Tracklists
|
||||||
|
|
||||||
|
I was originally against the idea of custom tracklists, because of my almost purist
|
||||||
|
ideals of a 100% no config at all vision for lowfi. But eventually, I gave in, which proved
|
||||||
|
to be a very good decision in hindsight. Now, regardless of what choices I make on the music
|
||||||
|
which is embedded, all may opt out of that and choose whatever they like.
|
||||||
|
|
||||||
|
This culminated in a few templates located in the `data` directory of this repository
|
||||||
|
which included a handful of tracklists, and in particular, the chillhop list by user
|
||||||
|
[danielwerg](https://github.com/danielwerg).
|
||||||
|
|
||||||
|
## The Switch
|
||||||
|
|
||||||
|
After `lofigirl.com` went down, I thought a bit and eventually decided
|
||||||
|
to just bite the bullet and switch to the chillhop list. This was despite the fact
|
||||||
|
that chillhop entirely bans third party players in their TOS. They also ban
|
||||||
|
scrapers, which I only learned after writing one.
|
||||||
|
|
||||||
|
So, is lowfi really going to have to violate the TOS of it's own music provider?
|
||||||
|
Well, yes. I thought about it, and came to the conclusion that lowfi is probably
|
||||||
|
not much of a threat for a few reasons.
|
||||||
|
|
||||||
|
Firstly, it emulates exactly the behavior of chillhop's own radio player.
|
||||||
|
The only difference is that one shoves you into a web browser, and the other,
|
||||||
|
into a nice terminal window.
|
||||||
|
|
||||||
|
Then, I also realize that lowfi is just a small program used by few.
|
||||||
|
I'm not making money on any of this, and I think degrading the experience for my
|
||||||
|
fellow nerds who just want to listen to some lowfi without all the crap is not worth it.
|
||||||
|
|
||||||
|
At the end of the day, lowfi has a distinct UserAgent. Should chillhop ever take issue with
|
||||||
|
it's behavior, banning it is extremely simple. I don't want that to happen, but I
|
||||||
|
understand if it does.
|
||||||
|
|
||||||
|
## Well, *I* Hate the Chillhop Music
|
||||||
|
|
||||||
|
It's not as "lofi". It is almost certainly a compromise, that much I cannot even pretend to
|
||||||
|
deny. I find myself hitting the skip button almost three times as often with chillhop.
|
||||||
|
|
||||||
|
If you are undeterred enough by TOS's to read this far, then you can use the `archive.txt`
|
||||||
|
list in the `data` folder. The list is a product of me worrying that the tracks on `lofigirl.com`
|
||||||
|
could've possibly been lost somehow, relating to the website going down.
|
||||||
|
|
||||||
|
It's hosted on `archive.org`, and could be taken down at any point for any reason.
|
||||||
|
Being derived from my own local archive, it retains ~2700 out of the ~3700 tracks.
|
||||||
|
That's not perfect, the organization is also *bad*, but it exists.
|
||||||
23
docs/fr/CONTRIBUER.md
Normal file
23
docs/fr/CONTRIBUER.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Contribuer à lowfi
|
||||||
|
|
||||||
|
Il y a quelque directives listées ici qui vont augmenter les chances pour votre PR d'être acceptée.
|
||||||
|
Seules les moins évidentes seront listées, si vous avez besoin de demander la réponse est probablement non.
|
||||||
|
|
||||||
|
## 1. IA
|
||||||
|
|
||||||
|
Vous pouvez utiliser l'IA pour chercher, ou s'il y a quelque chose de mineur et fastidieux(eg. des tests) que vous préférez éviter de faire manuellement.
|
||||||
|
|
||||||
|
Cela dit, si l'usage d'IA est visible, c'est déjà trop.
|
||||||
|
Les PR générées par IA n'aident pas les développeurs, elles sont juste embêtantes et leur font perdre leur temps.
|
||||||
|
|
||||||
|
## 2. Petit = mieux
|
||||||
|
|
||||||
|
Faites en sorte que chaque PR ne contienne qu'une fonctionnalité distincte. Ajouter plusieurs fonctionnalités dans une seule PR est généralement une mauvaise idée.
|
||||||
|
Cela permet aussi que des fonctionnalités spécifiques soit approuvées ou refusées au cas par cas, plutot qu'un seul bloc de code important.
|
||||||
|
|
||||||
|
## 3. Keep lowfi simple
|
||||||
|
|
||||||
|
lowfi est censé être un programme simple. Pour l'instant aucune modification de l'interface initiale ne sera acceptée.
|
||||||
|
L'interface de lowfi pendant la lecture est restée la même depuis les premières versions, la compliquer irait à l'encontre de son but initial.
|
||||||
|
|
||||||
|
Des fonctionnalités plus complexes, comme des couleurs fantaisistes ou des pochettes d'albums, ne seront jamais acceptées. L'implémentation de fonctionnalitées acceptables doit être simple et discrète, si une fonctionnalité est simple mais que sa mise en oeuvre est très complexe elle ne sera pas acceptée.
|
||||||
7
docs/fr/ENVIRONMENT_VARS.md
Normal file
7
docs/fr/ENVIRONMENT_VARS.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Variables d'Environment
|
||||||
|
|
||||||
|
lowfi a quelques options précises, généralement dûe à des demandes de fonctionnalité mineures, qui sont uniquement documentées ici.
|
||||||
|
S'il y a quelque chose de spécifique que vous souhaitez changer, voyez si l'une des ces options vous va.
|
||||||
|
|
||||||
|
* `LOWFI_FIXED_MPRIS_NAME` - Limite le nombre d'instances de lowfi à 1, mais force le nom du lecteur à toujours être `lowfi`.
|
||||||
|
* `LOWFI_DISABLE_UI` - Désactive l'interface utilisateur.
|
||||||
48
docs/fr/MUSIQUE.md
Normal file
48
docs/fr/MUSIQUE.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# La Musique de lowfi
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Ce document sera un peu long et n'aura presque rien à voir avec l'utilisation pratique de lowfi, juste avec la musique intégrée par défaut.
|
||||||
|
|
||||||
|
Mais avant cela, un peu de contexte. lowfi comprend une longue liste de musiques intégrés au programme, vous pouvez donc l'installer et l'utiliser directement.
|
||||||
|
|
||||||
|
J'ai toujours détesté les applications qui nécessitent une configuration complexe juste pour pouvoir les utiliser. Occasionnellement, cela se justifie, mais souvent, cela n'a aucun sens, car la plupart des utilisateurs finissent par utiliser les mêmes paramètres, qui ne sont même pas vraiment les mêmes.
|
||||||
|
|
||||||
|
lowfi est super et simple grâce à son aspect « plug and play », mais c'est devenu beaucoup plus difficile de continuer comme ça les derniers temps.
|
||||||
|
|
||||||
|
## La Liste Lofi Girl
|
||||||
|
|
||||||
|
À l'origine, il était prévu que lowfi utilise la musique récupérée sur le site web de Lofi Girl. Croyez-le ou non, le scraper est en fait apparu avant le reste du programme.
|
||||||
|
|
||||||
|
Cependant, après une longue période d'indisponibilité, le site web de Lofi Girl a été refait sans les fichiers mp3. Ceux-ci sont désormais pratiquement inaccessibles, sauf en achetant chaque album individuellement sur Bandcamp, ce qui revient très vite très cher.
|
||||||
|
|
||||||
|
*Scraper* n'a jamais été interdit, mais c'est désormais tout simplement impossible. La question était donc : que faire après avoir perdu la principale source de musique de lowfi ?
|
||||||
|
|
||||||
|
## Listes de Morceaux
|
||||||
|
|
||||||
|
Au départ, j'étais contre l'idée de créer des listes de morceaux personnalisées, car j'avais une vision presque puriste d'un lowfi 100 % sans configuration. J'ai cependant finis par céder, ce qui s'est avéré être une très bonne décision. Maintenant peu importe le choix que je fais par rapport à la musique qui est intégrée, tout le monde peut ne pas utiliser celle-ci et choisir ce qu'il veut.
|
||||||
|
|
||||||
|
Cela a aboutit à quelque *templates*, stockés dans le dossier [data](../../data), et en particulier la liste chillhop par [danielwerg](https://github.com/danielwerg).
|
||||||
|
|
||||||
|
## Le Changement
|
||||||
|
|
||||||
|
Après que `lofigirl.com` deviennent inaccessible, j'ai réfléchi un coup puis ai finalement décidé de serrer les dents et passer à la liste chillhop. Et ce malgré que chillhop bannis tous les lecteurs tiers dans leur CGU. Ils interdisent aussi les *scrappers*, ce que j'ai appris seulement après en avoir écrit un.
|
||||||
|
|
||||||
|
Bon, est-ce que lowfi va vraiment devoir violer les CGU de son fournisseur de musique ?
|
||||||
|
Eh bien oui. J'y ai réfléchi et je suis arrivé à la conclusion que lowfi n'est probablement pas une grande menace pour plusieurs raisons.
|
||||||
|
|
||||||
|
Premièrement, il émule exactement le lecteur "radio" de chillhop. La seule différence étant que l'un force l'utilisation d'un navigateur web, et l'autre celle d'une beau terminal.
|
||||||
|
|
||||||
|
Ensuite j'ai réalisé que lowfi est juste un petit programme utilisé par peu.
|
||||||
|
Je ne gagne pas d'argent avec, et je pense que dégrader l'expérience de mes cher nerds qui veulent juste écouter de la lofi sans toute la merde ne vaut pas le coup.
|
||||||
|
|
||||||
|
Au final, lowfi a un `UserAgent` unique, si chillhop a un jour un problème avec, le bannir est extrêmement simple. Je ne souhaite pas que cela arrive, mais je comprendrais.
|
||||||
|
|
||||||
|
## Well, *je* Deteste la Musique Chillhop
|
||||||
|
|
||||||
|
Ce n'est pas aussi « lofi ». C'est presque un compromis, ça je ne peux même pas prétendre le nier. J'utilise le bouton « skip » presque trois fois plus souvent avec chillhop.
|
||||||
|
|
||||||
|
Si vous n'êtes pas assez découragé par les CGU pour avoir lu jusqu'ici, vous pouvez utiliser la liste [archive.txt](../../data/archive.txt) dans le dossier [data](../../data). Cette liste est le fruit de mon inquiétude quant à la possibilité que les morceaux sur `lofigirl.com` aient pu être perdus d'une manière ou d'une autre, en raison de la fermeture du site web.
|
||||||
|
|
||||||
|
Elle est hébergée sur `archive.org` et pourrait être supprimée à tout moment pour n'importe quelle raison.
|
||||||
|
Provenant de mes propres archives locales, elle contient environ 2 700 des 3 700 morceaux.
|
||||||
|
Elle n'est pas parfaite, son organisation est également *mauvaise*, mais elle existe.
|
||||||
243
docs/fr/README.md
Normal file
243
docs/fr/README.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# lowfi
|
||||||
|
|
||||||
|
lowfi est une petite application écrite en Rust qui sert un objectif unique : écouter de la lofi.
|
||||||
|
Elle le fait de la manière la plus simple possible : pas d’albums, pas de pubs, juste de la lofi.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Attention
|
||||||
|
|
||||||
|
À partir de la version 1.7.0 de lowfi, **tous** les fichiers audio intégrés par défaut proviennent de [chillhop](https://chillhop.com/).
|
||||||
|
Consultez [MUSIQUE](./MUSIQUE.md) pour plus d’informations.
|
||||||
|
|
||||||
|
## Pourquoi ?
|
||||||
|
|
||||||
|
Je déteste les plateformes de musique modernes, et je voulais une application, petite et simple, qui mettrait simplement de la lofi aléatoire, sans vidéo ni autres fioritures.
|
||||||
|
|
||||||
|
Au-delà de ça, elle a aussi été conçue pour être assez résistante aux connections instables, et *cache* 5 morceaux entiers à la fois.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Si vous êtes intéressé par la maintenance d’un paquet pour `lowfi` sur des gestionnaires de paquets comme Homebrew ou autres, ouvrez une issue.
|
||||||
|
|
||||||
|
### Dépendances
|
||||||
|
|
||||||
|
Sur toutes les plateformes : Rust 1.83.0+.
|
||||||
|
|
||||||
|
Sur macOS et Windows, aucune dépendance supplémentaire n’est nécessaire.
|
||||||
|
|
||||||
|
Sur Linux, vous aurez aussi besoin d’openssl et d’alsa.
|
||||||
|
|
||||||
|
* `alsa-lib` sur Arch, `libasound2-dev` sur Ubuntu, `alsa-lib-devel` sur Fedora.
|
||||||
|
* `openssl` sur Arch, `libssl-dev` sur Ubuntu, `openssl-devel` sur Fedora.
|
||||||
|
|
||||||
|
Si vous utilisez PulseAudio vous aurez aussi besoin d’installer `pulseaudio-alsa`.
|
||||||
|
|
||||||
|
### Cargo
|
||||||
|
|
||||||
|
La méthode d’installation recommandée est cargo :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install lowfi
|
||||||
|
|
||||||
|
# Si vous voulez utiliser le protocole MPRIS.
|
||||||
|
cargo install lowfi --features mpris
|
||||||
|
```
|
||||||
|
|
||||||
|
Assurez-vous que `$HOME/.cargo/bin` est ajouté à votre `$PATH`.
|
||||||
|
Voir également [Fonctionnalités supplémentaires](#fonctionnalités-supplémentaires) pour des fonctionnalités étendues.
|
||||||
|
|
||||||
|
### Packets précompilés
|
||||||
|
|
||||||
|
Si vous rencontrez des difficultés ou ne souhaitez pas utiliser cargo, vous pouvez simplement télécharger les exécutables précompilés depuis la [dernière release](https://github.com/talwat/lowfi/releases/latest).
|
||||||
|
|
||||||
|
### AUR
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yay -S lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE
|
||||||
|
|
||||||
|
```sh
|
||||||
|
zypper install lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Ce packet est sur un dépôt non officiel maintenu par [Dario Griffo](https://github.com/dariogriffo).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||||
|
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||||
|
sudo apt install -y lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora (COPR)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Ce packet utilise un dépôt COPR non officiel par [FurqanHun](https://github.com/FurqanHun).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo dnf copr enable furqanhun/lowfi
|
||||||
|
sudo dnf install lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuel
|
||||||
|
|
||||||
|
Utile pour le débogage.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/talwat/lowfi
|
||||||
|
cd lowfi
|
||||||
|
|
||||||
|
# Si vous voulez un exécutable
|
||||||
|
cargo build --release --all-features
|
||||||
|
./target/release/lowfi
|
||||||
|
|
||||||
|
# Si vous voulez juste tester
|
||||||
|
cargo run --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
`lowfi`
|
||||||
|
|
||||||
|
Oui, c’est tout.
|
||||||
|
|
||||||
|
### Contrôles
|
||||||
|
|
||||||
|
| Touche | Fonction |
|
||||||
|
| ------------------ | ------------------- |
|
||||||
|
| `s`, `n`, `l` | Passer le morceau |
|
||||||
|
| `p`, Espace | Lecture / Pause |
|
||||||
|
| `+`, `=`, `k`, `↑` | Volume +10 % |
|
||||||
|
| `→` | Volume +1 % |
|
||||||
|
| `-`, `_`, `j`, `↓` | Volume -10 % |
|
||||||
|
| `←` | Volume -1 % |
|
||||||
|
| `q`, CTRL+C | Quitter |
|
||||||
|
| `b` | Ajouter aux Favoris |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> En plus de ces contrôles habituels, lowfi est compatible avec les touches multimédia de votre machine ainsi qu'avec le standard [MPRIS](https://wiki.archlinux.org/title/MPRIS) (avec des outils comme `playerctl`).
|
||||||
|
>
|
||||||
|
> MPRIS est actuellement une [fonctionnalité optionnelle](#fonctionnalités-supplémentaires) dans Cargo (activée avec `--features mpris`) car elle est uniquement destinée à Linux, et parce que le but principal de lowfi est son interface unique et minimaliste.
|
||||||
|
|
||||||
|
### Favoris
|
||||||
|
|
||||||
|
Les favoris sont la réponse extrêmement simple de lowfi à la question « et si je voulais garder un morceau ? ».
|
||||||
|
Vous pouvez ajouter ou retirer des morceaux des favoris avec `b`, et les lire avec `lowfi -t bookmarks`.
|
||||||
|
|
||||||
|
D’un point de vue technique, vos favoris ne sont pas différents de n’importe quelle autre liste de morceaux, et sont donc stockés dans le même répertoire.
|
||||||
|
|
||||||
|
### Options supplémentaires
|
||||||
|
|
||||||
|
Si vous avez quelque chose que vous souhaitez ajuster dans lowfi, vous pouvez utiliser des options supplémentaires qui modifient légèrement l’interface ou le comportement du menu.
|
||||||
|
Les options peuvent être consultées avec `lowfi --help`.
|
||||||
|
|
||||||
|
| Option | Fonction |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `-a`, `--alternate` | Utiliser un écran de terminal alternatif |
|
||||||
|
| `-m`, `--minimalist` | Masquer la barre de contrôle inférieure |
|
||||||
|
| `-b`, `--borderless` | Exclure les bordures de l’interface |
|
||||||
|
| `-p`, `--paused` | Lancer lowfi en pause, |
|
||||||
|
| `-f`, `--fps` | FPS de l’interface [défaut : 12] |
|
||||||
|
| `--timeout` | Délai d’attente en secondes pour les téléchargements |
|
||||||
|
| `-d`, `--debug` | Inclure les logs ALSA et autres |
|
||||||
|
| `-w`, `--width <WIDTH>` | Largeur du lecteur, de 0 à 32 [défaut : 3] |
|
||||||
|
| `-t`, `--track-list <TRACK_LIST>` | Utiliser une [liste de pistes personnalisée](#listes-de-pistes-personnalisées) |
|
||||||
|
| `-s`, `--buffer-size <BUFFER_SIZE>` | Nombre de morceaux ajoutés au cache en avance [défaut : 5] |
|
||||||
|
|
||||||
|
### Fonctionnalités supplémentaires
|
||||||
|
|
||||||
|
lowfi utilise le système de « features » de cargo/rust pour rendre certaines parties du programme optionnelles, notamment celles qui ne sont censées être utilisées que par une minorité d’utilisateurs.
|
||||||
|
|
||||||
|
#### `scrape` - Scraping
|
||||||
|
|
||||||
|
Cette fonctionnalité fournit la commande `scrape`.
|
||||||
|
Elle n’est généralement pas très utile, mais est incluse par souci de transparence.
|
||||||
|
|
||||||
|
Plus d’informations sont disponibles en exécutant `lowfi help scrape`.
|
||||||
|
|
||||||
|
#### `mpris` - MPRIS
|
||||||
|
|
||||||
|
Active MPRIS.
|
||||||
|
|
||||||
|
#### `extra-audio-formats` - Formats audio supplémentaires
|
||||||
|
|
||||||
|
Ceci est uniquement pertinent pour les utilisateurs de listes de pistes personnalisées ; dans ce cas, cela permet plus de formats que le simple MP3, à savoir FLAC, Vorbis et WAV.
|
||||||
|
|
||||||
|
Ces formats devraient couvrir environ 99 % des fichiers audio que les gens souhaitent lire. Si vous faites partie du 1 % utilisant un autre format audio, et présent dans [cette liste](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#codecs-decoders), ouvrez une issue.
|
||||||
|
|
||||||
|
### Listes de pistes personnalisées
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Certains gentils utilisateurs, en particulier [danielwerg](https://github.com/danielwerg), ont déjà créé des listes alternatives situées dans le dossier [data](https://github.com/talwat/lowfi/blob/main/data/) de ce dépôt. Vous pouvez les utiliser avec lowfi en utilisant l’option `--track-list`.
|
||||||
|
>
|
||||||
|
> N’hésitez pas à proposer votre propre liste via une pull request.
|
||||||
|
|
||||||
|
lowfi prend également en charge les listes de pistes personnalisées, bien que celle par défaut de chillhop soit intégrée directement dans l'exécutable.
|
||||||
|
|
||||||
|
Pour utiliser une liste personnalisée, utilisez l’option `--track-list`. Cela peut être soit un chemin vers un fichier, soit le nom d’un fichier (sans l’extension `.txt`) présent dans le dossier données.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Répertoires de données par plateforme :
|
||||||
|
>
|
||||||
|
> * Linux - `~/.local/share/lowfi`
|
||||||
|
> * macOS - `~/Library/Application Support/lowfi`
|
||||||
|
> * Windows - `%appdata%\Roaming\lowfi`
|
||||||
|
|
||||||
|
Par exemple, `lowfi --track-list minipop` chargera `~/.local/share/lowfi/minipop.txt`.
|
||||||
|
Tandis que `lowfi --track-list ~/Music/minipop.txt` chargera depuis le répertoire spécifié.
|
||||||
|
|
||||||
|
Tous les morceaux doivent être au format MP3, sauf si lowfi a été compilé avec la fonctionnalité `extra-audio-formats`, qui ajoute la prise en charge de certains autres formats.
|
||||||
|
|
||||||
|
#### Le format
|
||||||
|
|
||||||
|
Dans les listes, la première ligne est appelée l’en-tête, suivie du reste des pistes.
|
||||||
|
Chaque piste sera d’abord concaténée à l’en-tête, puis l’ensemble sera utilisé pour télécharger le morceau.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> lowfi *n’ajoutera pas* de `/` entre la base et la piste pour plus de flexibilité ;
|
||||||
|
> dans la plupart des cas, vous devriez donc avoir un `/` final dans votre en-tête.
|
||||||
|
|
||||||
|
L’exception à cette règle est lorsque le nom de la piste commence par un protocole tel que `https://`, auquel cas la base ne sera pas préfixée. Si toutes vos pistes sont de ce type, vous pouvez mettre `noheader` comme première ligne et ne pas avoir d’en-tête du tout.
|
||||||
|
|
||||||
|
Par exemple, dans cette liste :
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://lofigirl.com/wp-content/uploads/
|
||||||
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
|
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
lowfi téléchargerait ces trois URL :
|
||||||
|
|
||||||
|
* `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
|
||||||
|
* `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
|
||||||
|
* `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
|
||||||
|
|
||||||
|
De plus, vous pouvez choisir un nom d’affichage personnalisé pour une piste,
|
||||||
|
indiqué par un `!`. Par exemple, avec une entrée comme celle-ci :
|
||||||
|
|
||||||
|
```txt
|
||||||
|
2023/04/2-In-Front-Of-Me.mp3!nom personnalisé
|
||||||
|
```
|
||||||
|
|
||||||
|
lowfi téléchargera depuis la première partie et affichera la seconde comme nom du morceau.
|
||||||
|
|
||||||
|
`file://` peut être utilisé devant une piste ou un en-tête pour que lowfi le traite comme un fichier local.
|
||||||
|
C’est utile si vous souhaitez utiliser un fichier local comme URL de base, par exemple :
|
||||||
|
|
||||||
|
```txt
|
||||||
|
file:///home/utilisateur/Musique/
|
||||||
|
fichier.mp3
|
||||||
|
file:///home/utilisateur/Musique 2/deuxieme-fichier.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
D’autres exemples sont disponibles dans le dossier
|
||||||
|
[data](https://github.com/talwat/lowfi/tree/main/data).
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
4
scripts/fix_cache.sh
Normal file
4
scripts/fix_cache.sh
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
grep -rlZ "429 Too Many Requests" . | xargs -0 rm -f
|
||||||
|
find . -type f -empty -delete
|
||||||
55
src/audio.rs
Normal file
55
src/audio.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//! Some simple audio related utilities.
|
||||||
|
|
||||||
|
pub mod waiter;
|
||||||
|
|
||||||
|
/// This gets the output stream while also shutting up alsa with [libc].
|
||||||
|
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn silent_get_output_stream() -> crate::Result<rodio::OutputStream> {
|
||||||
|
use libc::freopen;
|
||||||
|
use rodio::OutputStreamBuilder;
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
// Get the file descriptor to stderr from libc.
|
||||||
|
extern "C" {
|
||||||
|
static stderr: *mut libc::FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
|
||||||
|
// output to `/dev/null` so that it wont be shoved down our throats.
|
||||||
|
|
||||||
|
// The mode which to redirect terminal output with.
|
||||||
|
let mode = CString::new("w")?;
|
||||||
|
|
||||||
|
// First redirect to /dev/null, which basically silences alsa.
|
||||||
|
let null = CString::new("/dev/null")?;
|
||||||
|
|
||||||
|
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
||||||
|
unsafe {
|
||||||
|
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the OutputStream while stderr is still redirected to /dev/null.
|
||||||
|
let stream = OutputStreamBuilder::open_default_stream()?;
|
||||||
|
|
||||||
|
// Redirect back to the current terminal, so that other output isn't silenced.
|
||||||
|
let tty = CString::new("/dev/tty")?;
|
||||||
|
|
||||||
|
// SAFETY: See the first call to `freopen`.
|
||||||
|
unsafe {
|
||||||
|
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an audio stream, doing so silently on Linux.
|
||||||
|
pub fn stream() -> crate::Result<rodio::OutputStream> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let mut stream = silent_get_output_stream()?;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let mut stream = rodio::OutputStreamBuilder::open_default_stream()?;
|
||||||
|
stream.log_on_drop(false);
|
||||||
|
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
53
src/audio/waiter.rs
Normal file
53
src/audio/waiter.rs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use rodio::Sink;
|
||||||
|
use tokio::{
|
||||||
|
sync::{mpsc, Notify},
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Background loop that waits for the sink to drain and then attempts
|
||||||
|
/// to send a `Message::Next` to the provided channel.
|
||||||
|
async fn waiter(
|
||||||
|
sink: Arc<Sink>,
|
||||||
|
tx: mpsc::Sender<crate::Message>,
|
||||||
|
notify: Arc<Notify>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
loop {
|
||||||
|
notify.notified().await;
|
||||||
|
|
||||||
|
while !sink.empty() {
|
||||||
|
time::sleep(Duration::from_millis(16)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx.try_send(crate::Message::Next).is_err() {
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight helper that waits for the current sink to drain and then
|
||||||
|
/// notifies the player to advance to the next track.
|
||||||
|
pub struct Handle {
|
||||||
|
/// Notification primitive used to wake the waiter.
|
||||||
|
notify: Arc<Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
/// Notify the waiter that playback state may have changed and it should
|
||||||
|
/// re-check the sink emptiness condition.
|
||||||
|
pub fn notify(&self) {
|
||||||
|
self.notify.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Tasks {
|
||||||
|
/// Create a new `Handle` which watches the provided `sink` and sends
|
||||||
|
/// `Message::Next` down `tx` when the sink becomes empty.
|
||||||
|
pub fn waiter(&mut self, sink: Arc<Sink>) -> Handle {
|
||||||
|
let notify = Arc::new(Notify::new());
|
||||||
|
self.spawn(waiter(sink, self.tx(), notify.clone()));
|
||||||
|
|
||||||
|
Handle { notify }
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/bookmark.rs
Normal file
89
src/bookmark.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
//! Bookmark persistence and helpers.
|
||||||
|
//!
|
||||||
|
//! Bookmarks are persisted to `bookmarks.txt` inside the application data
|
||||||
|
//! directory and follow the same track-list entry format (see `tracks::Info::to_entry`).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::{fs, io};
|
||||||
|
|
||||||
|
use crate::{data_dir, tracks};
|
||||||
|
|
||||||
|
/// Result alias for bookmark operations.
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Errors that might occur while managing bookmarks.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("data directory not found")]
|
||||||
|
Directory,
|
||||||
|
|
||||||
|
#[error("io failure")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the bookmarks in the current player.
|
||||||
|
pub struct Bookmarks {
|
||||||
|
/// The different entries in the bookmarks file.
|
||||||
|
pub(crate) entries: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bookmarks {
|
||||||
|
/// Returns the path to `bookmarks.txt`, creating the parent directory
|
||||||
|
/// if necessary.
|
||||||
|
pub async fn path() -> Result<PathBuf> {
|
||||||
|
let data_dir = data_dir().map_err(|_| Error::Directory)?;
|
||||||
|
fs::create_dir_all(data_dir.clone()).await?;
|
||||||
|
|
||||||
|
Ok(data_dir.join("bookmarks.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads bookmarks from disk. If no file exists an empty list is returned.
|
||||||
|
pub async fn load() -> Result<Self> {
|
||||||
|
let text = fs::read_to_string(Self::path().await?)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let entries: Vec<String> = text
|
||||||
|
.trim_start_matches("noheader")
|
||||||
|
.trim()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|x| {
|
||||||
|
if x.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(x.to_owned())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Self { entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves bookmarks to disk in `bookmarks.txt`.
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let text = format!("noheader\n{}", self.entries.join("\n"));
|
||||||
|
fs::write(Self::path().await?, text).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles bookmarking for `track` and returns whether it is now bookmarked.
|
||||||
|
///
|
||||||
|
/// If the track exists it is removed; otherwise it is appended to the list.
|
||||||
|
pub fn bookmark(&mut self, track: &tracks::Info) -> Result<bool> {
|
||||||
|
let entry = track.to_entry();
|
||||||
|
let idx = self.entries.iter().position(|x| **x == entry);
|
||||||
|
|
||||||
|
if let Some(idx) = idx {
|
||||||
|
self.entries.remove(idx);
|
||||||
|
} else {
|
||||||
|
self.entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(idx.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `track` is currently bookmarked.
|
||||||
|
pub fn bookmarked(&mut self, track: &tracks::Info) -> bool {
|
||||||
|
self.entries.contains(&track.to_entry())
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/downloader.rs
Normal file
174
src/downloader.rs
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
//! All of the logic and state relating to the downloader.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
sync::atomic::{self, AtomicBool, AtomicU8},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tracks;
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Flag indicating whether the downloader is actively fetching a track.
|
||||||
|
///
|
||||||
|
/// This is used internally to prevent concurrent downloader starts and to
|
||||||
|
/// indicate to the UI that a download is in progress.
|
||||||
|
static LOADING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Global download progress as an integer updated atomically.
|
||||||
|
///
|
||||||
|
/// This is just a [`AtomicU8`] from 0 to 255, really representing
|
||||||
|
/// a progress percentage as just a simple integer. For instance,
|
||||||
|
/// 0.5 would be represented here as 127.
|
||||||
|
static PROGRESS: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// A convenient wrapper for the global progress. This is updated by the downloader,
|
||||||
|
/// and then accessed by the UI to display progress when there isn't an available
|
||||||
|
/// queued track.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Progress(&'static AtomicU8);
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
/// Creates a new handle to the global progress.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(&PROGRESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the global progress.
|
||||||
|
///
|
||||||
|
/// `value` must be between 0 and 1.
|
||||||
|
pub fn set(&self, value: f32) {
|
||||||
|
self.0.store(
|
||||||
|
(value * f32::from(u8::MAX)).round() as u8,
|
||||||
|
atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global progress as a [`f32`] between 0 and 1.
|
||||||
|
pub fn get(&self) -> f32 {
|
||||||
|
f32::from(self.0.load(atomic::Ordering::Relaxed)) / f32::from(u8::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The downloader, which has all of the state necessary
|
||||||
|
/// to download tracks and add them to the queue.
|
||||||
|
pub struct Downloader {
|
||||||
|
/// The track queue itself, which in this case is actually
|
||||||
|
/// just an asynchronous sender.
|
||||||
|
///
|
||||||
|
/// It is a [`mpsc::Sender`] because the tracks will have to be
|
||||||
|
/// received by a completely different thread, so this avoids
|
||||||
|
/// the need to use an explicit [`tokio::sync::Mutex`].
|
||||||
|
queue: mpsc::Sender<tracks::Queued>,
|
||||||
|
|
||||||
|
/// The [`mpsc::Sender`] which is used to inform the
|
||||||
|
/// [`crate::Player`] with [`crate::Message::Loaded`].
|
||||||
|
tx: mpsc::Sender<crate::Message>,
|
||||||
|
|
||||||
|
/// The list of tracks to download from.
|
||||||
|
tracks: tracks::List,
|
||||||
|
|
||||||
|
/// The [`reqwest`] client to use for downloads.
|
||||||
|
client: Client,
|
||||||
|
|
||||||
|
/// The RNG generator to use.
|
||||||
|
rng: fastrand::Rng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
/// Actually runs the downloader, consuming it and beginning
|
||||||
|
/// the cycle of downloading tracks and reporting to the
|
||||||
|
/// rest of the program.
|
||||||
|
async fn run(mut self) -> crate::Result<()> {
|
||||||
|
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let result = self
|
||||||
|
.tracks
|
||||||
|
.random(&self.client, Progress::new(), &mut self.rng)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(track) => {
|
||||||
|
self.queue.send(track).await?;
|
||||||
|
if LOADING.load(atomic::Ordering::Relaxed) {
|
||||||
|
self.tx.send(crate::Message::Loaded).await?;
|
||||||
|
LOADING.store(false, atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
PROGRESS.store(0, atomic::Ordering::Relaxed);
|
||||||
|
if !error.timeout() {
|
||||||
|
tokio::time::sleep(ERROR_TIMEOUT).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloader handle, responsible for managing
|
||||||
|
/// the downloader task and internal buffer.
|
||||||
|
pub struct Handle {
|
||||||
|
/// The queue receiver, which can be used to actually
|
||||||
|
/// fetch a track from the queue.
|
||||||
|
queue: mpsc::Receiver<tracks::Queued>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The output when a track is requested from the downloader.
|
||||||
|
pub enum Output {
|
||||||
|
/// No track was immediately available from the downloader. When present,
|
||||||
|
/// the `Option<Progress>` provides a reference to the global download
|
||||||
|
/// progress so callers can show a loading indicator.
|
||||||
|
Loading(Option<Progress>),
|
||||||
|
|
||||||
|
/// A successfully downloaded (but not yet decoded) track ready to be
|
||||||
|
/// enqueued for decoding/playback.
|
||||||
|
Queued(tracks::Queued),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
/// Gets either a queued track, or a progress report,
|
||||||
|
/// depending on the state of the internal download buffer.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub fn track(&mut self) -> Output {
|
||||||
|
self.queue.try_recv().map_or_else(|_| {
|
||||||
|
LOADING.store(true, atomic::Ordering::Relaxed);
|
||||||
|
Output::Loading(Some(Progress::new()))
|
||||||
|
}, Output::Queued,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Tasks {
|
||||||
|
/// Initializes the downloader with a track list.
|
||||||
|
///
|
||||||
|
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
|
||||||
|
pub fn downloader(
|
||||||
|
&mut self,
|
||||||
|
size: usize,
|
||||||
|
timeout: u64,
|
||||||
|
tracks: tracks::List,
|
||||||
|
) -> crate::Result<Handle> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(concat!(
|
||||||
|
env!("CARGO_PKG_NAME"),
|
||||||
|
"/",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let (qtx, qrx) = mpsc::channel(size - 1);
|
||||||
|
let downloader = Downloader {
|
||||||
|
queue: qtx,
|
||||||
|
tx: self.tx(),
|
||||||
|
tracks,
|
||||||
|
client,
|
||||||
|
rng: fastrand::Rng::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.spawn(downloader.run());
|
||||||
|
Ok(Handle { queue: qrx })
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/error.rs
Normal file
61
src/error.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
//! Application-wide error type.
|
||||||
|
//!
|
||||||
|
//! This module exposes a single `Error` enum that aggregates the common
|
||||||
|
//! error kinds used across the application (IO, networking, UI, audio,
|
||||||
|
//! persistence). Higher-level functions should generally return
|
||||||
|
//! `crate::error::Result<T>` to make error handling consistent.
|
||||||
|
|
||||||
|
use crate::{bookmark, tracks, ui, volume};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
|
/// Result alias using the crate-wide `Error` type.
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Central application error.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("unable to load/save the persistent volume")]
|
||||||
|
PersistentVolume(#[from] volume::Error),
|
||||||
|
|
||||||
|
#[error("unable to load/save bookmarks")]
|
||||||
|
Bookmarks(#[from] bookmark::Error),
|
||||||
|
|
||||||
|
#[error("unable to fetch data")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("C string null error")]
|
||||||
|
FfiNull(#[from] std::ffi::NulError),
|
||||||
|
|
||||||
|
#[error("audio playing error")]
|
||||||
|
Rodio(#[from] rodio::StreamError),
|
||||||
|
|
||||||
|
#[error("couldn't send internal message")]
|
||||||
|
Send(#[from] mpsc::error::SendError<crate::Message>),
|
||||||
|
|
||||||
|
#[error("couldn't add track to the queue")]
|
||||||
|
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
|
||||||
|
|
||||||
|
#[error("couldn't update UI state")]
|
||||||
|
Broadcast(#[from] broadcast::error::SendError<ui::Update>),
|
||||||
|
|
||||||
|
#[error("io error")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("directory not found")]
|
||||||
|
Directory,
|
||||||
|
|
||||||
|
#[error("couldn't fetch track from downloader")]
|
||||||
|
Download,
|
||||||
|
|
||||||
|
#[error("couldn't parse integer")]
|
||||||
|
Parse(#[from] std::num::ParseIntError),
|
||||||
|
|
||||||
|
#[error("track failure")]
|
||||||
|
Track(#[from] tracks::Error),
|
||||||
|
|
||||||
|
#[error("ui failure")]
|
||||||
|
UI(#[from] ui::Error),
|
||||||
|
|
||||||
|
#[error("join error")]
|
||||||
|
JoinError(#[from] tokio::task::JoinError),
|
||||||
|
}
|
||||||
144
src/main.rs
144
src/main.rs
@ -1,24 +1,34 @@
|
|||||||
//! An extremely simple lofi player.
|
//! An extremely simple lofi player.
|
||||||
|
|
||||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
use crate::player::Player;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
mod play;
|
pub mod audio;
|
||||||
mod player;
|
pub mod bookmark;
|
||||||
mod tracks;
|
pub mod downloader;
|
||||||
|
pub mod error;
|
||||||
|
pub mod message;
|
||||||
|
pub mod player;
|
||||||
|
#[cfg(feature = "scrape")]
|
||||||
|
mod scrapers;
|
||||||
|
pub mod tasks;
|
||||||
|
mod tests;
|
||||||
|
pub mod tracks;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod volume;
|
||||||
|
|
||||||
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
#[cfg(feature = "scrape")]
|
||||||
mod scrape;
|
use crate::scrapers::Source;
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
pub use message::Message;
|
||||||
|
pub use tasks::Tasks;
|
||||||
|
|
||||||
/// An extremely simple lofi player.
|
/// An extremely simple lofi player.
|
||||||
#[derive(Parser)]
|
#[derive(Parser, Clone)]
|
||||||
#[command(about, version)]
|
#[command(about, version)]
|
||||||
#[allow(
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
clippy::struct_excessive_bools,
|
pub struct Args {
|
||||||
reason = "señor clippy, i assure you this is not a state machine"
|
|
||||||
)]
|
|
||||||
struct Args {
|
|
||||||
/// Use an alternate terminal screen.
|
/// Use an alternate terminal screen.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
alternate: bool,
|
alternate: bool,
|
||||||
@ -27,14 +37,26 @@ struct Args {
|
|||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
minimalist: bool,
|
minimalist: bool,
|
||||||
|
|
||||||
/// Exclude borders in UI.
|
/// Exclude window borders.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
borderless: bool,
|
borderless: bool,
|
||||||
|
|
||||||
|
/// Include a clock.
|
||||||
|
#[clap(long, short)]
|
||||||
|
clock: bool,
|
||||||
|
|
||||||
/// Start lowfi paused.
|
/// Start lowfi paused.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
paused: bool,
|
paused: bool,
|
||||||
|
|
||||||
|
/// FPS of the UI.
|
||||||
|
#[clap(long, short, default_value_t = 12)]
|
||||||
|
fps: u8,
|
||||||
|
|
||||||
|
/// Timeout in seconds for music downloads.
|
||||||
|
#[clap(long, default_value_t = 16)]
|
||||||
|
timeout: u64,
|
||||||
|
|
||||||
/// Include ALSA & other logs.
|
/// Include ALSA & other logs.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
@ -43,9 +65,13 @@ struct Args {
|
|||||||
#[clap(long, short, default_value_t = 3)]
|
#[clap(long, short, default_value_t = 3)]
|
||||||
width: usize,
|
width: usize,
|
||||||
|
|
||||||
/// Use a custom track list
|
/// Track list to play music from
|
||||||
#[clap(long, short, alias = "list", short_alias = 'l')]
|
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l', default_value_t = String::from("chillhop"))]
|
||||||
tracklist: Option<String>,
|
track_list: String,
|
||||||
|
|
||||||
|
/// Internal song buffer size.
|
||||||
|
#[clap(long, short = 's', alias = "buffer", default_value_t = 5, value_parser = clap::value_parser!(u32).range(2..))]
|
||||||
|
buffer_size: u32,
|
||||||
|
|
||||||
/// The command that was ran.
|
/// The command that was ran.
|
||||||
/// This is [None] if no command was specified.
|
/// This is [None] if no command was specified.
|
||||||
@ -54,35 +80,67 @@ struct Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Defines all of the extra commands lowfi can run.
|
/// Defines all of the extra commands lowfi can run.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand, Clone)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Scrapes the lofi girl website file server for files.
|
/// Scrapes a music source for files.
|
||||||
|
#[cfg(feature = "scrape")]
|
||||||
Scrape {
|
Scrape {
|
||||||
/// The file extension to search for, defaults to mp3.
|
// The source to scrape from.
|
||||||
#[clap(long, short, default_value = "mp3")]
|
source: scrapers::Source,
|
||||||
extension: String,
|
|
||||||
|
|
||||||
/// Whether to include the full HTTP URL or just the distinguishing part.
|
|
||||||
#[clap(long, short)]
|
|
||||||
include_full: bool,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
/// Shorthand to get a boolean environment variable.
|
||||||
async fn main() -> eyre::Result<()> {
|
#[inline]
|
||||||
#[cfg(target_os = "android")]
|
pub fn env(name: &str) -> bool {
|
||||||
compile_error!("Android Audio API not supported due to threading shenanigans");
|
std::env::var(name).is_ok_and(|x| x == "1")
|
||||||
|
}
|
||||||
let cli = Args::parse();
|
|
||||||
|
/// Returns the application data directory used for persistency.
|
||||||
if let Some(command) = cli.command {
|
///
|
||||||
match command {
|
/// The function returns the platform-specific user data directory with
|
||||||
Commands::Scrape {
|
/// a `lowfi` subfolder. Callers may use this path to store config,
|
||||||
extension,
|
/// bookmarks, and other persistent files.
|
||||||
include_full,
|
#[inline]
|
||||||
} => scrape::scrape(extension, include_full).await,
|
pub fn data_dir() -> crate::Result<PathBuf> {
|
||||||
}
|
let dir = dirs::data_dir().unwrap().join("lowfi");
|
||||||
} else {
|
|
||||||
play::play(cli).await
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Program entry point.
|
||||||
|
///
|
||||||
|
/// Parses CLI arguments, initializes the audio stream and player, then
|
||||||
|
/// runs the main event loop. On exit it performs cleanup of the UI and
|
||||||
|
/// returns the inner result.
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
#[cfg(feature = "scrape")]
|
||||||
|
if let Some(command) = &args.command {
|
||||||
|
return match command {
|
||||||
|
Commands::Scrape { source } => match source {
|
||||||
|
Source::Archive => scrapers::archive::scrape().await,
|
||||||
|
Source::Lofigirl => scrapers::lofigirl::scrape().await,
|
||||||
|
Source::Chillhop => scrapers::chillhop::scrape().await,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = audio::stream()?;
|
||||||
|
let environment = ui::Environment::ready(&args)?;
|
||||||
|
let (mut player, tasks) = Player::init(args, stream.mixer())
|
||||||
|
.await
|
||||||
|
.inspect_err(|_| environment.cleanup(false).unwrap())?;
|
||||||
|
|
||||||
|
let result = tokio::select! {
|
||||||
|
r = player.run() => r,
|
||||||
|
r = tasks => r,
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.cleanup(result.is_ok())?;
|
||||||
|
player.close().await?;
|
||||||
|
|
||||||
|
Ok(result?)
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/message.rs
Normal file
37
src/message.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/// Handles communication between different parts of the program.
|
||||||
|
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||||
|
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||||
|
pub enum Message {
|
||||||
|
/// Deliberate user request to go to the next song, also sent when the
|
||||||
|
/// song is over by the waiter.
|
||||||
|
Next,
|
||||||
|
|
||||||
|
/// When a track is loaded after the caller previously being told to wait.
|
||||||
|
/// If a track is taken from the queue, then there is no waiting, so this
|
||||||
|
/// is never actually sent.
|
||||||
|
Loaded,
|
||||||
|
|
||||||
|
/// Similar to Next, but specific to the first track.
|
||||||
|
Init,
|
||||||
|
|
||||||
|
/// Unpause the [Sink].
|
||||||
|
Play,
|
||||||
|
|
||||||
|
/// Pauses the [Sink].
|
||||||
|
Pause,
|
||||||
|
|
||||||
|
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||||
|
PlayPause,
|
||||||
|
|
||||||
|
/// Change the volume of playback.
|
||||||
|
ChangeVolume(f32),
|
||||||
|
|
||||||
|
/// Set the volume of playback, rather than changing it.
|
||||||
|
SetVolume(f32),
|
||||||
|
|
||||||
|
/// Bookmark the current track.
|
||||||
|
Bookmark,
|
||||||
|
|
||||||
|
/// Quits gracefully.
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
120
src/play.rs
120
src/play.rs
@ -1,120 +0,0 @@
|
|||||||
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use eyre::eyre;
|
|
||||||
use tokio::fs;
|
|
||||||
use tokio::{sync::mpsc, task};
|
|
||||||
|
|
||||||
use crate::player::Player;
|
|
||||||
use crate::player::{ui, Messages};
|
|
||||||
use crate::Args;
|
|
||||||
|
|
||||||
/// This is the representation of the persistent volume,
|
|
||||||
/// which is loaded at startup and saved on shutdown.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct PersistentVolume {
|
|
||||||
/// The volume, as a percentage.
|
|
||||||
inner: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PersistentVolume {
|
|
||||||
/// Retrieves the config directory.
|
|
||||||
async fn config() -> eyre::Result<PathBuf> {
|
|
||||||
let config = dirs::config_dir()
|
|
||||||
.ok_or_else(|| eyre!("Couldn't find config directory"))?
|
|
||||||
.join(PathBuf::from("lowfi"));
|
|
||||||
|
|
||||||
if !config.exists() {
|
|
||||||
fs::create_dir_all(&config).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the volume as a float from 0 to 1.
|
|
||||||
pub fn float(self) -> f32 {
|
|
||||||
f32::from(self.inner) / 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the [`PersistentVolume`] from [`dirs::config_dir()`].
|
|
||||||
pub async fn load() -> eyre::Result<Self> {
|
|
||||||
let config = Self::config().await?;
|
|
||||||
let volume = config.join(PathBuf::from("volume.txt"));
|
|
||||||
|
|
||||||
// Basically just read from the volume file if it exists, otherwise return 100.
|
|
||||||
let volume = if volume.exists() {
|
|
||||||
let contents = fs::read_to_string(volume).await?;
|
|
||||||
let trimmed = contents.trim();
|
|
||||||
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
|
|
||||||
stripped
|
|
||||||
.parse()
|
|
||||||
.map_err(|_error| eyre!("volume.txt file is invalid"))?
|
|
||||||
} else {
|
|
||||||
fs::write(&volume, "100").await?;
|
|
||||||
100u16
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self { inner: volume })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saves `volume` to `volume.txt`.
|
|
||||||
pub async fn save(volume: f32) -> eyre::Result<()> {
|
|
||||||
let config = Self::config().await?;
|
|
||||||
let path = config.join(PathBuf::from("volume.txt"));
|
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::as_conversions,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
reason = "already rounded & absolute, therefore this should be safe"
|
|
||||||
)]
|
|
||||||
let percentage = (volume * 100.0).abs().round() as u16;
|
|
||||||
|
|
||||||
fs::write(path, percentage.to_string()).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper around [`rodio::OutputStream`] to implement [Send], currently unsafely.
|
|
||||||
///
|
|
||||||
/// This is more of a temporary solution until cpal implements [Send] on it's output stream.
|
|
||||||
pub struct SendableOutputStream(pub rodio::OutputStream);
|
|
||||||
|
|
||||||
// SAFETY: This is necessary because [OutputStream] does not implement [Send],
|
|
||||||
// due to some limitation with Android's Audio API.
|
|
||||||
// I'm pretty sure nobody will use lowfi with android, so this is safe.
|
|
||||||
#[expect(
|
|
||||||
clippy::non_send_fields_in_send_ty,
|
|
||||||
reason = "this is expected because of the nature of the struct"
|
|
||||||
)]
|
|
||||||
unsafe impl Send for SendableOutputStream {}
|
|
||||||
|
|
||||||
/// Initializes the audio server, and then safely stops
|
|
||||||
/// it when the frontend quits.
|
|
||||||
pub async fn play(args: Args) -> eyre::Result<()> {
|
|
||||||
// Actually initializes the player.
|
|
||||||
// Stream kept here in the master thread to keep it alive.
|
|
||||||
let (player, stream) = Player::new(&args).await?;
|
|
||||||
let player = Arc::new(player);
|
|
||||||
|
|
||||||
// Initialize the UI, as well as the internal communication channel.
|
|
||||||
let (tx, rx) = mpsc::channel(8);
|
|
||||||
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
|
|
||||||
|
|
||||||
// Sends the player an "init" signal telling it to start playing a song straight away.
|
|
||||||
tx.send(Messages::Init).await?;
|
|
||||||
|
|
||||||
// Actually starts the player.
|
|
||||||
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
|
|
||||||
|
|
||||||
// Save the volume.txt file for the next session.
|
|
||||||
PersistentVolume::save(player.sink.volume()).await?;
|
|
||||||
drop(stream.0);
|
|
||||||
player.sink.stop();
|
|
||||||
ui.abort();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
553
src/player.rs
553
src/player.rs
@ -1,431 +1,206 @@
|
|||||||
//! Responsible for playing & queueing audio.
|
//! The player, which contains all of the core logic behind the music player.
|
||||||
//! This also has the code for the underlying
|
|
||||||
//! audio server which adds new tracks.
|
|
||||||
|
|
||||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc::{self, Receiver};
|
||||||
use arc_swap::ArcSwapOption;
|
|
||||||
use downloader::Downloader;
|
|
||||||
use reqwest::Client;
|
|
||||||
use rodio::{OutputStream, OutputStreamHandle, Sink};
|
|
||||||
use tokio::{
|
|
||||||
select,
|
|
||||||
sync::{
|
|
||||||
mpsc::{Receiver, Sender},
|
|
||||||
RwLock,
|
|
||||||
},
|
|
||||||
task,
|
|
||||||
time::sleep,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
play::{PersistentVolume, SendableOutputStream},
|
audio::waiter,
|
||||||
tracks::{self, list::List},
|
bookmark::Bookmarks,
|
||||||
Args,
|
downloader,
|
||||||
|
tracks::{self, List},
|
||||||
|
ui,
|
||||||
|
volume::PersistentVolume,
|
||||||
|
Message, Tasks,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod downloader;
|
/// Represents the currently known playback state.
|
||||||
pub mod ui;
|
///
|
||||||
|
/// * [`Current::Loading`] indicates the player is waiting for data.
|
||||||
|
/// * [`Current::Track`] indicates the player has a decoded track available.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Current {
|
||||||
|
/// Waiting for a track to arrive. The optional `Progress` is used to
|
||||||
|
/// indicate global download progress when present.
|
||||||
|
Loading(Option<downloader::Progress>),
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
/// A decoded track that can be played; contains the track `Info`.
|
||||||
pub mod mpris;
|
Track(tracks::Info),
|
||||||
|
|
||||||
/// Handles communication between the frontend & audio player.
|
|
||||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
|
||||||
pub enum Messages {
|
|
||||||
/// Notifies the audio server that it should update the track.
|
|
||||||
Next,
|
|
||||||
|
|
||||||
/// Special in that this isn't sent in a "client to server" sort of way,
|
|
||||||
/// but rather is sent by a child of the server when a song has not only
|
|
||||||
/// been requested but also downloaded aswell.
|
|
||||||
NewSong,
|
|
||||||
|
|
||||||
/// This signal is only sent if a track timed out. In that case,
|
|
||||||
/// lowfi will try again and again to retrieve the track.
|
|
||||||
TryAgain,
|
|
||||||
|
|
||||||
/// Similar to Next, but specific to the first track.
|
|
||||||
Init,
|
|
||||||
|
|
||||||
/// Unpause the [Sink].
|
|
||||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
|
||||||
Play,
|
|
||||||
|
|
||||||
/// Pauses the [Sink].
|
|
||||||
Pause,
|
|
||||||
|
|
||||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
|
||||||
PlayPause,
|
|
||||||
|
|
||||||
/// Change the volume of playback.
|
|
||||||
ChangeVolume(f32),
|
|
||||||
|
|
||||||
/// Quits gracefully.
|
|
||||||
Quit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The time to wait in between errors.
|
impl Default for Current {
|
||||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
fn default() -> Self {
|
||||||
|
// By default the player starts in a loading state with no progress.
|
||||||
|
Self::Loading(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The amount of songs to buffer up.
|
impl Current {
|
||||||
const BUFFER_SIZE: usize = 5;
|
/// Returns `true` if this `Current` value represents a loading state.
|
||||||
|
pub const fn loading(&self) -> bool {
|
||||||
|
matches!(self, Self::Loading(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Main struct responsible for queuing up & playing tracks.
|
/// The high-level application player.
|
||||||
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
|
///
|
||||||
// TODO: In other words, this would change the type from `Arc<Player>` to just `Player`.
|
/// `Player` composes the downloader, UI, audio sink and bookkeeping state.
|
||||||
// TODO:
|
/// It owns background `Handle`s and drives the main message loop in `run`.
|
||||||
// TODO: This is conflicting, since then it'd clone ~10 smaller [Arc]s
|
|
||||||
// TODO: every single time, which could be even worse than having an
|
|
||||||
// TODO: [Arc] of an [Arc] in some cases (Like with [Sink] & [Client]).
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
/// [rodio]'s [`Sink`] which can control playback.
|
/// Persistent bookmark storage used by the player.
|
||||||
pub sink: Sink,
|
bookmarks: Bookmarks,
|
||||||
|
|
||||||
/// The [`TrackInfo`] of the current track.
|
/// Current playback state (loading or track).
|
||||||
/// This is [`None`] when lowfi is buffering/loading.
|
current: Current,
|
||||||
current: ArcSwapOption<tracks::Info>,
|
|
||||||
|
|
||||||
/// The tracks, which is a [`VecDeque`] that holds
|
/// Background downloader that fills the internal queue.
|
||||||
/// *undecoded* [Track]s.
|
downloader: downloader::Handle,
|
||||||
///
|
|
||||||
/// This is populated specifically by the [Downloader].
|
|
||||||
tracks: RwLock<VecDeque<tracks::Track>>,
|
|
||||||
|
|
||||||
/// The actual list of tracks to be played.
|
/// Receiver for incoming `Message` commands.
|
||||||
list: List,
|
rx: Receiver<crate::Message>,
|
||||||
|
|
||||||
/// The initial volume level.
|
/// Shared audio sink used for playback.
|
||||||
volume: PersistentVolume,
|
sink: Arc<rodio::Sink>,
|
||||||
|
|
||||||
/// The web client, which can contain a `UserAgent` & some
|
/// UI handle for rendering and input.
|
||||||
/// settings that help lowfi work more effectively.
|
ui: ui::Handle,
|
||||||
client: Client,
|
|
||||||
|
|
||||||
/// The [`OutputStreamHandle`], which also can control some
|
/// Notifies when a play head has been appended.
|
||||||
/// playback, is for now unused and is here just to keep it
|
waiter: waiter::Handle,
|
||||||
/// alive so the playback can function properly.
|
|
||||||
_handle: OutputStreamHandle,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
/// This gets the output stream while also shutting up alsa with [libc].
|
/// Sets the in-memory current state and notifies the UI about the change.
|
||||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
|
||||||
///
|
///
|
||||||
/// In other words, for the younger generation, we're telling alsa
|
/// If the new state is a `Track`, this will also update the bookmarked flag
|
||||||
/// to simply just the audio in the bag, lil api.
|
/// based on persistent bookmarks.
|
||||||
#[cfg(target_os = "linux")]
|
pub fn set_current(&mut self, current: Current) -> crate::Result<()> {
|
||||||
fn silent_get_output_stream() -> eyre::Result<(OutputStream, OutputStreamHandle)> {
|
self.current = current.clone();
|
||||||
use libc::freopen;
|
self.ui.update(ui::Update::Track(current))?;
|
||||||
use std::ffi::CString;
|
|
||||||
|
|
||||||
// Get the file descriptor to stderr from libc.
|
let Current::Track(track) = &self.current else {
|
||||||
extern "C" {
|
return Ok(());
|
||||||
static stderr: *mut libc::FILE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
|
|
||||||
// output to `/dev/null` so that it wont be shoved down our throats.
|
|
||||||
|
|
||||||
// The mode which to redirect terminal output with.
|
|
||||||
let mode = CString::new("w")?;
|
|
||||||
|
|
||||||
// First redirect to /dev/null, which basically silences alsa.
|
|
||||||
let null = CString::new("/dev/null")?;
|
|
||||||
|
|
||||||
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
|
||||||
unsafe {
|
|
||||||
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the OutputStream while stderr is still redirected to /dev/null.
|
|
||||||
let (stream, handle) = OutputStream::try_default()?;
|
|
||||||
|
|
||||||
// Redirect back to the current terminal, so that other output isn't silenced.
|
|
||||||
let tty = CString::new("/dev/tty")?;
|
|
||||||
|
|
||||||
// SAFETY: See the first call to `freopen`.
|
|
||||||
unsafe {
|
|
||||||
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((stream, handle))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Just a shorthand for setting `current`.
|
|
||||||
fn set_current(&self, info: tracks::Info) {
|
|
||||||
self.current.store(Some(Arc::new(info)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A shorthand for checking if `self.current` is [Some].
|
|
||||||
pub fn current_exists(&self) -> bool {
|
|
||||||
self.current.load().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the volume of the sink, and also clamps the value to avoid negative/over 100% values.
|
|
||||||
pub fn set_volume(&self, volume: f32) {
|
|
||||||
self.sink.set_volume(volume.clamp(0.0, 1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the entire player, including audio devices & sink.
|
|
||||||
///
|
|
||||||
/// This also will load the track list & persistent volume.
|
|
||||||
pub async fn new(args: &Args) -> eyre::Result<(Self, SendableOutputStream)> {
|
|
||||||
// Load the volume file.
|
|
||||||
let volume = PersistentVolume::load().await?;
|
|
||||||
|
|
||||||
// Load the track list.
|
|
||||||
let list = List::load(args.tracklist.as_ref()).await?;
|
|
||||||
|
|
||||||
// We should only shut up alsa forcefully on Linux if we really have to.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let (stream, handle) = if !args.alternate && !args.debug {
|
|
||||||
Self::silent_get_output_stream()?
|
|
||||||
} else {
|
|
||||||
OutputStream::try_default()?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If we're not on Linux, then there's no problem.
|
let bookmarked = self.bookmarks.bookmarked(track);
|
||||||
#[cfg(not(target_os = "linux"))]
|
self.ui.update(ui::Update::Bookmarked(bookmarked))?;
|
||||||
let (stream, handle) = OutputStream::try_default()?;
|
|
||||||
|
|
||||||
let sink = Sink::try_new(&handle)?;
|
|
||||||
if args.paused {
|
|
||||||
sink.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = Client::builder()
|
|
||||||
.user_agent(concat!(
|
|
||||||
env!("CARGO_PKG_NAME"),
|
|
||||||
"/",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
))
|
|
||||||
.timeout(TIMEOUT)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let player = Self {
|
|
||||||
tracks: RwLock::new(VecDeque::with_capacity(5)),
|
|
||||||
current: ArcSwapOption::new(None),
|
|
||||||
client,
|
|
||||||
sink,
|
|
||||||
volume,
|
|
||||||
list,
|
|
||||||
_handle: handle,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((player, SendableOutputStream(stream)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This will play the next track, as well as refilling the buffer in the background.
|
|
||||||
///
|
|
||||||
/// This will also set `current` to the newly loaded song.
|
|
||||||
pub async fn next(&self) -> eyre::Result<tracks::Decoded> {
|
|
||||||
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
|
||||||
let track = self.tracks.write().await.pop_front();
|
|
||||||
let track = if let Some(track) = track {
|
|
||||||
track
|
|
||||||
} else {
|
|
||||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
|
||||||
// This is relevant particularly at the first song.
|
|
||||||
|
|
||||||
// Serves as an indicator that the queue is "loading".
|
|
||||||
// We're doing it here so that we don't get the "loading" display
|
|
||||||
// for only a frame in the other case that the buffer is not empty.
|
|
||||||
self.current.store(None);
|
|
||||||
|
|
||||||
self.list.random(&self.client).await.0?
|
|
||||||
};
|
|
||||||
|
|
||||||
let decoded = track.decode()?;
|
|
||||||
|
|
||||||
// Set the current track.
|
|
||||||
self.set_current(decoded.info.clone());
|
|
||||||
|
|
||||||
Ok(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This basically just calls [`Player::next`], and then appends the new track to the player.
|
|
||||||
///
|
|
||||||
/// This also notifies the background thread to get to work, and will send `TryAgain`
|
|
||||||
/// if it fails. This functions purpose is to be called in the background, so that
|
|
||||||
/// when the audio server recieves a `Next` signal it will still be able to respond to other
|
|
||||||
/// signals while it's loading.
|
|
||||||
///
|
|
||||||
/// This also sends the `NewSong` signal to `tx` apon successful completion.
|
|
||||||
async fn handle_next(
|
|
||||||
player: Arc<Self>,
|
|
||||||
itx: Sender<()>,
|
|
||||||
tx: Sender<Messages>,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
// Stop the sink.
|
|
||||||
player.sink.stop();
|
|
||||||
|
|
||||||
let track = player.next().await;
|
|
||||||
|
|
||||||
match track {
|
|
||||||
Ok(track) => {
|
|
||||||
// Start playing the new track.
|
|
||||||
player.sink.append(track.data);
|
|
||||||
|
|
||||||
// Notify the background downloader that there's an empty spot
|
|
||||||
// in the buffer.
|
|
||||||
Downloader::notify(&itx).await?;
|
|
||||||
|
|
||||||
// Notify the audio server that the next song has actually been downloaded.
|
|
||||||
tx.send(Messages::NewSong).await?;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
if !error.downcast::<reqwest::Error>()?.is_timeout() {
|
|
||||||
sleep(TIMEOUT).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.send(Messages::TryAgain).await?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is the main "audio server".
|
/// Initialize a `Player` with the provided CLI `args` and audio `mixer`.
|
||||||
///
|
///
|
||||||
/// `rx` & `tx` are used to communicate with it, for example when to
|
/// This sets up the audio sink, UI, downloader, bookmarks and persistent
|
||||||
/// skip tracks or pause.
|
/// volume state. The function returns a fully constructed `Player` ready
|
||||||
|
/// to be driven via `run`.
|
||||||
|
pub async fn init(
|
||||||
|
args: crate::Args,
|
||||||
|
mixer: &rodio::mixer::Mixer,
|
||||||
|
) -> crate::Result<(Self, crate::Tasks)> {
|
||||||
|
let (tx, rx) = mpsc::channel(8);
|
||||||
|
let mut tasks = Tasks::new(tx.clone());
|
||||||
|
if args.paused {
|
||||||
|
tx.send(Message::Pause).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(Message::Init).await?;
|
||||||
|
let list = List::load(args.track_list.as_ref()).await?;
|
||||||
|
|
||||||
|
let sink = Arc::new(rodio::Sink::connect_new(mixer));
|
||||||
|
let state = ui::State::initial(Arc::clone(&sink), list.name.clone());
|
||||||
|
|
||||||
|
let volume = PersistentVolume::load().await?;
|
||||||
|
sink.set_volume(volume.float());
|
||||||
|
|
||||||
|
let player = Self {
|
||||||
|
ui: tasks.ui(state, &args).await?,
|
||||||
|
downloader: tasks.downloader(args.buffer_size as usize, args.timeout, list)?,
|
||||||
|
waiter: tasks.waiter(Arc::clone(&sink)),
|
||||||
|
bookmarks: Bookmarks::load().await?,
|
||||||
|
current: Current::default(),
|
||||||
|
rx,
|
||||||
|
sink,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((player, tasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close any outlying processes, as well as persist state that
|
||||||
|
/// should survive such as bookmarks and volume.
|
||||||
|
pub async fn close(self) -> crate::Result<()> {
|
||||||
|
self.sink.stop();
|
||||||
|
self.bookmarks.save().await?;
|
||||||
|
PersistentVolume::save(self.sink.volume()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a queued track by decoding, appending to the sink and notifying
|
||||||
|
/// other subsystems that playback has changed.
|
||||||
|
pub fn play(&mut self, queued: tracks::Queued) -> crate::Result<()> {
|
||||||
|
let decoded = queued.decode()?;
|
||||||
|
self.sink.append(decoded.data);
|
||||||
|
self.set_current(Current::Track(decoded.info))?;
|
||||||
|
self.waiter.notify();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the main message loop of the player.
|
||||||
///
|
///
|
||||||
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
|
/// This will return when a `Message::Quit` is received. It handles commands
|
||||||
pub async fn play(
|
/// coming from the frontend and updates playback/UI state accordingly.
|
||||||
player: Arc<Self>,
|
pub async fn run(&mut self) -> crate::Result<()> {
|
||||||
tx: Sender<Messages>,
|
while let Some(message) = self.rx.recv().await {
|
||||||
mut rx: Receiver<Messages>,
|
match message {
|
||||||
) -> eyre::Result<()> {
|
Message::Next | Message::Init | Message::Loaded => {
|
||||||
// Initialize the mpris player.
|
if message == Message::Next && self.current.loading() {
|
||||||
//
|
|
||||||
// We're initializing here, despite MPRIS being a "user interface",
|
|
||||||
// since we need to be able to *actively* write new information to MPRIS
|
|
||||||
// specifically when it occurs, unlike the UI which passively reads the
|
|
||||||
// information each frame. Blame MPRIS, not me.
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
let mpris = mpris::Server::new(Arc::clone(&player), tx.clone())
|
|
||||||
.await
|
|
||||||
.inspect_err(|x| {
|
|
||||||
dbg!(x);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
|
||||||
let downloader = Downloader::new(Arc::clone(&player));
|
|
||||||
let (itx, downloader) = downloader.start();
|
|
||||||
|
|
||||||
// Start buffering tracks immediately.
|
|
||||||
Downloader::notify(&itx).await?;
|
|
||||||
|
|
||||||
// Set the initial sink volume to the one specified.
|
|
||||||
player.set_volume(player.volume.float());
|
|
||||||
|
|
||||||
// Whether the last signal was a `NewSong`. This is helpful, since we
|
|
||||||
// only want to autoplay if there hasn't been any manual intervention.
|
|
||||||
//
|
|
||||||
// In other words, this will be `true` after a new track has been fully
|
|
||||||
// loaded and it'll be `false` if a track is still currently loading.
|
|
||||||
let mut new = false;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let clone = Arc::clone(&player);
|
|
||||||
|
|
||||||
let msg = select! {
|
|
||||||
biased;
|
|
||||||
|
|
||||||
Some(x) = rx.recv() => x,
|
|
||||||
// This future will finish only at the end of the current track.
|
|
||||||
// The condition is a kind-of hack which gets around the quirks
|
|
||||||
// of `sleep_until_end`.
|
|
||||||
//
|
|
||||||
// That's because `sleep_until_end` will return instantly if the sink
|
|
||||||
// is uninitialized. That's why we put a check to make sure that the last
|
|
||||||
// signal we got was `NewSong`, since we shouldn't start waiting for the
|
|
||||||
// song to be over until it has actually started.
|
|
||||||
//
|
|
||||||
// It's also important to note that the condition is only checked at the
|
|
||||||
// beginning of the loop, not throughout.
|
|
||||||
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
|
|
||||||
if new => Messages::Next,
|
|
||||||
};
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Messages::Next | Messages::Init | Messages::TryAgain => {
|
|
||||||
// We manually skipped, so we shouldn't actually wait for the song
|
|
||||||
// to be over until we recieve the `NewSong` signal.
|
|
||||||
new = false;
|
|
||||||
|
|
||||||
// This basically just prevents `Next` while a song is still currently loading.
|
|
||||||
if msg == Messages::Next && !player.current_exists() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the rest of the signal in the background,
|
self.sink.stop();
|
||||||
// as to not block the main audio server thread.
|
match self.downloader.track() {
|
||||||
task::spawn(Self::handle_next(
|
downloader::Output::Loading(progress) => {
|
||||||
Arc::clone(&player),
|
self.set_current(Current::Loading(progress))?;
|
||||||
itx.clone(),
|
}
|
||||||
tx.clone(),
|
downloader::Output::Queued(queued) => self.play(queued)?,
|
||||||
));
|
|
||||||
}
|
|
||||||
Messages::Play => {
|
|
||||||
player.sink.play();
|
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
mpris.playback(PlaybackStatus::Playing).await?;
|
|
||||||
}
|
|
||||||
Messages::Pause => {
|
|
||||||
player.sink.pause();
|
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
mpris.playback(PlaybackStatus::Paused).await?;
|
|
||||||
}
|
|
||||||
Messages::PlayPause => {
|
|
||||||
if player.sink.is_paused() {
|
|
||||||
player.sink.play();
|
|
||||||
} else {
|
|
||||||
player.sink.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
mpris
|
|
||||||
.playback(mpris.player().playback_status().await?)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
Messages::ChangeVolume(change) => {
|
Message::Play => {
|
||||||
player.set_volume(player.sink.volume() + change);
|
self.sink.play();
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
mpris
|
|
||||||
.changed(vec![Property::Volume(player.sink.volume().into())])
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
// This basically just continues, but more importantly, it'll re-evaluate
|
Message::Pause => {
|
||||||
// the select macro at the beginning of the loop.
|
self.sink.pause();
|
||||||
// See the top section to find out why this matters.
|
|
||||||
Messages::NewSong => {
|
|
||||||
// We've recieved `NewSong`, so on the next loop iteration we'll
|
|
||||||
// begin waiting for the song to be over in order to autoplay.
|
|
||||||
new = true;
|
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
|
||||||
mpris
|
|
||||||
.changed(vec![
|
|
||||||
Property::Metadata(mpris.player().metadata().await?),
|
|
||||||
Property::PlaybackStatus(mpris.player().playback_status().await?),
|
|
||||||
])
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Messages::Quit => break,
|
Message::PlayPause => {
|
||||||
|
if self.sink.is_paused() {
|
||||||
|
self.sink.play();
|
||||||
|
} else {
|
||||||
|
self.sink.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ChangeVolume(change) => {
|
||||||
|
self.sink
|
||||||
|
.set_volume((self.sink.volume() + change).clamp(0.0, 1.0));
|
||||||
|
self.ui.update(ui::Update::Volume)?;
|
||||||
|
}
|
||||||
|
Message::SetVolume(set) => {
|
||||||
|
self.sink.set_volume(set.clamp(0.0, 1.0));
|
||||||
|
self.ui.update(ui::Update::Volume)?;
|
||||||
|
}
|
||||||
|
Message::Bookmark => {
|
||||||
|
let Current::Track(current) = &self.current else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bookmarked = self.bookmarks.bookmark(current)?;
|
||||||
|
self.ui.update(ui::Update::Bookmarked(bookmarked))?;
|
||||||
|
}
|
||||||
|
Message::Quit => break,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
downloader.abort();
|
#[cfg(feature = "mpris")]
|
||||||
|
self.ui.mpris.handle(&message).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
//! Contains the [`Downloader`] struct.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc::{self, Receiver, Sender},
|
|
||||||
task::{self, JoinHandle},
|
|
||||||
time::sleep,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{Player, BUFFER_SIZE, TIMEOUT};
|
|
||||||
|
|
||||||
/// This struct is responsible for downloading tracks in the background.
|
|
||||||
///
|
|
||||||
/// This is not used for the first track or a track when the buffer is currently empty.
|
|
||||||
pub struct Downloader {
|
|
||||||
/// The player for the downloader to download to & with.
|
|
||||||
player: Arc<Player>,
|
|
||||||
|
|
||||||
/// The internal reciever, which is used by the downloader to know
|
|
||||||
/// when to begin downloading more tracks.
|
|
||||||
rx: Receiver<()>,
|
|
||||||
|
|
||||||
/// A copy of the internal sender, which can be useful for keeping
|
|
||||||
/// track of it.
|
|
||||||
tx: Sender<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Downloader {
|
|
||||||
/// Uses a sender recieved from [Sender] to notify the
|
|
||||||
/// download thread that it should resume downloading.
|
|
||||||
pub async fn notify(sender: &Sender<()>) -> Result<(), mpsc::error::SendError<()>> {
|
|
||||||
sender.send(()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the [Downloader].
|
|
||||||
///
|
|
||||||
/// This also sends a [`Sender`] which can be used to notify
|
|
||||||
/// when the downloader needs to begin downloading more tracks.
|
|
||||||
pub fn new(player: Arc<Player>) -> Self {
|
|
||||||
let (tx, rx) = mpsc::channel(8);
|
|
||||||
Self { player, rx, tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Actually starts & consumes the [Downloader].
|
|
||||||
pub fn start(mut self) -> (Sender<()>, JoinHandle<()>) {
|
|
||||||
(
|
|
||||||
self.tx,
|
|
||||||
task::spawn(async move {
|
|
||||||
// Loop through each update notification.
|
|
||||||
while self.rx.recv().await == Some(()) {
|
|
||||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
|
||||||
while self.player.tracks.read().await.len() < BUFFER_SIZE {
|
|
||||||
let (data, timeout) = self.player.list.random(&self.player.client).await;
|
|
||||||
match data {
|
|
||||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
|
||||||
Err(_) => {
|
|
||||||
if !timeout {
|
|
||||||
sleep(TIMEOUT).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
281
src/player/ui.rs
281
src/player/ui.rs
@ -1,281 +0,0 @@
|
|||||||
//! The module which manages all user interface, including inputs.
|
|
||||||
|
|
||||||
#![allow(
|
|
||||||
clippy::as_conversions,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
reason = "the ui is full of these because of various layout & positioning aspects, and for a simple music player making all casts safe is not worth the effort"
|
|
||||||
)]
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
fmt::Write as _,
|
|
||||||
io::{stdout, Stdout},
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::Args;
|
|
||||||
|
|
||||||
use crossterm::{
|
|
||||||
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
|
|
||||||
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
|
||||||
style::{Print, Stylize as _},
|
|
||||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
|
||||||
|
|
||||||
use super::{Messages, Player};
|
|
||||||
|
|
||||||
mod components;
|
|
||||||
mod input;
|
|
||||||
|
|
||||||
/// Self explanitory.
|
|
||||||
const FPS: usize = 12;
|
|
||||||
|
|
||||||
/// How long the audio bar will be visible for when audio is adjusted.
|
|
||||||
/// This is in frames.
|
|
||||||
const AUDIO_BAR_DURATION: usize = 10;
|
|
||||||
|
|
||||||
/// How long to wait in between frames.
|
|
||||||
/// This is fairly arbitrary, but an ideal value should be enough to feel
|
|
||||||
/// snappy but not require too many resources.
|
|
||||||
const FRAME_DELTA: f32 = 1.0 / FPS as f32;
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
/// The volume timer, which controls how long the volume display should
|
|
||||||
/// show up and when it should disappear.
|
|
||||||
///
|
|
||||||
/// When this is 0, it means that the audio bar shouldn't be displayed.
|
|
||||||
/// To make it start counting, you need to set it to 1.
|
|
||||||
static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the volume timer to one, effectively flashing the audio display in lowfi's UI.
|
|
||||||
///
|
|
||||||
/// The amount of frames the audio display is visible for is determined by [`AUDIO_BAR_DURATION`].
|
|
||||||
pub fn flash_audio() {
|
|
||||||
VOLUME_TIMER.store(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents an abstraction for drawing the actual lowfi window itself.
|
|
||||||
///
|
|
||||||
/// The main purpose of this struct is just to add the fancy border,
|
|
||||||
/// as well as clear the screen before drawing.
|
|
||||||
pub struct Window {
|
|
||||||
/// Whether or not to include borders in the output.
|
|
||||||
borderless: bool,
|
|
||||||
|
|
||||||
/// The top & bottom borders, which are here since they can be
|
|
||||||
/// prerendered, as they don't change from window to window.
|
|
||||||
///
|
|
||||||
/// If the option to not include borders is set, these will just be empty [String]s.
|
|
||||||
borders: [String; 2],
|
|
||||||
|
|
||||||
/// The output, currently just an [`Stdout`].
|
|
||||||
out: Stdout,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Window {
|
|
||||||
/// Initializes a new [Window].
|
|
||||||
///
|
|
||||||
/// * `width` - Width of the windows.
|
|
||||||
/// * `borderless` - Whether to include borders in the window, or not.
|
|
||||||
pub fn new(width: usize, borderless: bool) -> Self {
|
|
||||||
let borders = if borderless {
|
|
||||||
[String::new(), String::new()]
|
|
||||||
} else {
|
|
||||||
let middle = "─".repeat(width + 2);
|
|
||||||
|
|
||||||
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
borders,
|
|
||||||
borderless,
|
|
||||||
out: stdout(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Actually draws the window, with each element in `content` being on a new line.
|
|
||||||
pub fn draw(&mut self, content: Vec<String>) -> eyre::Result<()> {
|
|
||||||
let len: u16 = content.len().try_into()?;
|
|
||||||
|
|
||||||
// Note that this will have a trailing newline, which we use later.
|
|
||||||
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
|
||||||
// Horizontal Padding & Border
|
|
||||||
let padding = if self.borderless { " " } else { "│" };
|
|
||||||
write!(output, "{padding} {} {padding}\r\n", x.reset()).unwrap();
|
|
||||||
|
|
||||||
output
|
|
||||||
});
|
|
||||||
|
|
||||||
// We're doing this because Windows is stupid and can't stand
|
|
||||||
// writing to the last line repeatedly.
|
|
||||||
#[cfg(windows)]
|
|
||||||
let (height, suffix) = (len + 2, "\r\n");
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
let (height, suffix) = (len + 1, "");
|
|
||||||
|
|
||||||
// There's no need for another newline after the main menu content, because it already has one.
|
|
||||||
let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]);
|
|
||||||
|
|
||||||
crossterm::execute!(
|
|
||||||
self.out,
|
|
||||||
Clear(ClearType::FromCursorDown),
|
|
||||||
MoveToColumn(0),
|
|
||||||
Print(rendered),
|
|
||||||
MoveToColumn(0),
|
|
||||||
MoveUp(height),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The code for the terminal interface itself.
|
|
||||||
///
|
|
||||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
|
||||||
/// * `borderless` - Whether to include borders or not.
|
|
||||||
/// * `width` - The width of player
|
|
||||||
async fn interface(
|
|
||||||
player: Arc<Player>,
|
|
||||||
minimalist: bool,
|
|
||||||
borderless: bool,
|
|
||||||
width: usize,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
let mut window = Window::new(width, borderless);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Load `current` once so that it doesn't have to be loaded over and over
|
|
||||||
// again by different UI components.
|
|
||||||
let current = player.current.load();
|
|
||||||
let current = current.as_ref();
|
|
||||||
|
|
||||||
let action = components::action(&player, current, width);
|
|
||||||
|
|
||||||
let volume = player.sink.volume();
|
|
||||||
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
|
||||||
|
|
||||||
let timer = VOLUME_TIMER.load(Ordering::Relaxed);
|
|
||||||
let middle = match timer {
|
|
||||||
0 => components::progress_bar(&player, current, width - 16),
|
|
||||||
_ => components::audio_bar(volume, &percentage, width - 17),
|
|
||||||
};
|
|
||||||
|
|
||||||
if timer > 0 && timer <= AUDIO_BAR_DURATION {
|
|
||||||
// We'll keep increasing the timer until it eventually hits `AUDIO_BAR_DURATION`.
|
|
||||||
VOLUME_TIMER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
} else {
|
|
||||||
// If enough time has passed, we'll reset it back to 0.
|
|
||||||
VOLUME_TIMER.store(0, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let controls = components::controls(width);
|
|
||||||
|
|
||||||
let menu = if minimalist {
|
|
||||||
vec![action, middle]
|
|
||||||
} else {
|
|
||||||
vec![action, middle, controls]
|
|
||||||
};
|
|
||||||
|
|
||||||
window.draw(menu)?;
|
|
||||||
|
|
||||||
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents the terminal environment, and is used to properly
|
|
||||||
/// initialize and clean up the terminal.
|
|
||||||
pub struct Environment {
|
|
||||||
/// Whether keyboard enhancements are enabled.
|
|
||||||
enhancement: bool,
|
|
||||||
|
|
||||||
/// Whether the terminal is in an alternate screen or not.
|
|
||||||
alternate: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Environment {
|
|
||||||
/// This prepares the terminal, returning an [Environment] helpful
|
|
||||||
/// for cleaning up afterwards.
|
|
||||||
pub fn ready(alternate: bool) -> eyre::Result<Self> {
|
|
||||||
let mut lock = stdout().lock();
|
|
||||||
|
|
||||||
crossterm::execute!(lock, Hide)?;
|
|
||||||
|
|
||||||
if alternate {
|
|
||||||
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal::enable_raw_mode()?;
|
|
||||||
let enhancement = terminal::supports_keyboard_enhancement()?;
|
|
||||||
|
|
||||||
if enhancement {
|
|
||||||
crossterm::execute!(
|
|
||||||
lock,
|
|
||||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
enhancement,
|
|
||||||
alternate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uses the information collected from initialization to safely close down
|
|
||||||
/// the terminal & restore it to it's previous state.
|
|
||||||
pub fn cleanup(&self) -> eyre::Result<()> {
|
|
||||||
let mut lock = stdout().lock();
|
|
||||||
|
|
||||||
if self.alternate {
|
|
||||||
crossterm::execute!(lock, LeaveAlternateScreen)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?;
|
|
||||||
|
|
||||||
if self.enhancement {
|
|
||||||
crossterm::execute!(lock, PopKeyboardEnhancementFlags)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal::disable_raw_mode()?;
|
|
||||||
|
|
||||||
eprintln!("bye! :)");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Environment {
|
|
||||||
/// Just a wrapper for [`Environment::cleanup`] which ignores any errors thrown.
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Well, we're dropping it, so it doesn't really matter if there's an error.
|
|
||||||
let _ = self.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the UI, this will also start taking input from the user.
|
|
||||||
///
|
|
||||||
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
|
|
||||||
/// previous terminal history.
|
|
||||||
pub async fn start(player: Arc<Player>, sender: Sender<Messages>, args: Args) -> eyre::Result<()> {
|
|
||||||
let environment = Environment::ready(args.alternate)?;
|
|
||||||
let interface = task::spawn(interface(
|
|
||||||
Arc::clone(&player),
|
|
||||||
args.minimalist,
|
|
||||||
args.borderless,
|
|
||||||
21 + args.width.min(32) * 2,
|
|
||||||
));
|
|
||||||
|
|
||||||
input::listen(sender.clone()).await?;
|
|
||||||
interface.abort();
|
|
||||||
|
|
||||||
environment.cleanup()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
//! Responsible for specifically recieving terminal input
|
|
||||||
//! using [`crossterm`].
|
|
||||||
|
|
||||||
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
|
||||||
use futures::{FutureExt as _, StreamExt as _};
|
|
||||||
use tokio::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use crate::player::{ui, Messages};
|
|
||||||
|
|
||||||
/// Starts the listener to recieve input from the terminal for various events.
|
|
||||||
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
|
||||||
let mut reader = EventStream::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let Some(Ok(event::Event::Key(event))) = reader.next().fuse().await else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if event.kind == KeyEventKind::Release {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = match event.code {
|
|
||||||
// Arrow key volume controls.
|
|
||||||
KeyCode::Up => Messages::ChangeVolume(0.1),
|
|
||||||
KeyCode::Right => Messages::ChangeVolume(0.01),
|
|
||||||
KeyCode::Down => Messages::ChangeVolume(-0.1),
|
|
||||||
KeyCode::Left => Messages::ChangeVolume(-0.01),
|
|
||||||
KeyCode::Char(character) => match character.to_ascii_lowercase() {
|
|
||||||
// Ctrl+C
|
|
||||||
'c' if event.modifiers == KeyModifiers::CONTROL => Messages::Quit,
|
|
||||||
|
|
||||||
// Quit
|
|
||||||
'q' => Messages::Quit,
|
|
||||||
|
|
||||||
// Skip/Next
|
|
||||||
's' | 'n' | 'l' => Messages::Next,
|
|
||||||
|
|
||||||
// Pause
|
|
||||||
'p' | ' ' => Messages::PlayPause,
|
|
||||||
|
|
||||||
// Volume up & down
|
|
||||||
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
|
|
||||||
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
|
|
||||||
|
|
||||||
_ => continue,
|
|
||||||
},
|
|
||||||
// Media keys
|
|
||||||
KeyCode::Media(media) => match media {
|
|
||||||
event::MediaKeyCode::Pause
|
|
||||||
| event::MediaKeyCode::Play
|
|
||||||
| event::MediaKeyCode::PlayPause => Messages::PlayPause,
|
|
||||||
event::MediaKeyCode::Stop => Messages::Pause,
|
|
||||||
event::MediaKeyCode::TrackNext => Messages::Next,
|
|
||||||
event::MediaKeyCode::LowerVolume => Messages::ChangeVolume(-0.1),
|
|
||||||
event::MediaKeyCode::RaiseVolume => Messages::ChangeVolume(0.1),
|
|
||||||
event::MediaKeyCode::MuteVolume => Messages::ChangeVolume(-1.0),
|
|
||||||
_ => continue,
|
|
||||||
},
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Messages::ChangeVolume(_) = messages {
|
|
||||||
ui::flash_audio();
|
|
||||||
}
|
|
||||||
|
|
||||||
sender.send(messages).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
91
src/scrapers.rs
Normal file
91
src/scrapers.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
//! Built in web scraping, which isn't guaranteed to have a unified UI.
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use eyre::bail;
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::{
|
||||||
|
fs::{self, File},
|
||||||
|
io::AsyncWriteExt,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod archive;
|
||||||
|
pub mod chillhop;
|
||||||
|
pub mod lofigirl;
|
||||||
|
|
||||||
|
/// Represents the different sources which can be scraped.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
|
||||||
|
pub enum Source {
|
||||||
|
Lofigirl,
|
||||||
|
Archive,
|
||||||
|
Chillhop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
/// Gets the cache directory name, for example, `chillhop`.
|
||||||
|
pub fn cache_dir(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Source::Lofigirl => "lofigirl",
|
||||||
|
Source::Archive => "archive",
|
||||||
|
Source::Chillhop => "chillhop",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the full root URL of the source.
|
||||||
|
pub fn url(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Source::Chillhop => "https://chillhop.com",
|
||||||
|
Source::Archive => "https://ia601004.us.archive.org/31/items/lofigirl",
|
||||||
|
Source::Lofigirl => "https://lofigirl.com/wp-content/uploads",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a get request, with caching.
|
||||||
|
async fn get(client: &Client, path: &str, source: Source) -> eyre::Result<String> {
|
||||||
|
let trimmed = path.trim_matches('/');
|
||||||
|
let cache = PathBuf::from(format!("./cache/{}/{trimmed}.html", source.cache_dir()));
|
||||||
|
|
||||||
|
if let Ok(x) = fs::read_to_string(&cache).await {
|
||||||
|
Ok(x)
|
||||||
|
} else {
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{}/{trimmed}", source.url()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
if status == 429 {
|
||||||
|
bail!("rate limit reached: {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != 404 && !status.is_success() && !status.is_redirection() {
|
||||||
|
bail!("non success code {}: {path}", resp.status().as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = resp.text().await?;
|
||||||
|
|
||||||
|
let parent = cache.parent();
|
||||||
|
if let Some(x) = parent {
|
||||||
|
if x != Path::new("") {
|
||||||
|
fs::create_dir_all(x).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = File::create(&cache).await?;
|
||||||
|
file.write_all(text.as_bytes()).await?;
|
||||||
|
|
||||||
|
if status.is_redirection() {
|
||||||
|
bail!("redirect: {path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == 404 {
|
||||||
|
bail!("not found: {path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/scrapers/archive.rs
Normal file
74
src/scrapers/archive.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
//! Has all of the functions for the `scrape` command.
|
||||||
|
//!
|
||||||
|
//! This command is completely optional, and as such isn't subject to the same
|
||||||
|
//! quality standards as the rest of the codebase.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use futures_util::{stream::FuturesOrdered, StreamExt};
|
||||||
|
use reqwest::Client;
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
use crate::scrapers::{get, Source};
|
||||||
|
|
||||||
|
static SELECTOR: LazyLock<Selector> =
|
||||||
|
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
|
||||||
|
|
||||||
|
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||||
|
let document = get(client, path, super::Source::Lofigirl).await?;
|
||||||
|
let html = Html::parse_document(&document);
|
||||||
|
|
||||||
|
Ok(html
|
||||||
|
.select(&SELECTOR)
|
||||||
|
.skip(1)
|
||||||
|
.map(|x| String::from(x.attr("href").unwrap()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function basically just scans the entire file server, and returns a list of paths to mp3 files.
|
||||||
|
///
|
||||||
|
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
||||||
|
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||||
|
async fn scan() -> eyre::Result<Vec<String>> {
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let mut releases = parse(&client, "/").await?;
|
||||||
|
releases.truncate(releases.len() - 4);
|
||||||
|
|
||||||
|
// A little bit of async to run all of the months concurrently.
|
||||||
|
let mut futures = FuturesOrdered::new();
|
||||||
|
|
||||||
|
for release in releases {
|
||||||
|
let client = client.clone();
|
||||||
|
futures.push_back(async move {
|
||||||
|
let items = parse(&client, &release).await.unwrap();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| {
|
||||||
|
if x.ends_with(".mp3") {
|
||||||
|
Some(format!("{release}{x}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
while let Some(mut result) = futures.next().await {
|
||||||
|
files.append(&mut result);
|
||||||
|
}
|
||||||
|
|
||||||
|
eyre::Result::Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scrape() -> eyre::Result<()> {
|
||||||
|
println!("{}/", Source::Lofigirl.url());
|
||||||
|
let files = scan().await?;
|
||||||
|
for file in files {
|
||||||
|
println!("{file}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
222
src/scrapers/chillhop.rs
Normal file
222
src/scrapers/chillhop.rs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
use eyre::eyre;
|
||||||
|
use futures_util::stream::FuturesUnordered;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use indicatif::ProgressBar;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::{fmt, sync::LazyLock};
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
use serde::{
|
||||||
|
de::{self, Visitor},
|
||||||
|
Deserialize, Deserializer,
|
||||||
|
};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::scrapers::{get, Source};
|
||||||
|
|
||||||
|
static RELEASES: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".table-body > a").unwrap());
|
||||||
|
static RELEASE_LABEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse("label").unwrap());
|
||||||
|
// static ref RELEASE_DATE: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > .text-xs").unwrap());
|
||||||
|
// static ref RELEASE_NAME: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > h2").unwrap());
|
||||||
|
// static RELEASE_AUTHOR: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props .artist-link").unwrap());
|
||||||
|
static RELEASE_TEXTAREA: LazyLock<Selector> =
|
||||||
|
LazyLock::new(|| Selector::parse("textarea").unwrap());
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Track {
|
||||||
|
title: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_u32_from_string")]
|
||||||
|
file_id: u32,
|
||||||
|
artists: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Track {
|
||||||
|
pub fn clean(&mut self) {
|
||||||
|
self.artists = html_escape::decode_html_entities(&self.artists).to_string();
|
||||||
|
|
||||||
|
self.title = html_escape::decode_html_entities(&self.title).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct Release {
|
||||||
|
#[serde(skip)]
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
pub index: usize,
|
||||||
|
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
enum ReleaseError {
|
||||||
|
#[error("invalid release: {0}")]
|
||||||
|
Invalid(#[from] eyre::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Release {
|
||||||
|
pub async fn scan(
|
||||||
|
path: String,
|
||||||
|
index: usize,
|
||||||
|
client: Client,
|
||||||
|
bar: ProgressBar,
|
||||||
|
) -> Result<Self, ReleaseError> {
|
||||||
|
let content = get(&client, &path, Source::Chillhop).await?;
|
||||||
|
let html = Html::parse_document(&content);
|
||||||
|
|
||||||
|
let textarea = html
|
||||||
|
.select(&RELEASE_TEXTAREA)
|
||||||
|
.next()
|
||||||
|
.ok_or(eyre!("unable to find textarea: {path}"))?;
|
||||||
|
|
||||||
|
let mut release: Self = serde_json::from_str(&textarea.inner_html()).unwrap();
|
||||||
|
release.path = path;
|
||||||
|
release.index = index;
|
||||||
|
release.tracks.reverse();
|
||||||
|
|
||||||
|
bar.inc(release.tracks.len() as u64);
|
||||||
|
|
||||||
|
Ok(release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan_page(
|
||||||
|
number: usize,
|
||||||
|
client: &Client,
|
||||||
|
bar: ProgressBar,
|
||||||
|
) -> eyre::Result<Vec<impl Future<Output = Result<Release, ReleaseError>>>> {
|
||||||
|
let path = format!("releases/?page={number}");
|
||||||
|
let content = get(client, &path, Source::Chillhop).await?;
|
||||||
|
let html = Html::parse_document(&content);
|
||||||
|
|
||||||
|
let elements = html.select(&RELEASES);
|
||||||
|
Ok(elements
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, x)| {
|
||||||
|
let label = x.select(&RELEASE_LABEL).next()?.inner_html();
|
||||||
|
if label == "Compilation" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Release::scan(
|
||||||
|
x.attr("href")?.to_string(),
|
||||||
|
(number * 12) + i,
|
||||||
|
client.clone(),
|
||||||
|
bar.clone(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scrape() -> eyre::Result<()> {
|
||||||
|
const PAGE_COUNT: usize = 40;
|
||||||
|
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
|
||||||
|
const TRACK_COUNT: u64 = 1625;
|
||||||
|
|
||||||
|
const IGNORED_TRACKS: [u32; 28] = [
|
||||||
|
// 404
|
||||||
|
74707, // Lyrics
|
||||||
|
21655, 21773, 8172, 55397, 75135, 24827, 8141, 8157, 64052, 31612, 41956, 8001, 9217, 8730,
|
||||||
|
55372, 9262, 30131, 9372, 20561, 21652, 9306, 21646, // Abnormal
|
||||||
|
8469, 7832, 10448, 9446, 9396,
|
||||||
|
];
|
||||||
|
|
||||||
|
const IGNORED_ARTISTS: [&str; 1] = [
|
||||||
|
"Kenji", // Lyrics
|
||||||
|
];
|
||||||
|
|
||||||
|
fs::create_dir_all("./cache/chillhop").await.unwrap();
|
||||||
|
let client = Client::builder().user_agent(USER_AGENT).build().unwrap();
|
||||||
|
|
||||||
|
let futures = FuturesUnordered::new();
|
||||||
|
let bar = ProgressBar::new(TRACK_COUNT + (12 * (PAGE_COUNT as u64)));
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
// This is slightly less memory efficient than I'd hope, but it is what it is.
|
||||||
|
for page in 0..=PAGE_COUNT {
|
||||||
|
bar.inc(12);
|
||||||
|
for x in scan_page(page, &client, bar.clone()).await? {
|
||||||
|
futures.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<Result<Release, ReleaseError>> = futures.collect().await;
|
||||||
|
bar.finish_and_clear();
|
||||||
|
|
||||||
|
// I mean, is it... optimal? Absolutely not. Does it work? Yes.
|
||||||
|
eprintln!("sorting...");
|
||||||
|
results.sort_by_key(|x| if let Ok(x) = x { x.index } else { 0 });
|
||||||
|
results.reverse();
|
||||||
|
|
||||||
|
eprintln!("printing...");
|
||||||
|
let mut printed = Vec::with_capacity(TRACK_COUNT as usize); // Lazy way to get rid of dupes.
|
||||||
|
for result in results {
|
||||||
|
let release = match result {
|
||||||
|
Ok(release) => release,
|
||||||
|
Err(error) => {
|
||||||
|
errors.push(error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for mut track in release.tracks {
|
||||||
|
if IGNORED_TRACKS.contains(&track.file_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if IGNORED_ARTISTS.contains(&track.artists.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if printed.contains(&track.file_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
printed.push(track.file_id);
|
||||||
|
|
||||||
|
track.clean();
|
||||||
|
println!("{}!{}", track.file_id, track.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("-- ERROR REPORT --");
|
||||||
|
for error in errors {
|
||||||
|
eprintln!("{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize_u32_from_string<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct U32FromStringVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for U32FromStringVisitor {
|
||||||
|
type Value = u32;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a string containing an unsigned 32-bit integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
u32::from_str(value).map_err(|_| {
|
||||||
|
de::Error::invalid_value(
|
||||||
|
de::Unexpected::Str(value),
|
||||||
|
&"a valid unsigned 32-bit integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_str(U32FromStringVisitor)
|
||||||
|
}
|
||||||
@ -3,21 +3,21 @@
|
|||||||
//! This command is completely optional, and as such isn't subject to the same
|
//! This command is completely optional, and as such isn't subject to the same
|
||||||
//! quality standards as the rest of the codebase.
|
//! quality standards as the rest of the codebase.
|
||||||
|
|
||||||
use futures::{stream::FuturesOrdered, StreamExt};
|
use std::sync::LazyLock;
|
||||||
use lazy_static::lazy_static;
|
|
||||||
|
use futures_util::{stream::FuturesOrdered, StreamExt};
|
||||||
|
use reqwest::Client;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
const BASE_URL: &str = "https://lofigirl.com/wp-content/uploads/";
|
use crate::scrapers::get;
|
||||||
|
|
||||||
lazy_static! {
|
static SELECTOR: LazyLock<Selector> =
|
||||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
|
||||||
let response = reqwest::get(format!("{}{}", BASE_URL, path)).await?;
|
|
||||||
let document = response.text().await?;
|
|
||||||
|
|
||||||
|
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||||
|
let document = get(client, path, super::Source::Lofigirl).await?;
|
||||||
let html = Html::parse_document(&document);
|
let html = Html::parse_document(&document);
|
||||||
|
|
||||||
Ok(html
|
Ok(html
|
||||||
.select(&SELECTOR)
|
.select(&SELECTOR)
|
||||||
.skip(5)
|
.skip(5)
|
||||||
@ -29,10 +29,9 @@ async fn parse(path: &str) -> eyre::Result<Vec<String>> {
|
|||||||
///
|
///
|
||||||
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
|
||||||
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
|
||||||
async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>> {
|
async fn scan() -> eyre::Result<Vec<String>> {
|
||||||
let extension = &format!(".{}", extension);
|
let client = Client::new();
|
||||||
|
let items = parse(&client, "/").await?;
|
||||||
let items = parse("").await?;
|
|
||||||
|
|
||||||
let mut years: Vec<u32> = items
|
let mut years: Vec<u32> = items
|
||||||
.iter()
|
.iter()
|
||||||
@ -48,22 +47,19 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
|
|||||||
let mut futures = FuturesOrdered::new();
|
let mut futures = FuturesOrdered::new();
|
||||||
|
|
||||||
for year in years {
|
for year in years {
|
||||||
let months = parse(&year.to_string()).await?;
|
let months = parse(&client, &year.to_string()).await?;
|
||||||
|
|
||||||
for month in months {
|
for month in months {
|
||||||
|
let client = client.clone();
|
||||||
futures.push_back(async move {
|
futures.push_back(async move {
|
||||||
let path = format!("{}/{}", year, month);
|
let path = format!("{}/{}", year, month);
|
||||||
|
|
||||||
let items = parse(&path).await.unwrap();
|
let items = parse(&client, &path).await.unwrap();
|
||||||
items
|
items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|x| {
|
.filter_map(|x| {
|
||||||
if x.ends_with(extension) {
|
if x.ends_with(".mp3") {
|
||||||
if include_full {
|
Some(format!("{path}{x}"))
|
||||||
Some(format!("{BASE_URL}{path}{x}"))
|
|
||||||
} else {
|
|
||||||
Some(format!("{path}{x}"))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -81,8 +77,8 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
|
|||||||
eyre::Result::Ok(files)
|
eyre::Result::Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scrape(extension: String, include_full: bool) -> eyre::Result<()> {
|
pub async fn scrape() -> eyre::Result<()> {
|
||||||
let files = scan(&extension, include_full).await?;
|
let files = scan().await?;
|
||||||
for file in files {
|
for file in files {
|
||||||
println!("{file}");
|
println!("{file}");
|
||||||
}
|
}
|
||||||
58
src/tasks.rs
Normal file
58
src/tasks.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//! Task management.
|
||||||
|
//!
|
||||||
|
//! This file aims to abstract a lot of potentially annoying Rust async logic, which may be
|
||||||
|
//! subject to change.
|
||||||
|
|
||||||
|
use futures_util::{FutureExt, TryFutureExt};
|
||||||
|
use std::{future::Future, pin::Pin, task::Poll};
|
||||||
|
use tokio::{sync::mpsc, task::JoinHandle};
|
||||||
|
|
||||||
|
/// Handles all of the processes within lowfi.
|
||||||
|
/// This entails initializing/closing tasks, and handling any potential errors that arise.
|
||||||
|
pub struct Tasks {
|
||||||
|
/// A simple [`Vec`] of [`JoinHandle`]s.
|
||||||
|
pub handles: Vec<JoinHandle<crate::Result<()>>>,
|
||||||
|
|
||||||
|
/// A sender, which is kept for convenience to be used when
|
||||||
|
/// initializing various other tasks.
|
||||||
|
tx: mpsc::Sender<crate::Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tasks {
|
||||||
|
/// Creates a new task manager.
|
||||||
|
pub const fn new(tx: mpsc::Sender<crate::Message>) -> Self {
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
handles: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a task, and adds it to the internal buffer.
|
||||||
|
pub fn spawn<E: Into<crate::Error> + Send + Sync + 'static>(
|
||||||
|
&mut self,
|
||||||
|
future: impl Future<Output = Result<(), E>> + Send + 'static,
|
||||||
|
) {
|
||||||
|
self.handles.push(tokio::spawn(future.map_err(Into::into)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a copy of the internal [`mpsc::Sender`].
|
||||||
|
pub fn tx(&self) -> mpsc::Sender<crate::Message> {
|
||||||
|
self.tx.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Future for Tasks {
|
||||||
|
type Output = crate::Result<()>;
|
||||||
|
|
||||||
|
fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
|
||||||
|
for handle in &mut self.get_mut().handles {
|
||||||
|
match handle.poll_unpin(cx) {
|
||||||
|
Poll::Ready(Ok(x)) => return Poll::Ready(x),
|
||||||
|
Poll::Ready(Err(x)) => return Poll::Ready(Err(crate::Error::JoinError(x))),
|
||||||
|
Poll::Pending => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Poll::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/tests.rs
Normal file
5
src/tests.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#![allow(clippy::all, clippy::missing_docs_in_private_items)]
|
||||||
|
|
||||||
|
mod bookmark;
|
||||||
|
mod tracks;
|
||||||
|
mod ui;
|
||||||
58
src/tests/bookmark.rs
Normal file
58
src/tests/bookmark.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod bookmark {
|
||||||
|
use crate::{bookmark::Bookmarks, tracks::Info};
|
||||||
|
|
||||||
|
fn test_info(path: &str, display: &str) -> Info {
|
||||||
|
Info {
|
||||||
|
path: path.into(),
|
||||||
|
display: display.into(),
|
||||||
|
width: display.len(),
|
||||||
|
duration: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_and_check() {
|
||||||
|
let mut bm = Bookmarks { entries: vec![] };
|
||||||
|
let info = test_info("p.mp3", "Nice Track");
|
||||||
|
|
||||||
|
// initially not bookmarked
|
||||||
|
assert!(!bm.bookmarked(&info));
|
||||||
|
|
||||||
|
// bookmark it
|
||||||
|
let added = bm.bookmark(&info).unwrap();
|
||||||
|
assert!(added);
|
||||||
|
assert!(bm.bookmarked(&info));
|
||||||
|
|
||||||
|
// un-bookmark it
|
||||||
|
let removed = bm.bookmark(&info).unwrap();
|
||||||
|
assert!(!removed);
|
||||||
|
assert!(!bm.bookmarked(&info));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_bookmarks() {
|
||||||
|
let mut bm = Bookmarks { entries: vec![] };
|
||||||
|
let info1 = test_info("track1.mp3", "Track One");
|
||||||
|
let info2 = test_info("track2.mp3", "Track Two");
|
||||||
|
|
||||||
|
bm.bookmark(&info1).unwrap();
|
||||||
|
bm.bookmark(&info2).unwrap();
|
||||||
|
|
||||||
|
assert!(bm.bookmarked(&info1));
|
||||||
|
assert!(bm.bookmarked(&info2));
|
||||||
|
assert_eq!(bm.entries.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_bookmark_removes() {
|
||||||
|
let mut bm = Bookmarks { entries: vec![] };
|
||||||
|
let info = test_info("x.mp3", "X");
|
||||||
|
|
||||||
|
bm.bookmark(&info).unwrap();
|
||||||
|
let is_added = bm.bookmark(&info).unwrap();
|
||||||
|
|
||||||
|
assert!(!is_added);
|
||||||
|
assert!(bm.entries.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/tests/tracks.rs
Normal file
185
src/tests/tracks.rs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod format {
|
||||||
|
use crate::tracks::format::name;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_all_numeric_name() {
|
||||||
|
let n = name("12345.mp3").unwrap();
|
||||||
|
assert_eq!(n, "12345");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_url() {
|
||||||
|
let n = name("lofi%20track.mp3").unwrap();
|
||||||
|
assert_eq!(n, "lofi track");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_extension_only() {
|
||||||
|
let n = name(".mp3").unwrap();
|
||||||
|
// Should handle edge case gracefully
|
||||||
|
assert!(!n.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod queued {
|
||||||
|
use crate::tracks::{format, Queued};
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_uses_custom_display() {
|
||||||
|
let q = Queued::new(
|
||||||
|
"path/to/file.mp3".into(),
|
||||||
|
Bytes::from_static(b"abc"),
|
||||||
|
Some("Shown".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(q.display, "Shown");
|
||||||
|
assert_eq!(q.path, "path/to/file.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_generates_display_if_none() {
|
||||||
|
let q = Queued::new(
|
||||||
|
"path/to/cool_track.mp3".into(),
|
||||||
|
Bytes::from_static(b"abc"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(q.display, format::name("path/to/cool_track.mp3").unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod info {
|
||||||
|
use crate::tracks::Info;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_entry_roundtrip() {
|
||||||
|
let info = Info {
|
||||||
|
path: "p.mp3".into(),
|
||||||
|
display: "Nice Track".into(),
|
||||||
|
width: 10,
|
||||||
|
duration: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(info.to_entry(), "p.mp3!Nice Track");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn width_counts_graphemes() {
|
||||||
|
// We cannot create a valid decoder for arbitrary bytes here, so test width through constructor logic directly.
|
||||||
|
let display = "a̐é"; // multiple-grapheme clusters
|
||||||
|
let width = display.graphemes(true).count();
|
||||||
|
|
||||||
|
let info = Info {
|
||||||
|
path: "x".into(),
|
||||||
|
display: display.into(),
|
||||||
|
width,
|
||||||
|
duration: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(info.width, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod decoded {
|
||||||
|
use crate::tracks::Queued;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decoded_fails_with_invalid_audio() {
|
||||||
|
let q = Queued::new(
|
||||||
|
"path.mp3".into(),
|
||||||
|
Bytes::from_static(b"not audio"),
|
||||||
|
Some("Name".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = q.decode();
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod list {
|
||||||
|
use crate::{downloader::Progress, tracks::List};
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_works() {
|
||||||
|
let text = "http://base/\ntrack1\ntrack2";
|
||||||
|
let list = List::new("test", text, None);
|
||||||
|
assert_eq!(list.header(), "http://base/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_path_parses_custom_display() {
|
||||||
|
let text = "http://x/\npath!Display";
|
||||||
|
let list = List::new("t", text, None);
|
||||||
|
|
||||||
|
let (p, d) = list.random_path(&mut fastrand::Rng::new());
|
||||||
|
assert_eq!(p, "path");
|
||||||
|
assert_eq!(d, Some("Display".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_path_no_display() {
|
||||||
|
let text = "http://x/\ntrackA";
|
||||||
|
let list = List::new("t", text, None);
|
||||||
|
|
||||||
|
let (p, d) = list.random_path(&mut fastrand::Rng::new());
|
||||||
|
assert_eq!(p, "trackA");
|
||||||
|
assert!(d.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_trims_lines() {
|
||||||
|
let text = "base\na \nb ";
|
||||||
|
let list = List::new("name", text, None);
|
||||||
|
|
||||||
|
assert_eq!(list.header(), "base");
|
||||||
|
assert_eq!(list.lines[1], "a");
|
||||||
|
assert_eq!(list.lines[2], "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_display_with_exclamation() {
|
||||||
|
let text = "http://base/\nfile.mp3!My Custom Name";
|
||||||
|
let list = List::new("t", text, None);
|
||||||
|
let (path, display) = list.random_path(&mut fastrand::Rng::new());
|
||||||
|
assert_eq!(path, "file.mp3");
|
||||||
|
assert_eq!(display, Some("My Custom Name".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_track() {
|
||||||
|
let text = "base\nonly_track.mp3";
|
||||||
|
let list = List::new("name", text, None);
|
||||||
|
let (path, _) = list.random_path(&mut fastrand::Rng::new());
|
||||||
|
assert_eq!(path, "only_track.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download() {
|
||||||
|
let text = "https://stream.chillhop.com/mp3/\n9476!Apple Juice";
|
||||||
|
let list = List::new("name", text, None);
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let track = list
|
||||||
|
.random(&client, Progress::new(), &mut fastrand::Rng::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(track.display, "Apple Juice");
|
||||||
|
assert_eq!(track.path, "https://stream.chillhop.com/mp3/9476");
|
||||||
|
assert_eq!(track.data.len(), 3150424);
|
||||||
|
|
||||||
|
let decoded = track.decode().unwrap();
|
||||||
|
assert_eq!(decoded.info.duration.unwrap().as_secs(), 143);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/tests/ui.rs
Normal file
226
src/tests/ui.rs
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
/* The lowfi UI:
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ loading │
|
||||||
|
│ [ ] 00:00/00:00 │
|
||||||
|
│ [s]kip [p]ause [q]uit │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod components {
|
||||||
|
use crate::ui::interface;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_works() {
|
||||||
|
let d = Duration::from_secs(62);
|
||||||
|
assert_eq!(interface::components::format_duration(&d), "01:02");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_zero() {
|
||||||
|
let d = Duration::from_secs(0);
|
||||||
|
assert_eq!(interface::components::format_duration(&d), "00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_hours_wrap() {
|
||||||
|
let d = Duration::from_secs(3661); // 1:01:01
|
||||||
|
assert_eq!(interface::components::format_duration(&d), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_contains_percentage() {
|
||||||
|
let s = interface::components::audio_bar(10, 0.5, "50%");
|
||||||
|
assert!(s.contains("50%"));
|
||||||
|
assert!(s.starts_with(" volume:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_muted_volume() {
|
||||||
|
let s = interface::components::audio_bar(8, 0.0, "0%");
|
||||||
|
assert!(s.contains("0%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_full_volume() {
|
||||||
|
let s = interface::components::audio_bar(10, 1.0, "100%");
|
||||||
|
assert!(s.contains("100%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_has_items() {
|
||||||
|
let s = interface::components::controls(30);
|
||||||
|
assert!(s.contains("[s]"));
|
||||||
|
assert!(s.contains("[p]"));
|
||||||
|
assert!(s.contains("[q]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod window {
|
||||||
|
use crate::ui::interface::Window;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_border_strings() {
|
||||||
|
let w = Window::new(10, false, false, false);
|
||||||
|
assert!(w.titlebar.content.starts_with('┌'));
|
||||||
|
assert!(w.statusbar.starts_with('└'));
|
||||||
|
|
||||||
|
let w2 = Window::new(5, true, false, false);
|
||||||
|
assert!(w2.titlebar.content.is_empty());
|
||||||
|
assert!(w2.statusbar.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sided(text: &str) -> String {
|
||||||
|
return format!("│ {text} │");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple() {
|
||||||
|
let w = Window::new(3, false, false, false);
|
||||||
|
let (render, height) = w.render(vec![String::from("abc")]).unwrap();
|
||||||
|
|
||||||
|
const MIDDLE: &str = "─────";
|
||||||
|
assert_eq!(format!("┌{MIDDLE}┐\n{}\n└{MIDDLE}┘", sided("abc")), render);
|
||||||
|
assert_eq!(height, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spaced() {
|
||||||
|
let w = Window::new(3, false, true, false);
|
||||||
|
let (render, height) = w
|
||||||
|
.render(vec![
|
||||||
|
String::from("abc"),
|
||||||
|
String::from(" b"),
|
||||||
|
String::from("c"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
const MIDDLE: &str = "─────";
|
||||||
|
assert_eq!(
|
||||||
|
format!(
|
||||||
|
"┌{MIDDLE}┐\n{}\n{}\n{}\n└{MIDDLE}┘",
|
||||||
|
sided("abc"),
|
||||||
|
sided(" b "),
|
||||||
|
sided("c "),
|
||||||
|
),
|
||||||
|
render
|
||||||
|
);
|
||||||
|
assert_eq!(height, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_width_window() {
|
||||||
|
let w = Window::new(0, false, false, false);
|
||||||
|
assert!(!w.titlebar.content.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod interface {
|
||||||
|
use crossterm::style::Stylize;
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
downloader::Progress,
|
||||||
|
player::Current,
|
||||||
|
tracks,
|
||||||
|
ui::{Interface, State},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loading() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
let mut state = State::initial(sink, String::from("test"));
|
||||||
|
let menu = Interface::default().menu(&mut state);
|
||||||
|
|
||||||
|
assert_eq!(menu[0], "loading ");
|
||||||
|
assert_eq!(menu[1], " [ ] 00:00/00:00 ");
|
||||||
|
assert_eq!(
|
||||||
|
menu[2],
|
||||||
|
format!(
|
||||||
|
"{}kip {}ause {}uit",
|
||||||
|
"[s]".bold(),
|
||||||
|
"[p]".bold(),
|
||||||
|
"[q]".bold()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn volume() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
sink.set_volume(0.5);
|
||||||
|
|
||||||
|
let mut state = State::initial(sink, String::from("test"));
|
||||||
|
state.volume_timer = Some(Instant::now());
|
||||||
|
|
||||||
|
let menu = Interface::default().menu(&mut state);
|
||||||
|
assert_eq!(menu[0], "loading ");
|
||||||
|
assert_eq!(menu[1], " volume: [///// ] 50% ");
|
||||||
|
assert_eq!(
|
||||||
|
menu[2],
|
||||||
|
format!(
|
||||||
|
"{}kip {}ause {}uit",
|
||||||
|
"[s]".bold(),
|
||||||
|
"[p]".bold(),
|
||||||
|
"[q]".bold()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn progress() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
Progress::new().set(0.5);
|
||||||
|
let mut state = State::initial(sink, String::from("test"));
|
||||||
|
state.current = Current::Loading(Some(Progress::new()));
|
||||||
|
|
||||||
|
let menu = Interface::default().menu(&mut state);
|
||||||
|
|
||||||
|
assert_eq!(menu[0], format!("loading {} ", "50%".bold()));
|
||||||
|
assert_eq!(menu[1], " [ ] 00:00/00:00 ");
|
||||||
|
assert_eq!(
|
||||||
|
menu[2],
|
||||||
|
format!(
|
||||||
|
"{}kip {}ause {}uit",
|
||||||
|
"[s]".bold(),
|
||||||
|
"[p]".bold(),
|
||||||
|
"[q]".bold()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
let track = tracks::Info {
|
||||||
|
path: "/path".to_owned(),
|
||||||
|
display: "Test Track".to_owned(),
|
||||||
|
width: 4 + 1 + 5,
|
||||||
|
duration: Some(Duration::from_secs(8)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = State::initial(sink, String::from("test"));
|
||||||
|
state.current = Current::Track(track.clone());
|
||||||
|
let menu = Interface::default().menu(&mut state);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
menu[0],
|
||||||
|
format!("playing {} ", track.display.bold())
|
||||||
|
);
|
||||||
|
assert_eq!(menu[1], " [ ] 00:00/00:08 ");
|
||||||
|
assert_eq!(
|
||||||
|
menu[2],
|
||||||
|
format!(
|
||||||
|
"{}kip {}ause {}uit",
|
||||||
|
"[s]".bold(),
|
||||||
|
"[p]".bold(),
|
||||||
|
"[q]".bold()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/tracks.rs
203
src/tracks.rs
@ -1,29 +1,91 @@
|
|||||||
//! Has all of the structs for managing the state
|
//! Has all of the structs for managing the state
|
||||||
//! of tracks, as well as downloading them &
|
//! of tracks, as well as downloading them & finding new ones.
|
||||||
//! finding new ones.
|
//!
|
||||||
|
//! There are several structs which represent the different stages
|
||||||
|
//! that go on in downloading and playing tracks. When first queued,
|
||||||
|
//! the downloader will return a [`Queued`] track.
|
||||||
|
//!
|
||||||
|
//! Then, when it's time to play the track, it is decoded into
|
||||||
|
//! a [`Decoded`] track, which includes all the information
|
||||||
|
//! in the form of [`Info`].
|
||||||
|
|
||||||
use std::{io::Cursor, time::Duration};
|
use std::{fmt::Debug, io::Cursor, time::Duration};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use eyre::OptionExt as _;
|
|
||||||
use inflector::Inflector as _;
|
|
||||||
use rodio::{Decoder, Source as _};
|
use rodio::{Decoder, Source as _};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation as _;
|
||||||
use url::form_urlencoded;
|
|
||||||
|
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
pub use list::List;
|
||||||
|
pub mod error;
|
||||||
|
pub mod format;
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
use crate::tracks::error::WithTrackContext as _;
|
||||||
|
|
||||||
/// Just a shorthand for a decoded [Bytes].
|
/// Just a shorthand for a decoded [Bytes].
|
||||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||||
|
|
||||||
|
/// Tracks which are still waiting in the queue, and can't be played yet.
|
||||||
|
///
|
||||||
|
/// This means that only the data & track name are included.
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
pub struct Queued {
|
||||||
|
/// Display name of the track.
|
||||||
|
pub display: String,
|
||||||
|
|
||||||
|
/// Full downloadable path/url of the track.
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
|
/// The raw data of the track, which is not decoded and
|
||||||
|
/// therefore much more memory efficient.
|
||||||
|
pub data: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for Queued {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Queued")
|
||||||
|
.field("display", &self.display)
|
||||||
|
.field("path", &self.path)
|
||||||
|
.field("data", &self.data.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Queued {
|
||||||
|
/// This will actually decode and format the track,
|
||||||
|
/// returning a [`DecodedTrack`] which can be played
|
||||||
|
/// and also has a duration & formatted name.
|
||||||
|
pub fn decode(self) -> Result<Decoded> {
|
||||||
|
Decoded::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new queued track.
|
||||||
|
pub fn new(path: String, data: Bytes, display: Option<String>) -> Result<Self> {
|
||||||
|
let display = match display {
|
||||||
|
None => self::format::name(&path)?,
|
||||||
|
Some(custom) => custom,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
display,
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The [`Info`] struct, which has the name and duration of a track.
|
/// The [`Info`] struct, which has the name and duration of a track.
|
||||||
///
|
///
|
||||||
/// This is not included in [Track] as the duration has to be acquired
|
/// This is not included in [Track] as the duration has to be acquired
|
||||||
/// from the decoded data and not from the raw data.
|
/// from the decoded data and not from the raw data.
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
pub struct Info {
|
pub struct Info {
|
||||||
|
/// The full downloadable path/url of the track.
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
/// This is a formatted name, so it doesn't include the full path.
|
/// This is a formatted name, so it doesn't include the full path.
|
||||||
pub name: String,
|
pub display: String,
|
||||||
|
|
||||||
/// This is the *actual* terminal width of the track name, used to make
|
/// This is the *actual* terminal width of the track name, used to make
|
||||||
/// the UI consistent.
|
/// the UI consistent.
|
||||||
@ -35,80 +97,27 @@ pub struct Info {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Info {
|
impl Info {
|
||||||
/// Decodes a URL string into normal UTF-8.
|
/// Converts the info back into a full track list entry.
|
||||||
fn decode_url(text: &str) -> String {
|
pub fn to_entry(&self) -> String {
|
||||||
#[expect(
|
let mut entry = self.path.clone();
|
||||||
clippy::tuple_array_conversions,
|
entry.push('!');
|
||||||
reason = "the tuple contains smart pointers, so it's not really practical to use `into()`"
|
entry.push_str(&self.display);
|
||||||
)]
|
|
||||||
form_urlencoded::parse(text.as_bytes())
|
entry
|
||||||
.map(|(key, val)| [key, val].concat())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a name with [Inflector].
|
/// Creates a new [`Info`] from decoded data & the queued track.
|
||||||
/// This will also strip the first few numbers that are
|
pub fn new(decoded: &DecodedData, path: String, display: String) -> Result<Self> {
|
||||||
/// usually present on most lofi tracks.
|
|
||||||
fn format_name(name: &str) -> eyre::Result<String> {
|
|
||||||
let split = name
|
|
||||||
.split('/')
|
|
||||||
.last()
|
|
||||||
.ok_or_eyre("split is never supposed to return nothing")?;
|
|
||||||
|
|
||||||
let stripped = split.strip_suffix(".mp3").unwrap_or(split);
|
|
||||||
let formatted = Self::decode_url(stripped)
|
|
||||||
.to_lowercase()
|
|
||||||
.to_title_case()
|
|
||||||
// Inflector doesn't like contractions...
|
|
||||||
// Replaces a few very common ones.
|
|
||||||
// TODO: Properly handle these.
|
|
||||||
.replace(" S ", "'s ")
|
|
||||||
.replace(" T ", "'t ")
|
|
||||||
.replace(" D ", "'d ")
|
|
||||||
.replace(" Ve ", "'ve ")
|
|
||||||
.replace(" Ll ", "'ll ")
|
|
||||||
.replace(" Re ", "'re ")
|
|
||||||
.replace(" M ", "'m ");
|
|
||||||
|
|
||||||
// This is incremented for each digit in front of the song name.
|
|
||||||
let mut skip = 0;
|
|
||||||
|
|
||||||
for character in formatted.as_bytes() {
|
|
||||||
if character.is_ascii_digit() {
|
|
||||||
skip += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the entire name of the track is a number, then just return it.
|
|
||||||
if skip == formatted.len() {
|
|
||||||
Ok(formatted)
|
|
||||||
} else {
|
|
||||||
#[expect(
|
|
||||||
clippy::string_slice,
|
|
||||||
reason = "We've already checked before that the bound is at an ASCII digit."
|
|
||||||
)]
|
|
||||||
Ok(String::from(&formatted[skip..]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded track data.
|
|
||||||
pub fn new(name: TrackName, decoded: &DecodedData) -> eyre::Result<Self> {
|
|
||||||
let name = match name {
|
|
||||||
TrackName::Raw(raw) => Self::format_name(&raw)?,
|
|
||||||
TrackName::Formatted(formatted) => formatted,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
duration: decoded.total_duration(),
|
duration: decoded.total_duration(),
|
||||||
width: name.graphemes(true).count(),
|
width: display.graphemes(true).count(),
|
||||||
name,
|
path,
|
||||||
|
display,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This struct is seperate from [Track] since it is generated lazily from
|
/// This struct is separate from [Track] since it is generated lazily from
|
||||||
/// a track, and not when the track is first downloaded.
|
/// a track, and not when the track is first downloaded.
|
||||||
pub struct Decoded {
|
pub struct Decoded {
|
||||||
/// Has both the formatted name and some information from the decoded data.
|
/// Has both the formatted name and some information from the decoded data.
|
||||||
@ -120,44 +129,16 @@ pub struct Decoded {
|
|||||||
|
|
||||||
impl Decoded {
|
impl Decoded {
|
||||||
/// Creates a new track.
|
/// Creates a new track.
|
||||||
/// This is equivalent to [`Track::decode`].
|
/// This is equivalent to [`QueuedTrack::decode`].
|
||||||
pub fn new(track: Track) -> eyre::Result<Self> {
|
pub fn new(track: Queued) -> Result<Self> {
|
||||||
let data = Decoder::new(Cursor::new(track.data))?;
|
let (path, display) = (track.path.clone(), track.display.clone());
|
||||||
let info = Info::new(track.name, &data)?;
|
let data = Decoder::builder()
|
||||||
|
.with_byte_len(track.data.len().try_into()?)
|
||||||
|
.with_data(Cursor::new(track.data))
|
||||||
|
.build()
|
||||||
|
.track(track.display)?;
|
||||||
|
|
||||||
|
let info = Info::new(&data, path, display)?;
|
||||||
Ok(Self { info, data })
|
Ok(Self { info, data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Specifies a track's name, and specifically,
|
|
||||||
/// whether it has already been formatted or if it
|
|
||||||
/// is still in it's raw form.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TrackName {
|
|
||||||
/// Pulled straight from the list,
|
|
||||||
/// with no splitting done at all.
|
|
||||||
Raw(String),
|
|
||||||
|
|
||||||
/// If a track has a custom specified name
|
|
||||||
/// in the list, then it should be defined with this variant.
|
|
||||||
Formatted(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The main track struct, which only includes data & the track name.
|
|
||||||
pub struct Track {
|
|
||||||
/// Name of the track.
|
|
||||||
pub name: TrackName,
|
|
||||||
|
|
||||||
/// The raw data of the track, which is not decoded and
|
|
||||||
/// therefore much more memory efficient.
|
|
||||||
pub data: Bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Track {
|
|
||||||
/// This will actually decode and format the track,
|
|
||||||
/// returning a [`DecodedTrack`] which can be played
|
|
||||||
/// and also has a duration & formatted name.
|
|
||||||
pub fn decode(self) -> eyre::Result<Decoded> {
|
|
||||||
Decoded::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
88
src/tracks/error.rs
Normal file
88
src/tracks/error.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Kind {
|
||||||
|
#[error("unable to decode: {0}")]
|
||||||
|
Decode(#[from] rodio::decoder::DecoderError),
|
||||||
|
|
||||||
|
#[error("invalid name")]
|
||||||
|
InvalidName,
|
||||||
|
|
||||||
|
#[error("invalid file path")]
|
||||||
|
InvalidPath,
|
||||||
|
|
||||||
|
#[error("unknown target track length")]
|
||||||
|
UnknownLength,
|
||||||
|
|
||||||
|
#[error("unable to read file: {0}")]
|
||||||
|
File(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("unable to fetch data: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("couldn't handle integer track length: {0}")]
|
||||||
|
Integer(#[from] std::num::TryFromIntError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{kind}{}", self.track.as_ref().map_or(String::new(), |t| format!(" (track: {t:?}) ")))]
|
||||||
|
pub struct Error {
|
||||||
|
pub track: Option<String>,
|
||||||
|
pub kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn timeout(&self) -> bool {
|
||||||
|
if let Kind::Request(x) = &self.kind {
|
||||||
|
x.is_timeout()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, E> From<(T, E)> for Error
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
Kind: From<E>,
|
||||||
|
{
|
||||||
|
fn from((track, err): (T, E)) -> Self {
|
||||||
|
Self {
|
||||||
|
track: Some(track.into()),
|
||||||
|
kind: Kind::from(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> From<E> for Error
|
||||||
|
where
|
||||||
|
Kind: From<E>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self {
|
||||||
|
track: None,
|
||||||
|
kind: Kind::from(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WithTrackContext<T> {
|
||||||
|
fn track(self, name: impl Into<String>) -> Result<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, E> WithTrackContext<T> for std::result::Result<T, E>
|
||||||
|
where
|
||||||
|
(String, E): Into<Error>,
|
||||||
|
E: Into<Kind>,
|
||||||
|
{
|
||||||
|
fn track(self, name: impl Into<String>) -> std::result::Result<T, Error> {
|
||||||
|
self.map_err(|e| {
|
||||||
|
let error = match e.into() {
|
||||||
|
Kind::Request(e) => Kind::Request(e.without_url()),
|
||||||
|
e => e,
|
||||||
|
};
|
||||||
|
|
||||||
|
(name.into(), error).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/tracks/format.rs
Normal file
54
src/tracks/format.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::error::WithTrackContext as _;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
/// Decodes a URL string into normal UTF-8.
|
||||||
|
fn decode_url(text: &str) -> String {
|
||||||
|
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
||||||
|
#[allow(clippy::tuple_array_conversions)]
|
||||||
|
form_urlencoded::parse(text.as_bytes())
|
||||||
|
.map(|(key, val)| [key, val].concat())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a name with [`convert_case`].
|
||||||
|
///
|
||||||
|
/// This will also strip the first few numbers that are
|
||||||
|
/// usually present on most lofi tracks and do some other
|
||||||
|
/// formatting operations.
|
||||||
|
pub fn name(name: &str) -> super::Result<String> {
|
||||||
|
let path = Path::new(name);
|
||||||
|
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|x| x.to_str())
|
||||||
|
.ok_or(super::error::Kind::InvalidName)
|
||||||
|
.track(name)?;
|
||||||
|
|
||||||
|
let name = decode_url(name);
|
||||||
|
|
||||||
|
// This is incremented for each digit in front of the song name.
|
||||||
|
let mut skip = 0;
|
||||||
|
|
||||||
|
for character in name.as_bytes() {
|
||||||
|
if character.is_ascii_digit()
|
||||||
|
|| *character == b'.'
|
||||||
|
|| *character == b')'
|
||||||
|
|| *character == b'('
|
||||||
|
{
|
||||||
|
skip += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the entire name of the track is a number, then just return it.
|
||||||
|
if skip == name.len() {
|
||||||
|
Ok(name.trim().to_owned())
|
||||||
|
} else {
|
||||||
|
// We've already checked before that the bound is at an ASCII digit.
|
||||||
|
#[allow(clippy::string_slice)]
|
||||||
|
Ok(String::from(name[skip..].trim()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,23 @@
|
|||||||
//! The module containing all of the logic behind track lists,
|
//! The module containing all of the logic behind track lists,
|
||||||
//! as well as obtaining track names & downloading the raw mp3 data.
|
//! as well as obtaining track names & downloading the raw audio data
|
||||||
|
|
||||||
use bytes::Bytes;
|
use std::cmp::min;
|
||||||
use eyre::OptionExt as _;
|
|
||||||
use rand::Rng as _;
|
use bytes::{BufMut as _, Bytes, BytesMut};
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use super::Track;
|
use crate::{
|
||||||
|
data_dir,
|
||||||
|
downloader::Progress,
|
||||||
|
tracks::{
|
||||||
|
self,
|
||||||
|
error::{self, WithTrackContext as _},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Queued;
|
||||||
|
|
||||||
/// Represents a list of tracks that can be played.
|
/// Represents a list of tracks that can be played.
|
||||||
///
|
///
|
||||||
@ -15,17 +25,21 @@ use super::Track;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct List {
|
pub struct List {
|
||||||
/// The "name" of the list, usually derived from a filename.
|
/// The "name" of the list, usually derived from a filename.
|
||||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
#[allow(dead_code)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
/// Just the raw file, but seperated by `/n` (newlines).
|
/// Just the raw file, but seperated by `/n` (newlines).
|
||||||
/// `lines[0]` is the base, with the rest being tracks.
|
/// `lines[0]` is the base/heaeder, with the rest being tracks.
|
||||||
lines: Vec<String>,
|
pub lines: Vec<String>,
|
||||||
|
|
||||||
|
/// The file path which the list was read from.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl List {
|
impl List {
|
||||||
/// Gets the base URL of the [List].
|
/// Gets the base URL of the [List].
|
||||||
pub fn base(&self) -> &str {
|
pub fn header(&self) -> &str {
|
||||||
self.lines[0].trim()
|
self.lines[0].trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,13 +47,13 @@ impl List {
|
|||||||
///
|
///
|
||||||
/// The second value in the tuple specifies whether the
|
/// The second value in the tuple specifies whether the
|
||||||
/// track has a custom display name.
|
/// track has a custom display name.
|
||||||
fn random_path(&self) -> (String, Option<String>) {
|
pub fn random_path(&self, rng: &mut fastrand::Rng) -> (String, Option<String>) {
|
||||||
// We're getting from 1 here, since the base is at `self.lines[0]`.
|
// We're getting from 1 here, since the base is at `self.lines[0]`.
|
||||||
//
|
//
|
||||||
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
|
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
|
||||||
// how rust vectors work, since it is slower to drain only a single element from
|
// how rust vectors work, since it is slower to drain only a single element from
|
||||||
// the start, so it's faster to just keep it in & work around it.
|
// the start, so it's faster to just keep it in & work around it.
|
||||||
let random = rand::thread_rng().gen_range(1..self.lines.len());
|
let random = rng.usize(1..self.lines.len());
|
||||||
let line = self.lines[random].clone();
|
let line = self.lines[random].clone();
|
||||||
|
|
||||||
if let Some((first, second)) = line.split_once('!') {
|
if let Some((first, second)) = line.split_once('!') {
|
||||||
@ -50,84 +64,125 @@ impl List {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a raw track, but doesn't decode it.
|
/// Downloads a raw track, but doesn't decode it.
|
||||||
async fn download(&self, track: &str, client: &Client) -> (eyre::Result<Bytes>, bool) {
|
pub(crate) async fn download(
|
||||||
|
&self,
|
||||||
|
track: &str,
|
||||||
|
client: &Client,
|
||||||
|
progress: Option<Progress>,
|
||||||
|
) -> tracks::Result<(Bytes, String)> {
|
||||||
// If the track has a protocol, then we should ignore the base for it.
|
// If the track has a protocol, then we should ignore the base for it.
|
||||||
let url = if track.contains("://") {
|
let path = if track.contains("://") {
|
||||||
track.to_owned()
|
track.to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}", self.base(), track)
|
format!("{}{}", self.header(), track)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (timeout, data) = if let Some(x) = url.strip_prefix("file://") {
|
let data: Bytes = if let Some(x) = path.strip_prefix("file://") {
|
||||||
let result = tokio::fs::read(x).await.unwrap();
|
let path = if x.starts_with('~') {
|
||||||
(false, Ok(result.into()))
|
let home_path = dirs::home_dir()
|
||||||
|
.ok_or(error::Kind::InvalidPath)
|
||||||
|
.track(track)?;
|
||||||
|
let home = home_path
|
||||||
|
.to_str()
|
||||||
|
.ok_or(error::Kind::InvalidPath)
|
||||||
|
.track(track)?;
|
||||||
|
|
||||||
|
x.replace('~', home)
|
||||||
|
} else {
|
||||||
|
x.to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::fs::read(path.clone()).await.track(x)?;
|
||||||
|
result.into()
|
||||||
} else {
|
} else {
|
||||||
let response = client.get(url).send().await;
|
let response = client.get(path.clone()).send().await.track(track)?;
|
||||||
|
let Some(progress) = progress else {
|
||||||
|
let bytes = response.bytes().await.track(track)?;
|
||||||
|
return Ok((bytes, path));
|
||||||
|
};
|
||||||
|
|
||||||
match response {
|
let total = response
|
||||||
Ok(x) => (false, x.bytes().await),
|
.content_length()
|
||||||
Err(x) => (x.is_timeout(), Err(x)),
|
.ok_or(error::Kind::UnknownLength)
|
||||||
|
.track(track)?;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut bytes = BytesMut::new();
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let chunk = item.track(track)?;
|
||||||
|
downloaded = min(downloaded + (chunk.len() as u64), total);
|
||||||
|
progress.set(downloaded as f32 / total as f32);
|
||||||
|
|
||||||
|
bytes.put(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bytes.into()
|
||||||
};
|
};
|
||||||
|
|
||||||
(data.map_err(|x| eyre::eyre!(x)), timeout)
|
Ok((data, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches and downloads a random track from the [List].
|
/// Fetches and downloads a random track from the [List].
|
||||||
pub async fn random(&self, client: &Client) -> (eyre::Result<Track>, bool) {
|
///
|
||||||
let (path, custom_name) = self.random_path();
|
/// The Result's error is a bool, which is true if a timeout error occurred,
|
||||||
let (data, timeout) = self.download(&path, client).await;
|
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
|
||||||
|
pub async fn random(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
progress: Progress,
|
||||||
|
rng: &mut fastrand::Rng,
|
||||||
|
) -> tracks::Result<Queued> {
|
||||||
|
let (path, display) = self.random_path(rng);
|
||||||
|
let (data, path) = self.download(&path, client, Some(progress)).await?;
|
||||||
|
|
||||||
let name = custom_name.map_or(super::TrackName::Raw(path), |formatted| {
|
Queued::new(path, data, display)
|
||||||
super::TrackName::Formatted(formatted)
|
|
||||||
});
|
|
||||||
|
|
||||||
let track = match data {
|
|
||||||
Ok(x) => Ok(Track { name, data: x }),
|
|
||||||
Err(x) => Err(x),
|
|
||||||
};
|
|
||||||
|
|
||||||
(track, timeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses text into a [List].
|
/// Parses text into a [List].
|
||||||
pub fn new(name: &str, text: &str) -> Self {
|
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
|
||||||
let lines: Vec<String> = text
|
let lines: Vec<String> = text
|
||||||
.trim()
|
.trim_end()
|
||||||
.lines()
|
.lines()
|
||||||
.map(|x| x.trim_end().to_owned())
|
.map(|x| x.trim_end().to_owned())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
lines,
|
lines,
|
||||||
|
path: path.map(ToOwned::to_owned),
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a [List] from the filesystem using the CLI argument provided.
|
/// Reads a [List] from the filesystem using the CLI argument provided.
|
||||||
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
|
pub async fn load(tracks: &str) -> tracks::Result<Self> {
|
||||||
if let Some(arg) = tracks {
|
if tracks == "chillhop" {
|
||||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
return Ok(Self::new(
|
||||||
let name = dirs::data_dir()
|
"chillhop",
|
||||||
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")?
|
include_str!("../../data/chillhop.txt"),
|
||||||
.join("lowfi")
|
None,
|
||||||
.join(format!("{arg}.txt"));
|
));
|
||||||
|
|
||||||
let name = if name.exists() { name } else { arg.into() };
|
|
||||||
|
|
||||||
let raw = fs::read_to_string(name.clone()).await?;
|
|
||||||
|
|
||||||
let name = name
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|x| x.to_str())
|
|
||||||
.ok_or_eyre("invalid track path")?;
|
|
||||||
|
|
||||||
Ok(Self::new(name, &raw))
|
|
||||||
} else {
|
|
||||||
Ok(Self::new(
|
|
||||||
"lofigirl",
|
|
||||||
include_str!("../../data/lofigirl.txt"),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
||||||
|
let path = data_dir()
|
||||||
|
.map_err(|_| error::Kind::InvalidPath)?
|
||||||
|
.join(format!("{tracks}.txt"));
|
||||||
|
let path = if path.exists() { path } else { tracks.into() };
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(path.clone()).await?;
|
||||||
|
|
||||||
|
// Get rid of special noheader case for tracklists without a header.
|
||||||
|
let raw = raw
|
||||||
|
.strip_prefix("noheader")
|
||||||
|
.map_or_else(|| raw.as_ref(), |stripped| stripped);
|
||||||
|
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|x| x.to_str())
|
||||||
|
.ok_or(tracks::error::Kind::InvalidName)
|
||||||
|
.track(tracks)?;
|
||||||
|
|
||||||
|
Ok(Self::new(name, raw, path.to_str()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/ui.rs
Normal file
157
src/ui.rs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
//! Everything which has to do with the user interface, including both input and output.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::player::Current;
|
||||||
|
use tokio::{sync::broadcast, time::Instant};
|
||||||
|
|
||||||
|
pub mod environment;
|
||||||
|
pub mod init;
|
||||||
|
pub use environment::Environment;
|
||||||
|
pub mod input;
|
||||||
|
pub mod interface;
|
||||||
|
pub use interface::Interface;
|
||||||
|
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
pub mod mpris;
|
||||||
|
|
||||||
|
/// Shorthand for a [`Result`] with a [`ui::Error`].
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// The error type for the UI, which is used to handle errors
|
||||||
|
/// that occur while drawing the UI or handling input.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("unable to convert number: {0}")]
|
||||||
|
Conversion(#[from] std::num::TryFromIntError),
|
||||||
|
|
||||||
|
#[error("unable to write output: {0}")]
|
||||||
|
Write(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("sending signal message to backend from ui failed: {0}")]
|
||||||
|
SignalSend(#[from] tokio::sync::mpsc::error::SendError<crate::Message>),
|
||||||
|
|
||||||
|
#[error("sharing state between backend and frontend failed: {0}")]
|
||||||
|
StateSend(#[from] tokio::sync::broadcast::error::SendError<Update>),
|
||||||
|
|
||||||
|
#[error("you can't disable the UI without MPRIS!")]
|
||||||
|
RejectedDisable,
|
||||||
|
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
#[error("mpris bus error: {0}")]
|
||||||
|
ZBus(#[from] mpris_server::zbus::Error),
|
||||||
|
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
#[error("mpris fdo (zbus interface) error: {0}")]
|
||||||
|
Fdo(#[from] mpris_server::zbus::fdo::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI state, which is all of the information that
|
||||||
|
/// the user interface needs to display to the user.
|
||||||
|
///
|
||||||
|
/// It should be noted that this is also used by MPRIS to keep
|
||||||
|
/// track of state.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct State {
|
||||||
|
/// The audio sink.
|
||||||
|
pub sink: Arc<rodio::Sink>,
|
||||||
|
|
||||||
|
/// The current track, which is updated by way of an [`Update`].
|
||||||
|
pub current: Current,
|
||||||
|
|
||||||
|
/// Whether the current track is bookmarked.
|
||||||
|
pub bookmarked: bool,
|
||||||
|
|
||||||
|
/// The timer, which is used when the user changes volume to briefly display it.
|
||||||
|
pub(crate) volume_timer: Option<Instant>,
|
||||||
|
|
||||||
|
/// The name of the playing tracklist, mainly for MPRIS.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
tracklist: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
/// Creates an initial UI state.
|
||||||
|
pub fn initial(sink: Arc<rodio::Sink>, list: String) -> Self {
|
||||||
|
Self {
|
||||||
|
sink,
|
||||||
|
tracklist: list,
|
||||||
|
current: Current::default(),
|
||||||
|
bookmarked: false,
|
||||||
|
volume_timer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes care of small updates, like resetting the volume timer.
|
||||||
|
pub fn tick(&mut self) {
|
||||||
|
let expired = |timer: Instant| timer.elapsed() > std::time::Duration::from_secs(1);
|
||||||
|
if self.volume_timer.is_some_and(expired) {
|
||||||
|
self.volume_timer = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UI update sent out by the main player thread, which may
|
||||||
|
/// not be immediately applied by the UI.
|
||||||
|
///
|
||||||
|
/// This corresponds to user actions, like bookmarking a track,
|
||||||
|
/// skipping, or changing the volume. The difference is that it also
|
||||||
|
/// contains the new information about the track.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Update {
|
||||||
|
Track(Current),
|
||||||
|
Bookmarked(bool),
|
||||||
|
Volume,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI handle for controlling the state of the UI, as well as
|
||||||
|
/// updating MPRIS information and other small interfacing tasks.
|
||||||
|
pub struct Handle {
|
||||||
|
/// Broadcast channel used to send UI updates.
|
||||||
|
updater: broadcast::Sender<Update>,
|
||||||
|
|
||||||
|
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
pub mpris: mpris::Server,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
/// Sends a `ui::Update` to the broadcast channel.
|
||||||
|
pub fn update(&mut self, update: Update) -> crate::Result<()> {
|
||||||
|
self.updater.send(update)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main UI process, which will both render the UI to the terminal
|
||||||
|
/// and also update state.
|
||||||
|
///
|
||||||
|
/// It does both of these things at a fixed interval, due to things
|
||||||
|
/// like the track duration changing too frequently.
|
||||||
|
///
|
||||||
|
/// `rx` is the receiver for state updates, `state` the initial state,
|
||||||
|
/// and `params` specifies aesthetic options that are specified by the user.
|
||||||
|
pub async fn run(
|
||||||
|
mut updater: broadcast::Receiver<Update>,
|
||||||
|
mut state: State,
|
||||||
|
params: interface::Params,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut interface = Interface::new(params)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(message) = updater.try_recv() {
|
||||||
|
match message {
|
||||||
|
Update::Track(track) => state.current = track,
|
||||||
|
Update::Bookmarked(bookmarked) => state.bookmarked = bookmarked,
|
||||||
|
Update::Volume => state.volume_timer = Some(Instant::now()),
|
||||||
|
Update::Quit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface.draw(&state).await?;
|
||||||
|
state.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
96
src/ui/environment.rs
Normal file
96
src/ui/environment.rs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
//! Contains the initialization and other handling of the terminal environment.
|
||||||
|
|
||||||
|
use std::{io::stdout, panic};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
cursor::{Hide, MoveTo, Show},
|
||||||
|
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
||||||
|
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Represents the terminal environment, and is used to properly
|
||||||
|
/// initialize and clean up the terminal.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Environment {
|
||||||
|
/// Whether keyboard enhancements are enabled.
|
||||||
|
enhancement: bool,
|
||||||
|
|
||||||
|
/// Whether the terminal is in an alternate screen or not.
|
||||||
|
alternate: bool,
|
||||||
|
|
||||||
|
/// Whether the UI is actually enabled at all.
|
||||||
|
/// This will effectively make the environment just do nothing.
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Environment {
|
||||||
|
/// This prepares the terminal, returning an [Environment] helpful
|
||||||
|
/// for cleaning up afterwards.
|
||||||
|
pub fn ready(args: &crate::Args) -> super::Result<Self> {
|
||||||
|
let enabled = !crate::env("LOWFI_DISABLE_UI");
|
||||||
|
if !enabled {
|
||||||
|
return Ok(Self {
|
||||||
|
enhancement: false,
|
||||||
|
alternate: args.alternate,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lock = stdout().lock();
|
||||||
|
|
||||||
|
crossterm::execute!(lock, Hide)?;
|
||||||
|
if args.alternate {
|
||||||
|
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
|
||||||
|
let enhancement = terminal::supports_keyboard_enhancement().unwrap_or_default();
|
||||||
|
if enhancement {
|
||||||
|
crossterm::execute!(
|
||||||
|
lock,
|
||||||
|
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let environment = Self {
|
||||||
|
enabled,
|
||||||
|
enhancement,
|
||||||
|
alternate: args.alternate,
|
||||||
|
};
|
||||||
|
|
||||||
|
panic::set_hook(Box::new(move |info| {
|
||||||
|
let _ = environment.cleanup(false);
|
||||||
|
eprintln!("panic: {info}");
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the information collected from initialization to safely close down
|
||||||
|
/// the terminal & restore it to it's previous state.
|
||||||
|
pub fn cleanup(&self, elegant: bool) -> super::Result<()> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lock = stdout().lock();
|
||||||
|
|
||||||
|
if self.alternate {
|
||||||
|
crossterm::execute!(lock, LeaveAlternateScreen)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?;
|
||||||
|
|
||||||
|
if self.enhancement {
|
||||||
|
crossterm::execute!(lock, PopKeyboardEnhancementFlags)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal::disable_raw_mode()?;
|
||||||
|
if elegant {
|
||||||
|
eprintln!("bye! :)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ui/init.rs
Normal file
27
src/ui/init.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//! Contains the code for initializing the UI and creating a [`ui::Handle`].
|
||||||
|
|
||||||
|
use crate::ui::{self, input, interface};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
impl crate::Tasks {
|
||||||
|
/// Initializes the UI itself, along with all of the tasks that are related to it.
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
|
pub async fn ui(&mut self, state: ui::State, args: &crate::Args) -> crate::Result<ui::Handle> {
|
||||||
|
let (utx, urx) = broadcast::channel(8);
|
||||||
|
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?;
|
||||||
|
|
||||||
|
let params = interface::Params::try_from(args)?;
|
||||||
|
if params.enabled {
|
||||||
|
self.spawn(ui::run(urx, state, params));
|
||||||
|
self.spawn(input::listen(self.tx()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ui::Handle {
|
||||||
|
updater: utx,
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
mpris,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/ui/input.rs
Normal file
67
src/ui/input.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//! Responsible for specifically receiving terminal input
|
||||||
|
//! using [`crossterm`].
|
||||||
|
|
||||||
|
use crate::Message;
|
||||||
|
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use futures_util::{FutureExt, StreamExt};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
/// Starts the listener to receive input from the terminal for various events.
|
||||||
|
pub async fn listen(sender: Sender<Message>) -> super::Result<()> {
|
||||||
|
let mut reader = EventStream::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(Ok(event::Event::Key(event))) = reader.next().fuse().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if event.kind == KeyEventKind::Release {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = match event.code {
|
||||||
|
// Arrow key volume controls.
|
||||||
|
KeyCode::Up => Message::ChangeVolume(0.1),
|
||||||
|
KeyCode::Right => Message::ChangeVolume(0.01),
|
||||||
|
KeyCode::Down => Message::ChangeVolume(-0.1),
|
||||||
|
KeyCode::Left => Message::ChangeVolume(-0.01),
|
||||||
|
KeyCode::Char(character) => match character.to_ascii_lowercase() {
|
||||||
|
// Ctrl+C
|
||||||
|
'c' if event.modifiers == KeyModifiers::CONTROL => Message::Quit,
|
||||||
|
|
||||||
|
// Quit
|
||||||
|
'q' => Message::Quit,
|
||||||
|
|
||||||
|
// Skip/Next
|
||||||
|
's' | 'n' | 'l' => Message::Next,
|
||||||
|
|
||||||
|
// Pause
|
||||||
|
'p' | ' ' => Message::PlayPause,
|
||||||
|
|
||||||
|
// Volume up & down
|
||||||
|
'+' | '=' | 'k' => Message::ChangeVolume(0.1),
|
||||||
|
'-' | '_' | 'j' => Message::ChangeVolume(-0.1),
|
||||||
|
|
||||||
|
// Bookmark
|
||||||
|
'b' => Message::Bookmark,
|
||||||
|
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
// Media keys
|
||||||
|
KeyCode::Media(media) => match media {
|
||||||
|
event::MediaKeyCode::Pause
|
||||||
|
| event::MediaKeyCode::Play
|
||||||
|
| event::MediaKeyCode::PlayPause => Message::PlayPause,
|
||||||
|
event::MediaKeyCode::Stop => Message::Pause,
|
||||||
|
event::MediaKeyCode::TrackNext => Message::Next,
|
||||||
|
event::MediaKeyCode::LowerVolume => Message::ChangeVolume(-0.1),
|
||||||
|
event::MediaKeyCode::RaiseVolume => Message::ChangeVolume(0.1),
|
||||||
|
event::MediaKeyCode::MuteVolume => Message::ChangeVolume(-1.0),
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
sender.send(messages).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/ui/interface.rs
Normal file
156
src/ui/interface.rs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
//! Responsible for the actual graphical interface of lowfi.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ui::{self, State},
|
||||||
|
Args,
|
||||||
|
};
|
||||||
|
use std::{io::stdout, time::Duration};
|
||||||
|
|
||||||
|
pub mod clock;
|
||||||
|
pub mod components;
|
||||||
|
pub mod titlebar;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
|
pub use clock::Clock;
|
||||||
|
pub use titlebar::TitleBar;
|
||||||
|
pub use window::Window;
|
||||||
|
|
||||||
|
/// UI-specific parameters and options.
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct Params {
|
||||||
|
/// Whether to include borders.
|
||||||
|
pub borderless: bool,
|
||||||
|
|
||||||
|
/// Whether to include the bottom control bar.
|
||||||
|
pub minimalist: bool,
|
||||||
|
|
||||||
|
/// Whether the visual part of the UI should be enabled.
|
||||||
|
/// This only applies if the MPRIS feature is enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Whether to include the clock on the top bar.
|
||||||
|
pub clock: bool,
|
||||||
|
|
||||||
|
/// The full inner width of the terminal window.
|
||||||
|
pub(crate) width: usize,
|
||||||
|
|
||||||
|
/// The total delta between frames, which takes into account
|
||||||
|
/// the time it takes to actually render each frame.
|
||||||
|
///
|
||||||
|
/// Derived from the FPS.
|
||||||
|
pub delta: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Params {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
borderless: false,
|
||||||
|
minimalist: false,
|
||||||
|
enabled: true,
|
||||||
|
clock: false,
|
||||||
|
width: 27,
|
||||||
|
delta: Duration::from_secs_f32(1.0 / 12.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&Args> for Params {
|
||||||
|
type Error = ui::Error;
|
||||||
|
|
||||||
|
fn try_from(args: &Args) -> ui::Result<Self> {
|
||||||
|
let delta = 1.0 / f32::from(args.fps);
|
||||||
|
let delta = Duration::from_secs_f32(delta);
|
||||||
|
|
||||||
|
let disabled = crate::env("LOWFI_DISABLE_UI");
|
||||||
|
if disabled && !cfg!(feature = "mpris") {
|
||||||
|
return Err(ui::Error::RejectedDisable);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
delta,
|
||||||
|
enabled: !disabled,
|
||||||
|
clock: args.clock,
|
||||||
|
width: 21 + args.width.min(32) * 2,
|
||||||
|
minimalist: args.minimalist,
|
||||||
|
borderless: args.borderless,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All of the state related to the interface itself,
|
||||||
|
/// which is displayed each frame to the standard output.
|
||||||
|
pub struct Interface {
|
||||||
|
/// The [`Window`] to render to.
|
||||||
|
pub(crate) window: Window,
|
||||||
|
|
||||||
|
/// The interval to wait between frames.
|
||||||
|
interval: tokio::time::Interval,
|
||||||
|
|
||||||
|
/// The visual clock, which is [`None`] if it has
|
||||||
|
/// been disabled by the [`Params`].
|
||||||
|
clock: Option<Clock>,
|
||||||
|
|
||||||
|
/// The interface parameters that control smaller
|
||||||
|
/// aesthetic features and options.
|
||||||
|
params: Params,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Interface {
|
||||||
|
#[inline]
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(Params::default()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interface {
|
||||||
|
/// Creates a new interface.
|
||||||
|
pub fn new(params: Params) -> ui::Result<Self> {
|
||||||
|
let mut window = Window::new(params.width, params.borderless, false, true);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
clock: params.clock.then(|| Clock::new(&mut window)),
|
||||||
|
interval: tokio::time::interval(params.delta),
|
||||||
|
window,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a full "menu" from the [`ui::State`], which can be
|
||||||
|
/// easily put into a window for display.
|
||||||
|
///
|
||||||
|
/// The menu really is just a [`Vec`] of the different components,
|
||||||
|
/// with padding already added.
|
||||||
|
pub(crate) fn menu(&self, state: &State) -> Vec<String> {
|
||||||
|
let action = components::action(state, self.params.width);
|
||||||
|
|
||||||
|
let middle = if state.volume_timer.is_some() {
|
||||||
|
let volume = state.sink.volume();
|
||||||
|
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
||||||
|
|
||||||
|
components::audio_bar(self.params.width - 17, volume, &percentage)
|
||||||
|
} else {
|
||||||
|
components::progress_bar(state, self.params.width - 16)
|
||||||
|
};
|
||||||
|
|
||||||
|
let controls = components::controls(self.params.width);
|
||||||
|
if self.params.minimalist {
|
||||||
|
vec![action, middle]
|
||||||
|
} else {
|
||||||
|
vec![action, middle, controls]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the terminal. This will also wait for the specified
|
||||||
|
/// delta to pass before completing.
|
||||||
|
pub async fn draw(&mut self, state: &State) -> super::Result<()> {
|
||||||
|
if let Some(x) = self.clock.as_mut() {
|
||||||
|
x.update(&mut self.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = self.menu(state);
|
||||||
|
self.window.draw(stdout().lock(), menu)?;
|
||||||
|
self.interval.tick().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/ui/interface/clock.rs
Normal file
32
src/ui/interface/clock.rs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use super::window::Window;
|
||||||
|
|
||||||
|
/// An extremely simple clock to be used alongside the [`Window`].
|
||||||
|
pub struct Clock(Instant);
|
||||||
|
|
||||||
|
impl Clock {
|
||||||
|
/// Small shorthand for getting the local time now, and formatting it.
|
||||||
|
#[inline]
|
||||||
|
fn now() -> chrono::format::DelayedFormat<chrono::format::StrftimeItems<'static>> {
|
||||||
|
chrono::Local::now().format("%H:%M:%S")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the last update was long enough ago, and if so,
|
||||||
|
/// updates the displayed clock.
|
||||||
|
///
|
||||||
|
/// This is to avoid constant calls to [`chrono::Local::now`], which
|
||||||
|
/// is somewhat expensive because of timezones.
|
||||||
|
pub fn update(&mut self, window: &mut Window) {
|
||||||
|
if self.0.elapsed().as_millis() >= 200 {
|
||||||
|
window.titlebar.display(Self::now());
|
||||||
|
self.0 = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simply creates a new clock, and renders it's initial state to the top of the window.
|
||||||
|
pub fn new(window: &mut Window) -> Self {
|
||||||
|
window.titlebar.display(Self::now());
|
||||||
|
Self(Instant::now())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
//! Various different individual components that
|
//! Various different individual components that
|
||||||
//! appear in lowfi's UI, like the progress bar.
|
//! appear in lowfi's UI, like the progress bar.
|
||||||
|
|
||||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::style::Stylize as _;
|
use crossterm::style::Stylize as _;
|
||||||
use unicode_segmentation::UnicodeSegmentation as _;
|
use unicode_segmentation::UnicodeSegmentation as _;
|
||||||
|
|
||||||
use crate::{player::Player, tracks::Info};
|
use crate::{player::Current, tracks, ui};
|
||||||
|
|
||||||
/// Small helper function to format durations.
|
/// Small helper function to format durations.
|
||||||
pub fn format_duration(duration: &Duration) -> String {
|
pub fn format_duration(duration: &Duration) -> String {
|
||||||
@ -17,23 +17,23 @@ pub fn format_duration(duration: &Duration) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the progress bar, as well as all the padding needed.
|
/// Creates the progress bar, as well as all the padding needed.
|
||||||
pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
pub fn progress_bar(state: &ui::State, width: usize) -> String {
|
||||||
let mut duration = Duration::new(0, 0);
|
let mut duration = Duration::new(0, 0);
|
||||||
let elapsed = if current.is_some() {
|
let elapsed = if matches!(&state.current, Current::Track(_)) {
|
||||||
player.sink.get_pos()
|
state.sink.get_pos()
|
||||||
} else {
|
} else {
|
||||||
Duration::new(0, 0)
|
Duration::new(0, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut filled = 0;
|
let mut filled = 0;
|
||||||
if let Some(current) = current {
|
if let Current::Track(current) = &state.current {
|
||||||
if let Some(x) = current.duration {
|
if let Some(x) = current.duration {
|
||||||
duration = x;
|
duration = x;
|
||||||
|
|
||||||
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
||||||
filled = (elapsed * width as f32).round() as usize;
|
filled = (elapsed * width as f32).round() as usize;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
" [{}{}] {}/{} ",
|
" [{}{}] {}/{} ",
|
||||||
@ -45,7 +45,7 @@ pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the audio bar, as well as all the padding needed.
|
/// Creates the audio bar, as well as all the padding needed.
|
||||||
pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
pub fn audio_bar(width: usize, volume: f32, percentage: &str) -> String {
|
||||||
let audio = (volume * width as f32).round() as usize;
|
let audio = (volume * width as f32).round() as usize;
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
@ -59,48 +59,72 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
|||||||
|
|
||||||
/// This represents the main "action" bars state.
|
/// This represents the main "action" bars state.
|
||||||
enum ActionBar {
|
enum ActionBar {
|
||||||
/// When the app is currently displaying "paused".
|
/// When the app is paused.
|
||||||
Paused(Info),
|
Paused(tracks::Info),
|
||||||
|
|
||||||
/// When the app is currently displaying "playing".
|
/// When the app is playing.
|
||||||
Playing(Info),
|
Playing(tracks::Info),
|
||||||
|
|
||||||
/// When the app is currently displaying "loading".
|
/// When the app is loading.
|
||||||
Loading,
|
Loading(Option<u8>),
|
||||||
|
|
||||||
|
/// When the app is muted.
|
||||||
|
Muted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionBar {
|
impl ActionBar {
|
||||||
/// Formats the action bar to be displayed.
|
/// Formats the action bar to be displayed.
|
||||||
/// The second value is the character length of the result.
|
/// The second value is the character length of the result.
|
||||||
fn format(&self) -> (String, usize) {
|
fn format(&self, star: bool) -> (String, usize) {
|
||||||
let (word, subject) = match self {
|
let (word, subject) = match self {
|
||||||
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
|
Self::Playing(x) => ("playing", Some((x.display.clone(), x.width))),
|
||||||
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
|
Self::Paused(x) => ("paused", Some((x.display.clone(), x.width))),
|
||||||
Self::Loading => ("loading", None),
|
Self::Loading(progress) => {
|
||||||
|
let progress = match *progress {
|
||||||
|
None | Some(0) => None,
|
||||||
|
Some(progress) => Some((format!("{: <2.0}%", progress.min(99)), 3)),
|
||||||
|
};
|
||||||
|
|
||||||
|
("loading", progress)
|
||||||
|
}
|
||||||
|
Self::Muted => {
|
||||||
|
let msg = "+ to increase volume";
|
||||||
|
|
||||||
|
("muted,", Some((String::from(msg), msg.len())))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
subject.map_or_else(
|
subject.map_or_else(
|
||||||
|| (word.to_owned(), word.len()),
|
|| (word.to_owned(), word.len()),
|
||||||
|(subject, len)| (format!("{} {}", word, subject.bold()), word.len() + 1 + len),
|
|(subject, len)| {
|
||||||
|
(
|
||||||
|
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
|
||||||
|
word.len() + 1 + len + usize::from(star),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the top/action bar, which has the name of the track and it's status.
|
/// Creates the top/action bar, which has the name of the track and it's status.
|
||||||
/// This also creates all the needed padding.
|
/// This also creates all the needed padding.
|
||||||
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
pub fn action(state: &ui::State, width: usize) -> String {
|
||||||
let (main, len) = current
|
let action = match state.current.clone() {
|
||||||
.map_or(ActionBar::Loading, |info| {
|
Current::Loading(progress) => {
|
||||||
let info = info.deref().clone();
|
ActionBar::Loading(progress.map(|x| (x.get() * 100.0).round() as u8))
|
||||||
|
}
|
||||||
if player.sink.is_paused() {
|
Current::Track(info) => {
|
||||||
|
if state.sink.volume() < 0.01 {
|
||||||
|
ActionBar::Muted
|
||||||
|
} else if state.sink.is_paused() {
|
||||||
ActionBar::Paused(info)
|
ActionBar::Paused(info)
|
||||||
} else {
|
} else {
|
||||||
ActionBar::Playing(info)
|
ActionBar::Playing(info)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.format();
|
};
|
||||||
|
|
||||||
|
let (main, len) = action.format(state.bookmarked);
|
||||||
if len > width {
|
if len > width {
|
||||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||||
|
|
||||||
62
src/ui/interface/titlebar.rs
Normal file
62
src/ui/interface/titlebar.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
/// The titlebar, which is the entire top row of the window.
|
||||||
|
///
|
||||||
|
/// The struct offers a basic API for displaying messages to it.
|
||||||
|
pub struct TitleBar {
|
||||||
|
/// The actual content of the titlebar.
|
||||||
|
pub(crate) content: String,
|
||||||
|
|
||||||
|
/// The width of the titlebar, identical to the width of the parent window.
|
||||||
|
width: usize,
|
||||||
|
|
||||||
|
/// Whether to render a bordered or borderless titlebar.
|
||||||
|
borderless: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TitleBar {
|
||||||
|
/// Returns a blank default titlebar string for use elsewhere.
|
||||||
|
fn blank_content(width: usize, borderless: bool) -> String {
|
||||||
|
if borderless {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let middle = "─".repeat(width + 2);
|
||||||
|
format!("┌{middle}┐")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empties the contents of the titlebar.
|
||||||
|
pub fn empty(&mut self) {
|
||||||
|
self.content = Self::blank_content(self.width, self.borderless);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds text to the top of the titlebar.
|
||||||
|
pub fn display(&mut self, display: impl Display) {
|
||||||
|
let mut display = display.to_string();
|
||||||
|
let graphemes = display.graphemes(true);
|
||||||
|
let mut len = graphemes.clone().count();
|
||||||
|
let inner = self.width - 2;
|
||||||
|
|
||||||
|
if len > inner {
|
||||||
|
display = format!("{}...", graphemes.take(inner - 3).collect::<String>());
|
||||||
|
len = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prefix, middle, suffix) = if self.borderless {
|
||||||
|
(" ", " ", " ")
|
||||||
|
} else {
|
||||||
|
("┌─", "─", "─┐")
|
||||||
|
};
|
||||||
|
|
||||||
|
self.content = format!("{prefix} {display} {}{suffix}", middle.repeat(inner - len));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(width: usize, borderless: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
content: Self::blank_content(width, borderless),
|
||||||
|
width,
|
||||||
|
borderless,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/ui/interface/window.rs
Normal file
120
src/ui/interface/window.rs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
use crate::ui::{self, interface::TitleBar};
|
||||||
|
use crossterm::{
|
||||||
|
cursor::{MoveToColumn, MoveUp},
|
||||||
|
style::{Print, Stylize as _},
|
||||||
|
terminal::{Clear, ClearType},
|
||||||
|
};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation as _;
|
||||||
|
|
||||||
|
/// Represents an abstraction for drawing the actual lowfi window itself.
|
||||||
|
///
|
||||||
|
/// The main purpose of this struct is just to add the fancy border,
|
||||||
|
/// as well as clear the screen before drawing.
|
||||||
|
pub struct Window {
|
||||||
|
/// Whether or not to include borders in the output.
|
||||||
|
borderless: bool,
|
||||||
|
|
||||||
|
/// The titlebar of this window.
|
||||||
|
pub titlebar: TitleBar,
|
||||||
|
|
||||||
|
/// The status (bottom) bar of the window, which for now shouldn't change since initialization.
|
||||||
|
pub(crate) statusbar: String,
|
||||||
|
|
||||||
|
/// The inner width of the window.
|
||||||
|
width: usize,
|
||||||
|
|
||||||
|
/// Whether content items should be automatically padded (spaced).
|
||||||
|
spaced: bool,
|
||||||
|
|
||||||
|
/// Whether to cautiously handle ANSI sequences by adding [`style::Attribute::Reset`] generously.
|
||||||
|
fancy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
/// Initializes a new [Window].
|
||||||
|
///
|
||||||
|
/// * `width` - Inner width of the window.
|
||||||
|
/// * `borderless` - Whether to include borders in the window, or not.
|
||||||
|
pub fn new(width: usize, borderless: bool, spaced: bool, fancy: bool) -> Self {
|
||||||
|
let statusbar = if borderless {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let middle = "─".repeat(width + 2);
|
||||||
|
format!("└{middle}┘")
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
spaced,
|
||||||
|
statusbar,
|
||||||
|
borderless,
|
||||||
|
width,
|
||||||
|
fancy,
|
||||||
|
titlebar: TitleBar::new(width, borderless),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the window itself, but doesn't actually draw it.
|
||||||
|
///
|
||||||
|
/// `testing` just determines whether to add special features
|
||||||
|
/// like color resets and carriage returns.
|
||||||
|
///
|
||||||
|
/// This returns both the final rendered window and also the full
|
||||||
|
/// height of the rendered window.
|
||||||
|
pub(crate) fn render(&self, content: Vec<String>) -> ui::Result<(String, u16)> {
|
||||||
|
let newline: &str = if self.fancy { "\r\n" } else { "\n" };
|
||||||
|
let len: u16 = content.len().try_into()?;
|
||||||
|
|
||||||
|
// Note that this will have a trailing newline, which we use later.
|
||||||
|
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
||||||
|
// Horizontal Padding & Border
|
||||||
|
let padding = if self.borderless { " " } else { "│" };
|
||||||
|
let space = if self.spaced {
|
||||||
|
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let center = if self.fancy { x.reset().to_string() } else { x };
|
||||||
|
write!(output, "{padding} {center}{space} {padding}{newline}").unwrap();
|
||||||
|
|
||||||
|
output
|
||||||
|
});
|
||||||
|
|
||||||
|
// We're doing this because Windows is stupid and can't stand
|
||||||
|
// writing to the last line repeatedly.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let (height, suffix) = (len + 3, linefeed);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let (height, suffix) = (len + 2, "");
|
||||||
|
|
||||||
|
// There's no need for another newline after the main menu content, because it already has one.
|
||||||
|
Ok((
|
||||||
|
format!(
|
||||||
|
"{}{newline}{menu}{}{suffix}",
|
||||||
|
self.titlebar.content, self.statusbar,
|
||||||
|
),
|
||||||
|
height,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually draws the window, with each element in `content` being on a new line.
|
||||||
|
pub fn draw(
|
||||||
|
&mut self,
|
||||||
|
mut writer: impl std::io::Write,
|
||||||
|
content: Vec<String>,
|
||||||
|
) -> ui::Result<()> {
|
||||||
|
let (rendered, height) = self.render(content)?;
|
||||||
|
|
||||||
|
crossterm::execute!(
|
||||||
|
writer,
|
||||||
|
Clear(ClearType::FromCursorDown),
|
||||||
|
MoveToColumn(0),
|
||||||
|
Print(rendered),
|
||||||
|
MoveToColumn(0),
|
||||||
|
MoveUp(height - 1),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,27 +1,61 @@
|
|||||||
//! Contains the code for the MPRIS server & other helper functions.
|
//! Contains the code for the MPRIS server & other helper functions.
|
||||||
|
|
||||||
use std::{process, sync::Arc};
|
use std::{
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
process,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
use mpris_server::{
|
use mpris_server::{
|
||||||
zbus::{self, fdo, Result},
|
zbus::{self, fdo, Result},
|
||||||
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
||||||
Time, TrackId, Volume,
|
Time, TrackId, Volume,
|
||||||
};
|
};
|
||||||
use tokio::sync::mpsc::Sender;
|
use rodio::Sink;
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
use super::ui;
|
use crate::{player::Current, ui::Update};
|
||||||
use super::Messages;
|
use crate::{ui, Message};
|
||||||
|
|
||||||
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
|
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
|
||||||
|
|
||||||
|
struct Sender {
|
||||||
|
inner: mpsc::Sender<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sender {
|
||||||
|
pub fn new(inner: mpsc::Sender<Message>) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&self, message: Message) -> fdo::Result<()> {
|
||||||
|
self.inner
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|x| fdo::Error::Failed(x.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn zbus(&self, message: Message) -> zbus::Result<()> {
|
||||||
|
self.inner
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|x| zbus::Error::Failure(x.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<fdo::Error> for crate::Error {
|
||||||
|
fn into(self) -> fdo::Error {
|
||||||
|
fdo::Error::Failed(self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The actual MPRIS player.
|
/// The actual MPRIS player.
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
/// A reference to the [`super::Player`] itself.
|
sink: Arc<Sink>,
|
||||||
pub player: Arc<super::Player>,
|
current: ArcSwap<Current>,
|
||||||
|
list: String,
|
||||||
/// The audio server sender, which is used to communicate with
|
sender: Sender,
|
||||||
/// the audio sender for skips and a few other inputs.
|
|
||||||
pub sender: Sender<Messages>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RootInterface for Player {
|
impl RootInterface for Player {
|
||||||
@ -30,10 +64,7 @@ impl RootInterface for Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn quit(&self) -> fdo::Result<()> {
|
async fn quit(&self) -> fdo::Result<()> {
|
||||||
self.sender
|
self.sender.send(Message::Quit).await
|
||||||
.send(Messages::Quit)
|
|
||||||
.await
|
|
||||||
.map_err(|_error| ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn can_quit(&self) -> fdo::Result<bool> {
|
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||||
@ -79,10 +110,7 @@ impl RootInterface for Player {
|
|||||||
|
|
||||||
impl PlayerInterface for Player {
|
impl PlayerInterface for Player {
|
||||||
async fn next(&self) -> fdo::Result<()> {
|
async fn next(&self) -> fdo::Result<()> {
|
||||||
self.sender
|
self.sender.send(Message::Next).await
|
||||||
.send(Messages::Next)
|
|
||||||
.await
|
|
||||||
.map_err(|_error| ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn previous(&self) -> fdo::Result<()> {
|
async fn previous(&self) -> fdo::Result<()> {
|
||||||
@ -90,28 +118,19 @@ impl PlayerInterface for Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn pause(&self) -> fdo::Result<()> {
|
async fn pause(&self) -> fdo::Result<()> {
|
||||||
self.sender
|
self.sender.send(Message::Pause).await
|
||||||
.send(Messages::Pause)
|
|
||||||
.await
|
|
||||||
.map_err(|_error| ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn play_pause(&self) -> fdo::Result<()> {
|
async fn play_pause(&self) -> fdo::Result<()> {
|
||||||
self.sender
|
self.sender.send(Message::PlayPause).await
|
||||||
.send(Messages::PlayPause)
|
|
||||||
.await
|
|
||||||
.map_err(|_error| ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop(&self) -> fdo::Result<()> {
|
async fn stop(&self) -> fdo::Result<()> {
|
||||||
self.pause().await
|
self.sender.send(Message::Quit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn play(&self) -> fdo::Result<()> {
|
async fn play(&self) -> fdo::Result<()> {
|
||||||
self.sender
|
self.sender.send(Message::Play).await
|
||||||
.send(Messages::Play)
|
|
||||||
.await
|
|
||||||
.map_err(|_error| ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
|
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
|
||||||
@ -127,9 +146,9 @@ impl PlayerInterface for Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
||||||
Ok(if !self.player.current_exists() {
|
Ok(if self.current.load().loading() {
|
||||||
PlaybackStatus::Stopped
|
PlaybackStatus::Stopped
|
||||||
} else if self.player.sink.is_paused() {
|
} else if self.sink.is_paused() {
|
||||||
PlaybackStatus::Paused
|
PlaybackStatus::Paused
|
||||||
} else {
|
} else {
|
||||||
PlaybackStatus::Playing
|
PlaybackStatus::Playing
|
||||||
@ -145,11 +164,11 @@ impl PlayerInterface for Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||||
Ok(self.player.sink.speed().into())
|
Ok(self.sink.speed().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
|
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
|
||||||
self.player.sink.set_speed(rate as f32);
|
self.sink.set_speed(rate as f32);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,15 +181,23 @@ impl PlayerInterface for Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn metadata(&self) -> fdo::Result<Metadata> {
|
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||||
let metadata = self
|
Ok(match self.current.load().as_ref() {
|
||||||
.player
|
Current::Loading(_) => Metadata::new(),
|
||||||
.current
|
Current::Track(track) => {
|
||||||
.load()
|
let mut hasher = DefaultHasher::new();
|
||||||
.as_ref()
|
track.path.hash(&mut hasher);
|
||||||
.map_or_else(Metadata::new, |track| {
|
|
||||||
|
let id = mpris_server::zbus::zvariant::ObjectPath::try_from(format!(
|
||||||
|
"/com/talwat/lowfi/{}/{}",
|
||||||
|
self.list,
|
||||||
|
hasher.finish()
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut metadata = Metadata::builder()
|
let mut metadata = Metadata::builder()
|
||||||
.title(track.name.clone())
|
.trackid(id)
|
||||||
.album(self.player.list.name.clone())
|
.title(track.display.clone())
|
||||||
|
.album(self.list.clone())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
metadata.set_length(
|
metadata.set_length(
|
||||||
@ -180,26 +207,20 @@ impl PlayerInterface for Player {
|
|||||||
);
|
);
|
||||||
|
|
||||||
metadata
|
metadata
|
||||||
});
|
}
|
||||||
|
})
|
||||||
Ok(metadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn volume(&self) -> fdo::Result<Volume> {
|
async fn volume(&self) -> fdo::Result<Volume> {
|
||||||
Ok(self.player.sink.volume().into())
|
Ok(self.sink.volume().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||||
self.player.set_volume(volume as f32);
|
self.sender.zbus(Message::SetVolume(volume as f32)).await
|
||||||
ui::flash_audio();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn position(&self) -> fdo::Result<Time> {
|
async fn position(&self) -> fdo::Result<Time> {
|
||||||
Ok(Time::from_micros(
|
Ok(Time::from_micros(self.sink.get_pos().as_micros() as i64))
|
||||||
self.player.sink.get_pos().as_micros() as i64
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||||
@ -240,24 +261,59 @@ impl PlayerInterface for Player {
|
|||||||
pub struct Server {
|
pub struct Server {
|
||||||
/// The inner MPRIS server.
|
/// The inner MPRIS server.
|
||||||
inner: mpris_server::Server<Player>,
|
inner: mpris_server::Server<Player>,
|
||||||
|
|
||||||
|
/// Broadcast receiver.
|
||||||
|
receiver: broadcast::Receiver<Update>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// Handles a player message to update the state of the MPRIS player.
|
||||||
|
pub async fn handle(&mut self, message: &crate::Message) -> ui::Result<()> {
|
||||||
|
match message {
|
||||||
|
Message::ChangeVolume(_) | Message::SetVolume(_) => self.update_volume().await,
|
||||||
|
Message::Play | Message::Pause | Message::PlayPause => self.update_playback().await,
|
||||||
|
Message::Init | Message::Loaded | Message::Next => self.update_metadata().await,
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
||||||
pub async fn changed(
|
async fn changed(
|
||||||
&self,
|
&mut self,
|
||||||
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
|
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
|
||||||
) -> eyre::Result<()> {
|
) -> ui::Result<()> {
|
||||||
|
while let Ok(update) = self.receiver.try_recv() {
|
||||||
|
if let Update::Track(current) = update {
|
||||||
|
self.player().current.swap(Arc::new(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.inner.properties_changed(properties).await?;
|
self.inner.properties_changed(properties).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the volume with the latest information.
|
||||||
|
async fn update_volume(&mut self) -> ui::Result<()> {
|
||||||
|
self.changed(vec![Property::Volume(self.player().sink.volume().into())])
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
|
/// Updates the playback with the latest information.
|
||||||
pub async fn playback(&self, new: PlaybackStatus) -> zbus::Result<()> {
|
async fn update_playback(&mut self) -> ui::Result<()> {
|
||||||
self.inner
|
let status = self.player().playback_status().await?;
|
||||||
.properties_changed(vec![Property::PlaybackStatus(new)])
|
self.changed(vec![Property::PlaybackStatus(status)]).await?;
|
||||||
.await
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the current track data with the current information.
|
||||||
|
async fn update_metadata(&mut self) -> ui::Result<()> {
|
||||||
|
let metadata = self.player().metadata().await?;
|
||||||
|
self.changed(vec![Property::Metadata(metadata)]).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorthand to get the inner mpris player object.
|
/// Shorthand to get the inner mpris player object.
|
||||||
@ -266,11 +322,31 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new MPRIS server.
|
/// Creates a new MPRIS server.
|
||||||
pub async fn new(player: Arc<super::Player>, sender: Sender<Messages>) -> eyre::Result<Self> {
|
pub async fn new(
|
||||||
let suffix = format!("lowfi.{}.instance{}", player.list.name, process::id());
|
state: ui::State,
|
||||||
|
sender: mpsc::Sender<Message>,
|
||||||
|
receiver: broadcast::Receiver<Update>,
|
||||||
|
) -> ui::Result<Server> {
|
||||||
|
let suffix = if crate::env("LOWFI_FIXED_MPRIS_NAME") {
|
||||||
|
String::from("lowfi")
|
||||||
|
} else {
|
||||||
|
format!("lowfi.{}.instance{}", state.tracklist, process::id())
|
||||||
|
};
|
||||||
|
|
||||||
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
|
let server = mpris_server::Server::new(
|
||||||
|
&suffix,
|
||||||
|
Player {
|
||||||
|
sender: Sender::new(sender),
|
||||||
|
sink: state.sink,
|
||||||
|
current: ArcSwap::new(Arc::new(state.current)),
|
||||||
|
list: state.tracklist,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Self { inner: server })
|
Ok(Self {
|
||||||
|
inner: server,
|
||||||
|
receiver,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
82
src/volume.rs
Normal file
82
src/volume.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
//! Persistent volume management.
|
||||||
|
//!
|
||||||
|
//! The module provides a tiny helper that reads and writes the user's
|
||||||
|
//! configured volume to `volume.txt` inside the platform config directory.
|
||||||
|
use std::{num::ParseIntError, path::PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
/// Shorthand for a [`Result`] with a persistent volume error.
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Errors which occur when loading/unloading persistent volume.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("couldn't find config directory")]
|
||||||
|
DirectoryNotFound,
|
||||||
|
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("error parsing volume integer: {0}")]
|
||||||
|
Parse(#[from] ParseIntError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Representation of the persistent volume stored on disk.
|
||||||
|
///
|
||||||
|
/// The inner value is an integer percentage (0..=100). Use
|
||||||
|
/// [`PersistentVolume::float`] to convert to a normalized `f32` in the
|
||||||
|
/// range 0.0..=1.0 for playback volume calculations.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PersistentVolume {
|
||||||
|
/// The volume, as a percentage.
|
||||||
|
pub(crate) inner: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistentVolume {
|
||||||
|
/// Retrieves the config file path, creating it if necessary.
|
||||||
|
async fn path() -> Result<PathBuf> {
|
||||||
|
let config = dirs::config_dir()
|
||||||
|
.ok_or(Error::DirectoryNotFound)?
|
||||||
|
.join(PathBuf::from("lowfi"));
|
||||||
|
|
||||||
|
if !config.exists() {
|
||||||
|
fs::create_dir_all(&config).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config.join(PathBuf::from("volume.txt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the volume as a normalized float in the range 0.0..=1.0.
|
||||||
|
pub fn float(self) -> f32 {
|
||||||
|
f32::from(self.inner) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the [`PersistentVolume`] from the platform config directory.
|
||||||
|
///
|
||||||
|
/// If the file does not exist a default of `100` is written and
|
||||||
|
/// returned.
|
||||||
|
pub async fn load() -> Result<Self> {
|
||||||
|
let path = Self::path().await?;
|
||||||
|
|
||||||
|
// Basically just read from the volume file if it exists, otherwise return 100.
|
||||||
|
let volume = if path.exists() {
|
||||||
|
let contents = fs::read_to_string(path).await?;
|
||||||
|
let trimmed = contents.trim();
|
||||||
|
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
|
||||||
|
stripped.parse()?
|
||||||
|
} else {
|
||||||
|
fs::write(&path, "100").await?;
|
||||||
|
100u16
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self { inner: volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves `volume` (0.0..=1.0) to `volume.txt` as an integer percent.
|
||||||
|
pub async fn save(volume: f32) -> Result<()> {
|
||||||
|
let percentage = (volume * 100.0).abs().round() as u16;
|
||||||
|
fs::write(Self::path().await?, percentage.to_string()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user