mirror of
https://github.com/talwat/lowfi
synced 2025-12-07 15:38:22 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
29
CHILLHOP.md
Normal file
29
CHILLHOP.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Using the chillhop list
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> As of lowfi 1.7.0, the chillhop list is included by default. For a more
|
||||||
|
> detailed explanation, see [MUSIC.md](MUSIC.md). This document is included
|
||||||
|
> to preserve any old links or references. The instructions are still valid.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/.local/share/lowfi
|
||||||
|
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir ~/.local/share/lowfi
|
||||||
|
```
|
||||||
|
|
||||||
|
## MacOS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p "$HOME/Library/Application Support/lowfi"
|
||||||
|
curl https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt -O --output-dir "$HOME/Library/Application Support/lowfi"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Go to `%appdata%` in Explorer, then `Roaming`, and make a folder called `lowfi`.
|
||||||
|
Then just put [this file](https://raw.githubusercontent.com/talwat/lowfi/refs/heads/main/data/chillhop.txt) in there.
|
||||||
|
|
||||||
|
## Launching lowfi
|
||||||
|
|
||||||
|
Once the list has been added, just launch `lowfi` with `-t chillhop`.
|
||||||
28
CONTRIBUTING.md
Normal file
28
CONTRIBUTING.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Contributing to lowfi
|
||||||
|
|
||||||
|
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.
|
||||||
2088
Cargo.lock
generated
2088
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
67
Cargo.toml
67
Cargo.toml
@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "1.6.2-dev"
|
version = "2.0.0-dev"
|
||||||
|
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"
|
||||||
@ -18,36 +19,62 @@ repository = "https://github.com/talwat/lowfi"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
mpris = ["dep:mpris-server"]
|
mpris = ["dep:mpris-server"]
|
||||||
|
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",
|
|
||||||
"fs"
|
|
||||||
], default-features = false }
|
|
||||||
futures = "0.3.31"
|
|
||||||
arc-swap = "1.7.1"
|
arc-swap = "1.7.1"
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
reqwest = "0.12.9"
|
reqwest = { version = "0.12.9", features = ["stream", "http2", "default-tls"], 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"], 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.24.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"
|
||||||
|
|||||||
7
ENVIRONMENT_VARS.md
Normal file
7
ENVIRONMENT_VARS.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
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.
|
||||||
75
MUSIC.md
Normal file
75
MUSIC.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# The State of Lowfi's Music
|
||||||
|
|
||||||
|
> [!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.
|
||||||
177
README.md
177
README.md
@ -7,24 +7,18 @@ 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.md](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 +28,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 +51,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 +70,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 +124,110 @@ 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 behaviour 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
|
### Extra Features
|
||||||
|
|
||||||
lowfi also has a `scrape` command which is usually not relevant, but
|
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,
|
||||||
if you're trying to download some files from Lofi Girls' website,
|
like those which are only expected to be used by a handful of users.
|
||||||
it can be useful.
|
|
||||||
|
|
||||||
An example of scrape is as follows,
|
#### `scrape` - Scraping
|
||||||
|
|
||||||
`lowfi scrape --extension zip --include-full`
|
This feature provides the `scrape` command.
|
||||||
|
It's usually not very useful, but is included for transparency's sake.
|
||||||
|
|
||||||
where more information can be found by running `lowfi help scrape`.
|
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 +235,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 +252,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
|
||||||
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
|
||||||
52
src/audio.rs
Normal file
52
src/audio.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
59
src/audio/waiter.rs
Normal file
59
src/audio/waiter.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use rodio::Sink;
|
||||||
|
use tokio::{
|
||||||
|
sync::{mpsc, Notify},
|
||||||
|
task::{self, JoinHandle},
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Lightweight helper that waits for the current sink to drain and then
|
||||||
|
/// notifies the player to advance to the next track.
|
||||||
|
pub struct Handle {
|
||||||
|
/// Background task monitoring the sink.
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
|
||||||
|
/// Notification primitive used to wake the waiter.
|
||||||
|
notify: Arc<Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Handle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
/// Create a new `Handle` which watches the provided `sink` and sends
|
||||||
|
/// `Message::Next` down `tx` when the sink becomes empty.
|
||||||
|
pub fn new(sink: Arc<Sink>, tx: mpsc::Sender<crate::Message>) -> Self {
|
||||||
|
let notify = Arc::new(Notify::new());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
task: task::spawn(Self::waiter(sink, tx, Arc::clone(¬ify))),
|
||||||
|
notify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>) {
|
||||||
|
loop {
|
||||||
|
notify.notified().await;
|
||||||
|
|
||||||
|
while !sink.empty() {
|
||||||
|
time::sleep(Duration::from_millis(8)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx.try_send(crate::Message::Next).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/download.rs
Normal file
152
src/download.rs
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
use std::{
|
||||||
|
sync::atomic::{self, AtomicBool, AtomicU8},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::{
|
||||||
|
sync::mpsc::{self, Receiver, Sender},
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tracks;
|
||||||
|
|
||||||
|
/// 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 in the range 0..=100 updated atomically.
|
||||||
|
///
|
||||||
|
/// The UI can read this `AtomicU8` to render a global progress indicator
|
||||||
|
/// when there isn't an immediately queued track available.
|
||||||
|
pub(crate) static PROGRESS: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// A convenient alias for the progress `AtomicU8` pointer type.
|
||||||
|
pub type Progress = &'static AtomicU8;
|
||||||
|
|
||||||
|
/// 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 [`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: Sender<tracks::Queued>,
|
||||||
|
|
||||||
|
/// The [`Sender`] which is used to inform the
|
||||||
|
/// [`crate::Player`] with [`crate::Message::Loaded`].
|
||||||
|
tx: Sender<crate::Message>,
|
||||||
|
|
||||||
|
/// The list of tracks to download from.
|
||||||
|
tracks: tracks::List,
|
||||||
|
|
||||||
|
/// The [`reqwest`] client to use for downloads.
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
/// Initializes the downloader with a track list.
|
||||||
|
///
|
||||||
|
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
|
||||||
|
pub fn init(
|
||||||
|
size: usize,
|
||||||
|
timeout: u64,
|
||||||
|
tracks: tracks::List,
|
||||||
|
tx: Sender<crate::Message>,
|
||||||
|
) -> 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 = Self {
|
||||||
|
queue: qtx,
|
||||||
|
tx,
|
||||||
|
tracks,
|
||||||
|
client,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Handle {
|
||||||
|
queue: qrx,
|
||||||
|
task: tokio::spawn(downloader.run()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually runs the downloader, consuming it and beginning
|
||||||
|
/// the cycle of downloading tracks and reporting to the
|
||||||
|
/// rest of the program.
|
||||||
|
async fn run(self) -> crate::Result<()> {
|
||||||
|
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let result = self.tracks.random(&self.client, &PROGRESS).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: Receiver<tracks::Queued>,
|
||||||
|
|
||||||
|
/// The downloader task, which can be aborted.
|
||||||
|
task: JoinHandle<crate::Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}, Output::Queued,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Handle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/error.rs
Normal file
76
src/error.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
//! 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 {
|
||||||
|
/// Errors while loading or saving the persistent volume settings.
|
||||||
|
#[error("unable to load/save the persistent volume")]
|
||||||
|
PersistentVolume(#[from] volume::Error),
|
||||||
|
|
||||||
|
/// Errors while loading or saving bookmarks.
|
||||||
|
#[error("unable to load/save bookmarks")]
|
||||||
|
Bookmarks(#[from] bookmark::Error),
|
||||||
|
|
||||||
|
/// Network request failures from `reqwest`.
|
||||||
|
#[error("unable to fetch data")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
/// Failure converting to/from a C string (FFI helpers).
|
||||||
|
#[error("C string null error")]
|
||||||
|
FfiNull(#[from] std::ffi::NulError),
|
||||||
|
|
||||||
|
/// Errors coming from the audio backend / stream handling.
|
||||||
|
#[error("audio playing error")]
|
||||||
|
Rodio(#[from] rodio::StreamError),
|
||||||
|
|
||||||
|
/// Failure to send an internal `Message` over the mpsc channel.
|
||||||
|
#[error("couldn't send internal message")]
|
||||||
|
Send(#[from] mpsc::error::SendError<crate::Message>),
|
||||||
|
|
||||||
|
/// Failure to enqueue a track into the queue channel.
|
||||||
|
#[error("couldn't add track to the queue")]
|
||||||
|
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
|
||||||
|
|
||||||
|
/// Failure to broadcast UI updates.
|
||||||
|
#[error("couldn't update UI state")]
|
||||||
|
Broadcast(#[from] broadcast::error::SendError<ui::Update>),
|
||||||
|
|
||||||
|
/// Generic IO error.
|
||||||
|
#[error("io error")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// Data directory was not found or could not be determined.
|
||||||
|
#[error("directory not found")]
|
||||||
|
Directory,
|
||||||
|
|
||||||
|
/// Downloader failed to provide the requested track.
|
||||||
|
#[error("couldn't fetch track from downloader")]
|
||||||
|
Download,
|
||||||
|
|
||||||
|
/// Integer parsing errors.
|
||||||
|
#[error("couldn't parse integer")]
|
||||||
|
Parse(#[from] std::num::ParseIntError),
|
||||||
|
|
||||||
|
/// Track subsystem error.
|
||||||
|
#[error("track failure")]
|
||||||
|
Track(#[from] tracks::Error),
|
||||||
|
|
||||||
|
/// UI subsystem error.
|
||||||
|
#[error("ui failure")]
|
||||||
|
UI(#[from] ui::Error),
|
||||||
|
|
||||||
|
/// Error returned when a spawned task join failed.
|
||||||
|
#[error("join error")]
|
||||||
|
JoinError(#[from] tokio::task::JoinError),
|
||||||
|
}
|
||||||
122
src/main.rs
122
src/main.rs
@ -1,24 +1,34 @@
|
|||||||
//! An extremely simple lofi player.
|
//! An extremely simple lofi player.
|
||||||
|
pub mod error;
|
||||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
mod tests;
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
pub mod message;
|
||||||
|
pub mod ui;
|
||||||
|
use futures_util::TryFutureExt;
|
||||||
|
pub use message::Message;
|
||||||
|
|
||||||
mod play;
|
use crate::player::Player;
|
||||||
mod player;
|
pub mod audio;
|
||||||
mod tracks;
|
pub mod bookmark;
|
||||||
|
pub mod download;
|
||||||
|
pub mod player;
|
||||||
|
pub mod tracks;
|
||||||
|
pub mod volume;
|
||||||
|
|
||||||
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
#[cfg(feature = "scrape")]
|
||||||
mod scrape;
|
mod scrapers;
|
||||||
|
|
||||||
|
#[cfg(feature = "scrape")]
|
||||||
|
use crate::scrapers::Source;
|
||||||
|
|
||||||
/// 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,
|
||||||
@ -35,6 +45,14 @@ struct Args {
|
|||||||
#[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 +61,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 +76,53 @@ 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]
|
/// Returns the application data directory used for persistency.
|
||||||
async fn main() -> eyre::Result<()> {
|
///
|
||||||
#[cfg(target_os = "android")]
|
/// The function returns the platform-specific user data directory with
|
||||||
compile_error!("Android Audio API not supported due to threading shenanigans");
|
/// a `lowfi` subfolder. Callers may use this path to store config,
|
||||||
|
/// bookmarks, and other persistent files.
|
||||||
|
pub fn data_dir() -> crate::Result<PathBuf> {
|
||||||
|
let dir = dirs::data_dir().unwrap().join("lowfi");
|
||||||
|
|
||||||
let cli = Args::parse();
|
Ok(dir)
|
||||||
|
}
|
||||||
if let Some(command) = cli.command {
|
|
||||||
match command {
|
/// Program entry point.
|
||||||
Commands::Scrape {
|
///
|
||||||
extension,
|
/// Parses CLI arguments, initializes the audio stream and player, then
|
||||||
include_full,
|
/// runs the main event loop. On exit it performs cleanup of the UI and
|
||||||
} => scrape::scrape(extension, include_full).await,
|
/// returns the inner result.
|
||||||
}
|
#[tokio::main(flavor = "current_thread")]
|
||||||
} else {
|
async fn main() -> eyre::Result<()> {
|
||||||
play::play(cli).await
|
let args = Args::parse();
|
||||||
}
|
|
||||||
|
#[cfg(feature = "scrape")]
|
||||||
|
if let Some(command) = &args.command {
|
||||||
|
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.alternate)?;
|
||||||
|
let result = Player::init(args, environment, stream.mixer())
|
||||||
|
.and_then(Player::run)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
environment.cleanup(result.is_ok())?;
|
||||||
|
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)]
|
||||||
|
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(())
|
|
||||||
}
|
|
||||||
590
src/player.rs
590
src/player.rs
@ -1,432 +1,246 @@
|
|||||||
//! Responsible for playing & queueing audio.
|
use std::sync::Arc;
|
||||||
//! This also has the code for the underlying
|
|
||||||
//! audio server which adds new tracks.
|
|
||||||
|
|
||||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
use tokio::sync::{
|
||||||
|
broadcast,
|
||||||
use arc_swap::ArcSwapOption;
|
mpsc::{self, Receiver},
|
||||||
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,
|
download::{self, Downloader},
|
||||||
|
tracks::{self, List},
|
||||||
|
ui,
|
||||||
|
volume::PersistentVolume,
|
||||||
|
Message,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod downloader;
|
#[derive(Clone, Debug)]
|
||||||
pub mod ui;
|
/// Represents the currently known playback state.
|
||||||
|
///
|
||||||
|
/// * [`Current::Loading`] indicates the player is waiting for data.
|
||||||
|
/// * [`Current::Track`] indicates the player has a decoded track available.
|
||||||
|
pub enum Current {
|
||||||
|
/// Waiting for a track to arrive. The optional `Progress` is used to
|
||||||
|
/// indicate global download progress when present.
|
||||||
|
Loading(Option<download::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.
|
/// Background downloader that fills the internal queue.
|
||||||
pub sink: Sink,
|
downloader: download::Handle,
|
||||||
|
|
||||||
/// The [`TrackInfo`] of the current track.
|
/// Persistent bookmark storage used by the player.
|
||||||
/// This is [`None`] when lowfi is buffering/loading.
|
bookmarks: Bookmarks,
|
||||||
current: ArcSwapOption<tracks::Info>,
|
|
||||||
|
|
||||||
/// The tracks, which is a [`VecDeque`] that holds
|
/// Shared audio sink used for playback.
|
||||||
/// *undecoded* [Track]s.
|
sink: Arc<rodio::Sink>,
|
||||||
///
|
|
||||||
/// 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.
|
/// Broadcast channel used to send UI updates.
|
||||||
volume: PersistentVolume,
|
broadcast: broadcast::Sender<ui::Update>,
|
||||||
|
|
||||||
/// The web client, which can contain a `UserAgent` & some
|
/// Current playback state (loading or track).
|
||||||
/// settings that help lowfi work more effectively.
|
current: Current,
|
||||||
client: Client,
|
|
||||||
|
|
||||||
/// The [`OutputStreamHandle`], which also can control some
|
/// UI handle for rendering and input.
|
||||||
/// playback, is for now unused and is here just to keep it
|
ui: ui::Handle,
|
||||||
/// alive so the playback can function properly.
|
|
||||||
_handle: OutputStreamHandle,
|
/// Notifies when a play head has been appended.
|
||||||
|
waiter: waiter::Handle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Player {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Ensure playback is stopped when the player is dropped.
|
||||||
|
self.sink.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
/// This gets the output stream while also shutting up alsa with [libc].
|
/// Returns the `Environment` currently used by the UI.
|
||||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
pub const fn environment(&self) -> ui::Environment {
|
||||||
|
self.ui.environment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the in-memory current state and notifies the UI about the change.
|
||||||
///
|
///
|
||||||
/// 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.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.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".
|
/// Sends a `ui::Update` to the broadcast channel.
|
||||||
|
pub fn update(&mut self, update: ui::Update) -> crate::Result<()> {
|
||||||
|
self.broadcast.send(update)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
environment: ui::Environment,
|
||||||
|
mixer: &rodio::mixer::Mixer,
|
||||||
|
) -> crate::Result<Self> {
|
||||||
|
let (tx, rx) = mpsc::channel(8);
|
||||||
|
if args.paused {
|
||||||
|
tx.send(Message::Pause).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(Message::Init).await?;
|
||||||
|
|
||||||
|
let (utx, urx) = broadcast::channel(8);
|
||||||
|
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), args.width, list.name.clone());
|
||||||
|
|
||||||
|
let volume = PersistentVolume::load().await?;
|
||||||
|
sink.set_volume(volume.float());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ui: ui::Handle::init(tx.clone(), environment, urx, state, &args).await?,
|
||||||
|
downloader: Downloader::init(
|
||||||
|
args.buffer_size as usize,
|
||||||
|
args.timeout,
|
||||||
|
list,
|
||||||
|
tx.clone(),
|
||||||
|
)?,
|
||||||
|
waiter: waiter::Handle::new(Arc::clone(&sink), tx),
|
||||||
|
bookmarks: Bookmarks::load().await?,
|
||||||
|
current: Current::default(),
|
||||||
|
broadcast: utx,
|
||||||
|
rx,
|
||||||
|
sink,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist state that should survive a run (bookmarks and volume).
|
||||||
|
pub async fn close(&self) -> crate::Result<()> {
|
||||||
|
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(
|
download::Output::Loading(progress) => {
|
||||||
Arc::clone(&player),
|
self.set_current(Current::Loading(progress))?;
|
||||||
itx.clone(),
|
}
|
||||||
tx.clone(),
|
download::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.update(ui::Update::Volume)?;
|
||||||
|
}
|
||||||
|
Message::SetVolume(set) => {
|
||||||
|
self.sink.set_volume(set.clamp(0.0, 1.0));
|
||||||
|
self.update(ui::Update::Volume)?;
|
||||||
|
}
|
||||||
|
Message::Bookmark => {
|
||||||
|
let Current::Track(current) = &self.current else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bookmarked = self.bookmarks.bookmark(current)?;
|
||||||
|
self.update(ui::Update::Bookmarked(bookmarked))?;
|
||||||
|
}
|
||||||
|
Message::Quit => break,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
match message {
|
||||||
|
Message::ChangeVolume(_) | Message::SetVolume(_) => {
|
||||||
|
self.ui.mpris.update_volume().await?
|
||||||
|
}
|
||||||
|
Message::Play | Message::Pause | Message::PlayPause => {
|
||||||
|
self.ui.mpris.update_playback().await?
|
||||||
|
}
|
||||||
|
Message::Init | Message::Loaded | Message::Next => {
|
||||||
|
self.ui.mpris.update_metadata().await?
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader.abort();
|
self.close().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?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
90
src/scrapers.rs
Normal file
90
src/scrapers.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
#![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}");
|
||||||
}
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/tests/tracks.rs
Normal file
182
src/tests/tracks.rs
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
#[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::{download::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();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/tests/ui.rs
Normal file
251
src/tests/ui.rs
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
/* The lowfi UI:
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ loading │
|
||||||
|
│ [ ] 00:00/00:00 │
|
||||||
|
│ [s]kip [p]ause [q]uit │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod components {
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_works() {
|
||||||
|
let d = Duration::from_secs(62);
|
||||||
|
assert_eq!(ui::components::format_duration(&d), "01:02");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_zero() {
|
||||||
|
let d = Duration::from_secs(0);
|
||||||
|
assert_eq!(ui::components::format_duration(&d), "00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_hours_wrap() {
|
||||||
|
let d = Duration::from_secs(3661); // 1:01:01
|
||||||
|
assert_eq!(ui::components::format_duration(&d), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_contains_percentage() {
|
||||||
|
let s = ui::components::audio_bar(10, 0.5, "50%");
|
||||||
|
assert!(s.contains("50%"));
|
||||||
|
assert!(s.starts_with(" volume:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_muted_volume() {
|
||||||
|
let s = ui::components::audio_bar(8, 0.0, "0%");
|
||||||
|
assert!(s.contains("0%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_bar_full_volume() {
|
||||||
|
let s = ui::components::audio_bar(10, 1.0, "100%");
|
||||||
|
assert!(s.contains("100%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_has_items() {
|
||||||
|
let s = ui::components::controls(30);
|
||||||
|
assert!(s.contains("[s]"));
|
||||||
|
assert!(s.contains("[p]"));
|
||||||
|
assert!(s.contains("[q]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod window {
|
||||||
|
use crate::ui::window::Window;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_border_strings() {
|
||||||
|
let w = Window::new(10, false);
|
||||||
|
assert!(w.borders[0].starts_with('┌'));
|
||||||
|
assert!(w.borders[1].starts_with('└'));
|
||||||
|
|
||||||
|
let w2 = Window::new(5, true);
|
||||||
|
assert!(w2.borders[0].is_empty());
|
||||||
|
assert!(w2.borders[1].is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sided(text: &str) -> String {
|
||||||
|
return format!("│ {text} │");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple() {
|
||||||
|
let w = Window::new(3, false);
|
||||||
|
let (render, height) = w.render(vec![String::from("abc")], false, true).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);
|
||||||
|
let (render, height) = w
|
||||||
|
.render(
|
||||||
|
vec![String::from("abc"), String::from(" b"), String::from("c")],
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.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);
|
||||||
|
assert!(!w.borders[0].is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod interface {
|
||||||
|
use crossterm::style::Stylize;
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
download::PROGRESS,
|
||||||
|
player::Current,
|
||||||
|
tracks,
|
||||||
|
ui::{
|
||||||
|
interface::{self, Params},
|
||||||
|
State,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
let mut state = State::initial(sink, 3, String::from("test"));
|
||||||
|
let menu = interface::menu(&mut state, Params::default());
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
sink.set_volume(0.5);
|
||||||
|
let mut state = State::initial(sink, 3, String::from("test"));
|
||||||
|
state.timer = Some(Instant::now());
|
||||||
|
|
||||||
|
let menu = interface::menu(&mut state, Params::default());
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress() {
|
||||||
|
let sink = Arc::new(rodio::Sink::new().0);
|
||||||
|
PROGRESS.store(50, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut state = State::initial(sink, 3, String::from("test"));
|
||||||
|
state.current = Current::Loading(Some(&PROGRESS));
|
||||||
|
|
||||||
|
let menu = interface::menu(&mut state, Params::default());
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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, 3, String::from("test"));
|
||||||
|
state.current = Current::Track(track.clone());
|
||||||
|
let menu = interface::menu(&mut state, Params::default());
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod environment {
|
||||||
|
use crate::ui::Environment;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_and_cleanup_no_panic() {
|
||||||
|
// Try to create the environment but don't fail the test if the
|
||||||
|
// terminal isn't available. We just assert the API exists.
|
||||||
|
if let Ok(env) = Environment::ready(false) {
|
||||||
|
// cleanup should succeed
|
||||||
|
let _ = env.cleanup(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_with_alternate_screen() {
|
||||||
|
if let Ok(env) = Environment::ready(true) {
|
||||||
|
let _ = env.cleanup(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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} (track: {track:?})")]
|
||||||
|
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,25 @@
|
|||||||
//! 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::{
|
||||||
use eyre::OptionExt as _;
|
cmp::min,
|
||||||
use rand::Rng as _;
|
sync::atomic::{AtomicU8, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 +27,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 +49,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) -> (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 = fastrand::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 +66,121 @@ 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<&AtomicU8>,
|
||||||
|
) -> 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);
|
||||||
|
let rounded = ((downloaded as f64) / (total as f64) * 100.0).round() as u8;
|
||||||
|
progress.store(rounded, Ordering::Relaxed);
|
||||||
|
|
||||||
|
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 occured,
|
||||||
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: &AtomicU8) -> tracks::Result<Queued> {
|
||||||
|
let (path, display) = self.random_path();
|
||||||
|
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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
src/ui.rs
Normal file
211
src/ui.rs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
player::Current,
|
||||||
|
ui::{self, window::Window},
|
||||||
|
Args,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
sync::{broadcast, mpsc::Sender},
|
||||||
|
task::JoinHandle,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
pub mod components;
|
||||||
|
pub mod environment;
|
||||||
|
pub use environment::Environment;
|
||||||
|
pub mod input;
|
||||||
|
pub mod interface;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
|
#[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 message to backend from ui failed: {0}")]
|
||||||
|
CrateSend(#[from] tokio::sync::mpsc::error::SendError<crate::Message>),
|
||||||
|
|
||||||
|
#[error("sharing state between backend and frontend failed: {0}")]
|
||||||
|
Send(#[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) timer: Option<Instant>,
|
||||||
|
|
||||||
|
/// The full inner width of the terminal window.
|
||||||
|
pub(crate) width: usize,
|
||||||
|
|
||||||
|
/// The name of the playing tracklist, for MPRIS.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
list: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
/// Creates an initial UI state.
|
||||||
|
pub fn initial(sink: Arc<rodio::Sink>, width: usize, list: String) -> Self {
|
||||||
|
let width = 21 + width.min(32) * 2;
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
sink,
|
||||||
|
list,
|
||||||
|
current: Current::default(),
|
||||||
|
bookmarked: false,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just a simple wrapper for the two primary tasks that the UI
|
||||||
|
/// requires to function.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Tasks {
|
||||||
|
/// The renderer, responsible for sending output to `stdout`.
|
||||||
|
render: JoinHandle<Result<()>>,
|
||||||
|
|
||||||
|
/// The input, which receives data from `stdin` via [`crossterm`].
|
||||||
|
input: JoinHandle<Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tasks {
|
||||||
|
/// Actually takes care of spawning the tasks for the [`ui`].
|
||||||
|
pub fn spawn(
|
||||||
|
tx: Sender<crate::Message>,
|
||||||
|
updater: broadcast::Receiver<ui::Update>,
|
||||||
|
state: State,
|
||||||
|
params: interface::Params,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
render: tokio::spawn(Handle::ui(updater, state, params)),
|
||||||
|
input: tokio::spawn(input::listen(tx)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Tasks {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.input.abort();
|
||||||
|
self.render.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI handle for controlling the state of the UI, as well as
|
||||||
|
/// updating MPRIS information and other small interfacing tasks.
|
||||||
|
pub struct Handle {
|
||||||
|
/// The terminal environment, which can be used for cleanup.
|
||||||
|
pub(crate) environment: Environment,
|
||||||
|
|
||||||
|
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
pub mpris: mpris::Server,
|
||||||
|
|
||||||
|
/// The UI's running tasks.
|
||||||
|
_tasks: Option<Tasks>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
/// 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.
|
||||||
|
async fn ui(
|
||||||
|
mut rx: broadcast::Receiver<Update>,
|
||||||
|
mut state: State,
|
||||||
|
params: interface::Params,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut interval = tokio::time::interval(params.delta);
|
||||||
|
let mut window = Window::new(state.width, params.borderless);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(message) = rx.try_recv() {
|
||||||
|
match message {
|
||||||
|
Update::Track(track) => state.current = track,
|
||||||
|
Update::Bookmarked(bookmarked) => state.bookmarked = bookmarked,
|
||||||
|
Update::Volume => state.timer = Some(Instant::now()),
|
||||||
|
Update::Quit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface::draw(&mut state, &mut window, params)?;
|
||||||
|
interval.tick().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the UI itself, along with all of the tasks that are related to it.
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
|
pub async fn init(
|
||||||
|
tx: Sender<crate::Message>,
|
||||||
|
environment: Environment,
|
||||||
|
updater: broadcast::Receiver<ui::Update>,
|
||||||
|
state: State,
|
||||||
|
args: &Args,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let params = interface::Params::try_from(args)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
#[cfg(feature = "mpris")]
|
||||||
|
mpris: mpris::Server::new(state.clone(), tx.clone(), updater.resubscribe()).await?,
|
||||||
|
environment,
|
||||||
|
_tasks: params
|
||||||
|
.enabled
|
||||||
|
.then(|| Tasks::spawn(tx, updater, state, params)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.load(std::sync::atomic::Ordering::Relaxed)))
|
||||||
|
}
|
||||||
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();
|
||||||
|
|
||||||
76
src/ui/environment.rs
Normal file
76
src/ui/environment.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Environment {
|
||||||
|
/// This prepares the terminal, returning an [Environment] helpful
|
||||||
|
/// for cleaning up afterwards.
|
||||||
|
pub fn ready(alternate: bool) -> super::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().unwrap_or_default();
|
||||||
|
if enhancement {
|
||||||
|
crossterm::execute!(
|
||||||
|
lock,
|
||||||
|
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let environment = Self {
|
||||||
|
enhancement,
|
||||||
|
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<()> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
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?;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/ui/interface.rs
Normal file
73
src/ui/interface.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use std::{env, time::Duration};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ui::{self, components, window::Window},
|
||||||
|
Args,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
pub struct Params {
|
||||||
|
pub borderless: bool,
|
||||||
|
pub minimalist: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub delta: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = env::var("LOWFI_DISABLE_UI").is_ok_and(|x| x == "1");
|
||||||
|
if disabled && !cfg!(feature = "mpris") {
|
||||||
|
return Err(ui::Error::RejectedDisable);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
delta,
|
||||||
|
enabled: !disabled,
|
||||||
|
minimalist: args.minimalist,
|
||||||
|
borderless: args.borderless,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(state: &mut ui::State, params: Params) -> Vec<String> {
|
||||||
|
let action = components::action(state, state.width);
|
||||||
|
|
||||||
|
let middle = match state.timer {
|
||||||
|
Some(timer) => {
|
||||||
|
let volume = state.sink.volume();
|
||||||
|
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
||||||
|
if timer.elapsed() > Duration::from_secs(1) {
|
||||||
|
state.timer = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
components::audio_bar(state.width - 17, volume, &percentage)
|
||||||
|
}
|
||||||
|
None => components::progress_bar(state, state.width - 16),
|
||||||
|
};
|
||||||
|
|
||||||
|
let controls = components::controls(state.width);
|
||||||
|
if params.minimalist {
|
||||||
|
vec![action, middle]
|
||||||
|
} else {
|
||||||
|
vec![action, middle, controls]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The code for the terminal interface itself.
|
||||||
|
///
|
||||||
|
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||||
|
pub fn draw(state: &mut ui::State, window: &mut Window, params: Params) -> super::Result<()> {
|
||||||
|
let menu = menu(state, params);
|
||||||
|
window.draw(menu, false)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -1,27 +1,62 @@
|
|||||||
//! 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::{
|
||||||
|
env,
|
||||||
|
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 +65,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 +111,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 +119,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 +147,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 +165,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 +182,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 +208,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 +262,49 @@ 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 {
|
||||||
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
||||||
pub async fn changed(
|
pub 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.
|
||||||
|
pub 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<()> {
|
pub 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.
|
||||||
|
pub 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 +313,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 env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
|
||||||
|
String::from("lowfi")
|
||||||
|
} else {
|
||||||
|
format!("lowfi.{}.instance{}", state.list, 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.list,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Self { inner: server })
|
Ok(Self {
|
||||||
|
inner: server,
|
||||||
|
receiver,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
118
src/ui/window.rs
Normal file
118
src/ui/window.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
use std::io::{stdout, Stdout};
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
pub(crate) borders: [String; 2],
|
||||||
|
|
||||||
|
/// The inner width of the window.
|
||||||
|
width: usize,
|
||||||
|
|
||||||
|
/// The output, currently just an [`Stdout`].
|
||||||
|
out: Stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) -> Self {
|
||||||
|
let borders = if borderless {
|
||||||
|
[String::new(), String::new()]
|
||||||
|
} else {
|
||||||
|
let middle = "─".repeat(width + 2);
|
||||||
|
|
||||||
|
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
borders,
|
||||||
|
borderless,
|
||||||
|
width,
|
||||||
|
out: stdout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
space: bool,
|
||||||
|
testing: bool,
|
||||||
|
) -> super::Result<(String, u16)> {
|
||||||
|
let linefeed = if testing { "\n" } else { "\r\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 space {
|
||||||
|
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let center = if testing { x } else { x.reset().to_string() };
|
||||||
|
write!(output, "{padding} {center}{space} {padding}{linefeed}").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!(
|
||||||
|
"{}{linefeed}{menu}{}{suffix}",
|
||||||
|
self.borders[0], self.borders[1]
|
||||||
|
),
|
||||||
|
height,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually draws the window, with each element in `content` being on a new line.
|
||||||
|
pub fn draw(&mut self, content: Vec<String>, space: bool) -> super::Result<()> {
|
||||||
|
let (rendered, height) = self.render(content, space, false)?;
|
||||||
|
|
||||||
|
crossterm::execute!(
|
||||||
|
self.out,
|
||||||
|
Clear(ClearType::FromCursorDown),
|
||||||
|
MoveToColumn(0),
|
||||||
|
Print(rendered),
|
||||||
|
MoveToColumn(0),
|
||||||
|
MoveUp(height - 1),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/volume.rs
Normal file
85
src/volume.rs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
//! 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")]
|
||||||
|
Directory,
|
||||||
|
|
||||||
|
#[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 directory, creating it if necessary.
|
||||||
|
async fn config() -> Result<PathBuf> {
|
||||||
|
let config = dirs::config_dir()
|
||||||
|
.ok_or(Error::Directory)?
|
||||||
|
.join(PathBuf::from("lowfi"));
|
||||||
|
|
||||||
|
if !config.exists() {
|
||||||
|
fs::create_dir_all(&config).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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()?
|
||||||
|
} else {
|
||||||
|
fs::write(&volume, "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 config = Self::config().await?;
|
||||||
|
let path = config.join(PathBuf::from("volume.txt"));
|
||||||
|
let percentage = (volume * 100.0).abs().round() as u16;
|
||||||
|
fs::write(path, percentage.to_string()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user