Compare commits

..

158 Commits
1.2.4 ... main

Author SHA1 Message Date
talwat
e702c1de00 docs: update feature list and fix some wording 2025-09-25 19:40:19 +02:00
talwat
8f837302c3 docs: update list of flags 2025-09-25 19:25:03 +02:00
talwat
226c674295 style: format code 2025-09-25 19:19:43 +02:00
talwat
66f2243b2c fix: don't crash if bookmarks.txt is missing
this was a one line fix that i just completely forgot about whilst improving the bookmarking system.
i feel very, very stupid.
2025-09-25 19:16:22 +02:00
Dario Griffo
05fe8069ea
docs: fix debian mirror url
Fix mirror url
2025-09-25 18:42:34 +02:00
talwat
9b61dffb12 chore: bump version due to mistake with scraper
yes, i messed up... it's fixed now.
2025-09-25 16:07:32 +02:00
talwat
c2530453fb chore: rescrape and update chillhop list 2025-09-25 16:07:07 +02:00
talwat
e4fd542edf chore: purge a few more songs from scraper 2025-09-25 16:03:32 +02:00
talwat
632b298de2 docs: properly link music.md 2025-09-25 15:50:26 +02:00
talwat
41ba98b9cf docs: add leading newline to MUSIC.md 2025-09-25 15:40:47 +02:00
Tal
0162421db4
feat: the 1.7.0 release (#97)
* docs: update to be relevant to the current version

* chore: bump version

* fix: change default progress to 0

why it was ever -1.0 is a mystery to me, it doesn't make any logical
sense...

* fix: switch from rand to fastrand

* feat: prepare for 1.7.0 release

docs: explain music situation
docs: more internal documentation
feat: make timeout configurable
chore: clean up some sections of code

* fix: use boring fs functions for bookmark loading and writing

* chore: remove useless internal doc

* chore: bump version
2025-09-25 15:39:20 +02:00
Lim Ding Wen
4d4f5e0920
docs: add Fedora installation dependencies (#98) 2025-09-12 16:16:28 +02:00
danielwerg
d41bd16069
chore(chillhop): remove tracks with lyrics (#99) 2025-09-12 11:01:20 +02:00
Dan
d2c8bdb8aa
chore: update README.md (#96)
* chore: Update README.md

updated the custom tracklists section with the proper command

* Update README.md
2025-09-06 20:57:02 +02:00
talwat
f6ec3bb1fe feat: switch to chillhop by default
feat: add special noheader exception for legibility of tracklists
feat: add small muted display
docs: fix macos instructions
2025-08-21 23:56:32 +02:00
Tal
6f679055ea chore: remove a few tracks with lyrics 2025-08-19 20:20:56 +02:00
Tal
1bb3ee2e02 fix: also split off letter ids 2025-08-10 17:18:29 +02:00
Tal
d60dc362ca feat: add percent loading indicator
chore: switch from inflector to convert case
chore: tweak timeout settings again
fix: make debug mode more useful by showing full track path
fix: strip url from reqwest errors
2025-08-10 16:22:37 +02:00
talwat
3e0cbf9871 fix: formatting touch ups and timeout adjustment 2025-08-10 00:27:37 +02:00
talwat
6f15f9226f feat: add archive scraper
feat: add more information to errors
2025-08-09 23:38:00 +02:00
Tal
bdd508bfbb
feat: chillhop scraper (#94)
* feat: start work on chillhop scraper

only the basic get request with caching has actually been implemented,
but the rest shouldn't be too complicated.

* feat: finish chillhop scraper

* chore: remove vscode dir

* chore: upload new chillhop list for comparison

* fix: improve scraper and remove duplicates

* style: reorganize release scan function slightly

* fix: make lowfi compile on non-linux

* feat: make scraper fully reproducable (hopefully)

* chore: remove useless mut

* feat: add scrape feature flag

* chore: update deps

* chore: i hate macos

* chore: add .DS_Store to gitignore

* fix: ignore two tracks with lyrics

* fix: seperate get function from chillhop scraper

* fix: linux audio output stream

* chore: replace chillhop old list
2025-08-09 19:18:50 +02:00
talwat
19ca315509 fix: make lowfi compile on non-linux 2025-08-08 20:12:13 +02:00
Tal
620b568926 fix(bookmarks): don't write to the bookmarks file on every bookmark 2025-08-07 00:08:11 +02:00
Tal
ad1fe84480 chore(tracks): remove tracks by kenji in the chillhop list for having lyrics 2025-08-06 15:43:00 +02:00
Tal
d81c4cced8 chore: bump version 2025-08-06 15:40:34 +02:00
Tal
1884d2ebed chore: even more error handling improvements 2025-08-06 15:34:44 +02:00
Tal
9f7c895154 fix: add byte len to prevent unknown track duration when decoding 2025-08-05 22:07:38 +02:00
Tal
0b7638468c fix: don't crash on tracklists with empty headers 2025-08-05 21:49:55 +02:00
Tal
2c9a8229da chore: make track list error more explicit 2025-08-05 21:31:23 +02:00
Tal
f6bea9f048 feat: experimental support for other file types
chore: migrate to rodio v0.21.1
feat: add a currently nonfunctional source argument to the scrape
command

well, this is an important commit for anyone who has some interest in
the workings of lowfi, so strap in.

rodio finally updated cpal, so that ugly unsafe bit of code to implement
send is no longer necessary since cpal did it themselves (a year ago)
they also removed output stream handles and some other things which
nobody really used anyway.

next, the other file types is literally not even code, just adding a
feature flag which enables the default rodio feature flags. i do feel a
bit silly for thinking it'd be super difficult, so thanks rodio.

lastly, the source flag is for a possible switch to the chillhop
tracklist. anything embedded into lowfi should be generated with an open
source script, hence if lowfi does switch to chillhop, it'll need to be
selectable.
2025-08-05 21:24:04 +02:00
Tal
cd4a371da1 docs: change june to july 2025-07-19 21:51:44 +02:00
Tal
05747b6537 docs: fix macos instructions 2025-07-19 20:43:40 +02:00
Tal
399ee25ef5 docs: add some instructions on using the chillhop list
thanks @danielwerg :)
2025-07-19 20:41:33 +02:00
talwat
3713056f70 docs: better wording 2025-07-10 15:56:08 +03:00
talwat
0dc1f72d97 docs: mention premade tracklists 2025-07-10 15:54:51 +03:00
azikx
cb886b344e
feat: add synthboy sounds list (#83)
* feat: add synthboy sounds list

* feat: synthboy rm duplicate

---------

Co-authored-by: nixzoid <nixzoid@jetpure.org>
2025-07-10 15:39:46 +03:00
Tal
69747ff8b4 docs: remove note about reliability under harsh conditions
i've done a few train rides with lowfi, and it's been great,
so this warning isn't super helpful.
2025-07-02 19:48:26 +02:00
Tal
0b15ce8e1b chore: give better names to track structs 2025-07-02 19:46:17 +02:00
Tal
6fadfe6304 chore: restructure and clean up 2025-07-02 19:36:53 +02:00
talwat
b6a81c9634 chore: refactor track error to use thiserror 2025-07-02 18:32:38 +02:00
talwat
1af976ad77 fix: begin work of improving error handling 2025-06-04 22:16:52 +02:00
talwat
e8b4b17f98 fix: bookmark writing 2025-06-04 14:09:41 +02:00
talwat
1a76699afc fix: improve bookmarks
my computer broke so this commit is probably of a below average quality,
since i'm just backing stuff up.
2025-05-08 17:46:40 +02:00
talwat
2ccf073646 feat: add fps flag 2025-05-05 13:16:01 +02:00
talwat
315fa105bf fix: don't start lowfi's UI unless in terminal 2025-05-01 09:46:17 +02:00
talwat
7cdd2e7694 feat: add star indicator for bookmarking 2025-04-23 14:14:49 +02:00
talwat
a89854e46f chore: remove test.txt 2025-04-23 14:00:54 +02:00
Tal
f1c6cbf026
docs: create ENVIRONMENT_VARS.md 2025-04-22 22:01:21 +02:00
talwat
d24c6b1a74 feat: implement basic bookmarking, still wip 2025-04-22 11:48:50 +02:00
talwat
a83a052ae9 docs: update disclaimer 2025-03-17 19:04:20 +01:00
talwat
a9cd30550c chore: bump version in preparation for 1.6.0 2025-03-17 18:34:11 +01:00
talwat
29dab7a77a docs: update flags list 2025-03-17 18:30:15 +01:00
talwat
fe70800502 docs: add fedora install instructions 2025-03-17 16:57:01 +01:00
talwat
d05f36a0bb chore: minor changes to internal docs 2025-03-17 16:54:16 +01:00
talwat
5db5146b8e feat: add option for fixed mpris name 2025-03-17 16:39:19 +01:00
talwat
34577efe8f feat: add buffer size option 2025-03-17 16:32:06 +01:00
talwat
968c1ee670 chore: formatting fix 2025-03-12 14:28:29 +01:00
talwat
bbdcfdd6f2 chore: bump version 2025-03-12 14:28:05 +01:00
talwat
8e843c12a2 docs: fix readme formatting 2025-03-12 14:23:26 +01:00
talwat
adcb20f2d0 fix: reform errors for track fetching 2025-03-12 14:21:37 +01:00
talwat
27fc505830 feat(internal): add extra flexibility to windows 2025-03-12 14:21:37 +01:00
Dario Griffo
66ccc44099
docs: add debian unofficial repository (#65)
* Add debian unofficial

* Update README.md
2025-03-12 07:10:14 +01:00
talwat
ca746c0902 chore: format & bump version 2025-03-03 15:06:27 +01:00
talwat
768f976e89 fix: allow for empty headers 2025-03-02 16:18:35 +01:00
talwat
84f386e0eb feat: add experimental local file loading in track lists 2025-02-28 19:07:22 +01:00
danielwerg
b68ce27d19
docs: fix typos and spelling (#62) 2025-02-27 08:14:51 +02:00
talwat
ed4b79d2bf chore: redo help menu descriptions 2025-02-26 14:45:02 +01:00
talwat
a4dd55fb28 fix: incorrect variable name on linux 2025-02-22 13:08:22 +01:00
talwat
3db4f9d402 chore: redo versioning 2025-02-22 10:49:22 +01:00
talwat
2a36bc72f3 fix: import necessary functions & types in silent_get_output_stream() 2025-02-21 22:06:56 +01:00
talwat
ce8f8d2845 fix: drop stream before aborting ui to prevent flicker 2025-02-21 21:50:20 +01:00
talwat
f0123fd2bc chore: add actual reason for sendableoutputstream allowing non send fields 2025-02-21 21:46:41 +01:00
talwat
ece88de1ae fix: tackle lots of clippy lints, along with removing outputstream from player struct 2025-02-21 21:44:32 +01:00
talwat
a720e9d2cf docs: mention space to pause 2025-02-20 10:29:37 +02:00
talwat
503b4fe9db docs(internal): add internal documentation for ui::flash_audio() 2025-02-19 23:48:31 +02:00
danielwerg
67a4c4f0ea
fix: show volume bar (mpris) (#59) 2025-02-19 23:43:14 +02:00
talwat
2b20bf7709 chore: specify album in MPRIS metadata, still untested 2025-02-18 22:09:16 +02:00
danielwerg
945b420cd8
chore: update chillhop track list (#56) 2025-02-18 21:50:07 +02:00
talwat
1e3c66679c fix: bug where text after ! in custom names wouldn't show up 2025-02-18 21:48:30 +02:00
talwat
840b1663e7 docs: tweak control documentation 2025-02-18 21:25:30 +02:00
danielwerg
7502d1cd17
docs: update controls (#58) 2025-02-18 20:20:00 +01:00
Beune
1480b62be9
feat: add mpv and vim-like keybindings (#57) 2025-02-17 20:49:06 +01:00
Tal
923ac05cf8 add functionality for custom track names 2025-02-15 12:51:53 +01:00
Tal
6a6823d078 feat: readd padding to borderless mode 2025-02-11 19:20:44 +01:00
Tal
1e491bb36f fix: make wiser use of the cfg macro 2025-02-09 15:58:08 +01:00
Tal
b87a525c74 feat: introduce -n for no borders 2025-02-09 15:38:33 +01:00
talwat
22a0851d40 chore: bump version 2025-01-14 11:13:31 +01:00
talwat
3db0623a72 fix: fix potential dangling pointer 2025-01-14 11:10:58 +01:00
Tal
22a2e7f986 chore: bump version 2025-01-07 22:34:34 +01:00
Tal
02a8e4f815 chore: update cargo lock 2025-01-07 22:33:49 +01:00
Tal
bc80a7e702 chore: bump version 2025-01-07 22:28:37 +01:00
Tal
07a4fc26c1 docs: fix strange wording in readme 2025-01-02 19:23:16 +01:00
Tal
5057721913 fix: use unicode-segmentation to fix issues with unicode track names 2025-01-02 19:20:31 +01:00
talwat
6ff41e0e34 chore: update dependencies 2024-12-03 14:16:31 +01:00
Tal
a39d903c68 feat: convert width to an arbitrary unit to avoid uneven setups 2024-11-26 10:55:15 +01:00
Tobias Clasen
a6f3eb034d
feat: allow variable width
* Added the feature described in issue #44

* Update components.rs with a comment
2024-11-26 10:40:57 +01:00
Tal
5f0f78f0e6 fix: readd list header to lofigirl list 2024-11-26 10:36:21 +01:00
Tal
41a9e32184 fix: make scrape command deterministic and update lofigirl list 2024-11-26 10:35:26 +01:00
Tal
a076c2b62f chore: bump version 2024-10-30 17:56:07 +01:00
Tal
56b03290d0 feat: make MPRIS bus suffixes unique 2024-10-27 14:17:30 +01:00
Tal
b12a7077a8 fix: tracklist shorthand not working 2024-10-27 13:20:38 +01:00
Tal
e7ac0c9214 docs: add more internal docs for mpris 2024-10-24 19:04:14 +02:00
Tal
ab288ee0d4 chore: bump version 2024-10-20 19:35:53 +02:00
Tal
fe9429bfb3 chore: reintroduce clippy & fix a lot of errors 2024-10-18 23:37:45 +02:00
talwat
04d5ed335d fix(mpris): handle edge case when paused 2024-10-17 23:58:20 +02:00
talwat
083d6c473c fix(mpris): fix initial playback status when there's a new track 2024-10-17 23:18:11 +02:00
talwat
0096eda4db docs: fix grammar mistake 2024-10-17 22:48:33 +02:00
talwat
cefb95e556 fix: only import mpris_server with the feature 2024-10-17 22:46:45 +02:00
talwat
978dfbc5e2 feat(mpris): wip implementation of propertieschanged signal 2024-10-17 22:33:00 +02:00
talwat
9414b97d62 chore: bump version 2024-10-17 21:34:54 +02:00
talwat
30af38a63a fix: ignore release key events 2024-10-17 21:33:23 +02:00
talwat
a64b10e20c fix: fix flickering on windows & abstract window drawing 2024-10-17 21:30:06 +02:00
talwat
71461a8d09 chore: update dependencies 2024-10-17 13:22:47 +02:00
Andrea Manzini
cb78b4909b
docs: add opensuse install instructions 2024-10-16 16:34:55 +02:00
talwat
ddc572b405 fix: refine loading indicator 2024-10-16 12:54:03 +02:00
talwat
120ac3f972 docs: mention openssl 2024-10-16 07:57:19 +02:00
talwat
6f9dab6aa8 docs: again go into more depth in internal documentation 2024-10-15 23:46:10 +02:00
Tal
e3e7c28ab0 docs: mention release binaries 2024-10-15 15:58:08 +02:00
Tal
301b831737 docs: improve internal documentation 2024-10-15 15:06:52 +02:00
Tal
ac9b196675 chore: bump version 2024-10-15 14:41:01 +02:00
Tal
10a3263c82 fix: use CString instead of new rust syntax to fix older versions of rust throwing compilation errors 2024-10-15 14:28:26 +02:00
Tal
543aeee78c
feat: add custom sources (#21)
* feat: initial work on decoupling embedded list file & the rest of the app

* chore: rename tracks.txt to lofigirl.txt

* fix: make base optional

* feat: partially revert previous commit

* feat: fix loading tracks with explicit url

* fix: include list in main player struct

* chore: reduce timeout

* chore: remove unused import

* fix: rename InitialProperties to PersistentVolume

* feat: move persistent volume init to player init function

* feat: add micropop.txt as an example of a custom tracklist

* docs: add note about mp3

* docs: move format of lists to list struct docs

* docs: document custom track lists

* fix: fix silly spelling error

* docs: update formatting

* docs: fix sample formatting

* docs: add missing sample track

* fix: fix ui when track name has special characters

* fix: use proper char counting on subject, not word

* fix: use unicode-segmentation to finally fix handling special characters

* fix: precompute track len

* fix: switch to using the unicode-width crate

* style: split off list into it's own module

* fix: move logic to read a list from the fs into the list module
2024-10-15 14:15:51 +02:00
Teemu Viikeri
b2c225256f
feat: add dynamic handling of play/pause state in bottom controls bar (#31) 2024-10-15 13:36:45 +02:00
danielwerg
8d9d003dc9
feat: add play and pause messages (#24) 2024-10-12 10:54:32 +02:00
Timoyoungster
fd8ecfcd05
docs: add description of available controls (#22)
* add description of available controls

add dedicated description for the available controls, since the volume control is not documented in the app.

* removing redundancy

* docs: fix casing

---------

Co-authored-by: Tal <83217276+talwat@users.noreply.github.com>
2024-10-11 20:06:43 +02:00
Tal
79a2b7da9c chore: bump 2024-10-08 20:05:05 +02:00
Timo Furrer
5eeee8069c
fix: properly trim volume content before stripping percentage sign (#18) 2024-10-08 16:11:50 +02:00
talwat
4207016e82 docs: fix formatting error 2024-10-07 22:47:32 +02:00
talwat
2a8754815c docs: update linux dependencies 2024-10-07 22:46:16 +02:00
Tal
e47ed1da94 feat: add -d flag 2024-10-07 21:32:57 +02:00
Tal
cc73fa5a37 feat: also clamp volume in mpris 2024-10-07 21:24:22 +02:00
Tal
41bba2d6e4 chore: bump version 2024-10-07 17:26:39 +02:00
Tal
724afd6fa7 fix: remove accidental debug display 2024-10-07 17:11:09 +02:00
Tal
6f7c2dbcba chore: improve readability of code relating to downloader thread 2024-10-07 17:09:22 +02:00
Timo Furrer
5b546ea2de
fix: trim whitespace from volume contents (#16) 2024-10-07 15:19:50 +02:00
Tal
d9ba0c3b3b fix: make autoplay actually function properly 2024-10-07 15:17:13 +02:00
talwat
d01673e0a7 docs: mention lowfi help 2024-10-07 14:40:39 +02:00
talwat
c7d46e9872 feat: add --pause flag 2024-10-07 14:39:08 +02:00
Tal
6b157dd457 feat: remember volume 2024-10-06 18:52:11 +02:00
Tal
901bf0e871
feat: support for mpris (#14)
* feat: initial support for mpris

* feat: overhaul app flow, making audio server control main thread instead of the ui thread

* fix: remove useless extra thread

* fix: last touches

* fix: call interface with minimalist flag

* fix: fix oversight when silencing alsa
2024-10-06 17:46:47 +02:00
talwat
baa2e095d9 feat: add minimalist flag 2024-10-06 09:36:06 +02:00
talwat
8f805d7119 fix: convert to lowercase so that inflector doesn't hallucinate, again 2024-10-06 00:46:03 +02:00
talwat
d1a56403c2 fix: update contractions 2024-10-05 23:59:50 +02:00
omnigenous
f79390b574
docs: add manual install section (#10) 2024-10-05 12:34:54 +02:00
talwat
badc93a4fa docs: whoops, SSL not SSH 2024-10-04 11:38:12 +02:00
talwat
2d785ee47e docs: mention OpenSSH & alsa 2024-10-04 11:37:52 +02:00
talwat
0ef844a3f2 feat: improve slightly media key support 2024-10-04 00:09:39 +02:00
Tal
706ba97428 feat: add wip support for media keys 2024-10-03 23:19:00 +02:00
Tal
86f88ff34f chore: bump 2024-10-03 18:05:38 +02:00
Tal
b85d8c0be2 feat: improve UI flexibility if resizing is implemented 2024-10-03 18:05:22 +02:00
talwat
9741d4b0d5 chore: bump version 2024-10-03 14:29:46 +02:00
Samsu
49e7191369
feat: fine grained volume adjustment (#8) 2024-10-03 14:27:27 +02:00
talwat
a414c5e9f4 chore: move ui components into their own file 2024-10-03 08:40:10 +02:00
Tal
6b85a83749 fix: only run the hack on linux targets 2024-10-02 22:58:41 +02:00
Tal
c2baa53ded fix: finally fix the alsa warnings with libc's freopen 2024-10-02 22:56:01 +02:00
Tal
5d2e6c6d23 fix: switch to using stdout 2024-10-02 22:07:02 +02:00
Tal
60a00f189e chore: bump version 2024-10-02 21:22:19 +02:00
Brendan Mesters
86f3f56edb
feat: add volume control & display (#4)
* feat: added volume control

I added a simple volume control to the program, using native
functionality in `rodio::sink`.

This logic has also been linked through to the UI so that users will be
aware of this fucntionality (bound to the '-' and '+' keys) and adding a
volume readout to the UI as well.

* feat: add volume bindings which work without shift

A small issue I noticed I had was that I had to press shift to hit '+',
I now bound the volume up fucntionality to '+' and '=' and the volume
down functionality to '-' and '_', to make both undependant of shift
(assuming most default western keyboard layouts)

* feat: support arrow keys

* feat: add temporarily appearing audio bar

* fix: polish input controls

---------

Co-authored-by: talwat <83217276+talwat@users.noreply.github.com>
2024-10-02 21:20:16 +02:00
36 changed files with 9430 additions and 1897 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
/target
/cache
.DS_Store

29
CHILLHOP.md Normal file
View 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`.

2228
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "lowfi"
version = "1.2.4"
version = "1.7.2"
edition = "2021"
description = "An extremely simple lofi player."
license = "MIT"
@ -16,27 +16,49 @@ documentation = "https://github.com/talwat/lowfi"
homepage = "https://github.com/talwat/lowfi"
repository = "https://github.com/talwat/lowfi"
[features]
mpris = ["dep:mpris-server"]
extra-audio-formats = ["rodio/default"]
scrape = ["dep:serde", "dep:serde_json", "dep:html-escape", "dep:scraper", "dep:indicatif"]
[dependencies]
# Basics
clap = { version = "4.5.18", features = ["derive", "cargo"] }
eyre = { version = "0.6.12" }
rand = "0.8.5"
clap = { version = "4.5.21", features = ["derive", "cargo"] }
eyre = "0.6.12"
fastrand = "2.3.0"
thiserror = "2.0.12"
color-eyre = { version = "0.6.5", default-features = false }
# Async
tokio = { version = "1.40.0", features = [
"macros",
"rt-multi-thread",
], default-features = false }
futures = "0.3.30"
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "fs"], default-features = false }
futures = "0.3.31"
arc-swap = "1.7.1"
# Data
reqwest = "0.12.7"
bytes = "1.7.2"
reqwest = { version = "0.12.9", features = ["stream"] }
bytes = "1.9.0"
# I/O
crossterm = { version = "0.29.0", features = ["event-stream"] }
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
mpris-server = { version = "0.8.1", optional = true }
dirs = "6.0.0"
# Misc
scraper = "0.20.0"
rodio = { version = "0.19.0", features = ["symphonia-mp3"], default-features = false }
crossterm = "0.28.1"
Inflector = "0.11.4"
convert_case = "0.8.0"
lazy_static = "1.5.0"
url = "2.5.4"
unicode-segmentation = "1.12.0"
# Scraper
serde = { version = "1.0.219", features = ["derive"], optional = true }
serde_json = { version = "1.0.142", optional = true }
scraper = { version = "0.21.0", optional = true }
html-escape = { version = "0.2.13", optional = true }
indicatif = { version = "0.18.0", optional = true }
regex = "1.11.1"
atomic_float = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.167"

7
ENVIRONMENT_VARS.md Normal file
View 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 behaviour 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
View 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 behaviour, 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.

238
README.md
View File

@ -7,68 +7,250 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
## Disclaimer
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
under their [licensing guidelines](https://form.lofigirl.com/CommercialLicense).
If god forbid you're planning to use this in a commercial setting, please
follow their rules.
As of the 1.7.0 version of lowfi, **all** of the audio files embedded
by default are from [chillhop](https://chillhop.com/). Read
[MUSIC.md](MUSIC.md) for more information.
## Why?
I really hate modern music platforms, and I wanted a small, "suckless"
app that would literally just play lofi without video so I could use it
whenever.
I really hate modern music platforms, and I wanted a small, simple
app that would just play random ambient music without video and other fluff.
I also wanted it to be fairly resiliant to inconsistent networks,
so 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.
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.
## Installing
> [!NOTE]
>
> If you're interested in maintaining a package for `lowfi`
> on package managers such as homebrew and the like, open an issue.
### Dependencies
You'll need Rust 1.74.0+.
On MacOS & Windows, no extra dependencies are needed.
On Linux, you'll also need openssl & alsa, as well as their headers.
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu, `alsa-lib-devel` on Fedora.
- `openssl` on Arch, `libssl-dev` on Ubuntu, `openssl-devel` on Fedora.
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
### Cargo
The recommended installation method is to use cargo:
```sh
cargo install lowfi
# If you want MPRIS support.
cargo install lowfi --features mpris
```
and making sure `$HOME/.cargo/bin` is added to `$PATH`.
### AUR
### Release Binaries
If you're on Arch, you can also use the AUR:
If you're struggling or unwilling to use cargo, you can just download
precompiled binaries from the [latest release](https://github.com/talwat/lowfi/releases/latest).
### AUR
```sh
yay -S lowfi
```
### openSUSE
```sh
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
This is good for debugging, especially in issues.
```sh
git clone https://github.com/talwat/lowfi
cd lowfi
# If you want an actual binary
cargo build --release --all-features
./target/release/lowfi
# If you just want to test
cargo run --all-features
```
## Usage
`lowfi`
Yeah, that's it. Controls are documented in the app.
Yeah, that's it.
### Controls
| Key | Function |
| ------------------ | --------------- |
| `s`, `n`, `l` | Skip Song |
| `p`, Space | Play/Pause |
| `+`, `=`, `k`, `↑` | Volume Up 10% |
| `→` | Volume Up 1% |
| `-`, `_`, `j`, `↓` | Volume Down 10% |
| `←` | Volume Down 1% |
| `q`, CTRL+C | Quit |
| `b` | Bookmark |
> [!NOTE]
> Besides its regular controls, lowfi offers compatibility with Media Keys
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
>
> MPRIS is currently an optional feature in cargo (enabled with `--features mpris`)
> due to it being only for Linux, as well as the fact that the main point of
> lowfi is it's unique & minimal interface.
### Extra Flags
If you're having issues on Linux with error messages,
or you don't want your terminal history to be visible when you
run lofi, you can use the `--alternate` or `-a` flag to
hide your history.
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`.
### Scraping
| Flag | Function |
| ----------------------------------- | --------------------------------------------------- |
| `-a`, `--alternate` | Use an alternate terminal screen |
| `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI |
| `-p`, `--paused` | Start lowfi paused |
| `-f`, `--fps` | FPS of the UI [default: 12] |
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
| `-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] |
lowfi also has a `scrape` command which is usually not relevant, but
if you're trying to download some files from Lofi Girls' website,
it can be useful.
### Extra Features
An example of scrape is as follows,
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,
like those which are only expected to be used by a handful of users.
`lowfi scrape --extension zip --include-full`
#### `scrape` - Scraping
where more information can be found by running `lowfi help scrape`.
This feature provides the `scrape` command.
It's usually not very useful, but is included for transparency's sake.
More information can be found by running `lowfi help scrape`.
#### `mpris` - MPRIS
Enables MPRIS. It's not rocket science.
#### `extra-audio-formats` - Extra Audio Formats
This is only relevant to those using a custom track list, in which case
it allows for more formats than just MP3. Those are FLAC, Vorbis, and WAV.
These should be sufficient for some 99% of music files people might want to play.
If you 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
> [!NOTE]
> Some nice users, especially [danielwerg](https://github.com/danielwerg),
> have aleady 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.
>
> Feel free to contribute your own list with a PR.
lowfi also supports custom track lists, although the default one from chillhop
is embedded into the binary.
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
directory.
> [!NOTE]
> 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.
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
In lists, the first line is what's known as the header, followed by the rest of the tracks.
Each track will be first appended to the header, and then use the combination to download
the track.
> [!NOTE]
> lowfi _will not_ put a `/` between the base & track for added flexibility,
> so for most cases you should have a trailing `/` in your header.
The exception to this is if the track name begins with a protocol like `https://`,
in which case the base will not be prepended to it. If all of your tracks are like this,
then you can put `noheader` as the first line and not have a header at all.
For example, in this list:
```txt
https://lofigirl.com/wp-content/uploads/
2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/04/2-In-Front-Of-Me.mp3
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
```
lowfi would download these three URLs:
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
- `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
Additionally, you may also specify a custom display name for the track which is indicated by a `!`.
For example, if you had an entry like this:
```txt
2023/04/2-In-Front-Of-Me.mp3!custom 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

File diff suppressed because it is too large Load Diff

1358
data/chillhop.txt Normal file

File diff suppressed because it is too large Load Diff

2
data/file.txt Normal file
View File

@ -0,0 +1,2 @@
file:///home/user/Music/
Test.mp3

2
data/noheader.txt Normal file
View File

@ -0,0 +1,2 @@
noheader
https://stream.chillhop.com/mp3/9476

File diff suppressed because it is too large Load Diff

115
data/old/synthboy.txt Normal file
View 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

4
data/sample.txt Normal file
View File

@ -0,0 +1,4 @@
https://lofigirl.com/wp-content/uploads/
2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/04/2-In-Front-Of-Me.mp3
https://stream.chillhop.com/mp3/9476

4
scripts/fix_cache.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
grep -rlZ "429 Too Many Requests" . | xargs -0 rm -f
find . -type f -empty -delete

View File

@ -1,18 +1,67 @@
use clap::{Parser, Subcommand};
//! An extremely simple lofi player.
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod messages;
mod play;
mod player;
mod scrape;
mod tracks;
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
#[cfg(feature = "scrape")]
mod scrapers;
#[cfg(feature = "scrape")]
use crate::scrapers::Source;
/// An extremely simple lofi player.
#[derive(Parser)]
#[derive(Parser, Clone)]
#[command(about, version)]
#[allow(clippy::struct_excessive_bools)]
struct Args {
/// Whether to use an alternate terminal screen.
/// Use an alternate terminal screen.
#[clap(long, short)]
alternate: bool,
/// Hide the bottom control bar.
#[clap(long, short)]
minimalist: bool,
/// Exclude borders in UI.
#[clap(long, short)]
borderless: bool,
/// Start lowfi paused.
#[clap(long, short)]
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 = 3)]
timeout: u64,
/// Include ALSA & other logs.
#[clap(long, short)]
debug: bool,
/// Width of the player, from 0 to 32.
#[clap(long, short, default_value_t = 3)]
width: usize,
/// Use a custom track list
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
track_list: Option<String>,
/// Internal song buffer size.
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
buffer_size: usize,
/// The command that was ran.
/// This is [None] if no command was specified.
#[command(subcommand)]
@ -20,32 +69,43 @@ struct Args {
}
/// Defines all of the extra commands lowfi can run.
#[derive(Subcommand)]
#[derive(Subcommand, Clone)]
enum Commands {
/// Scrapes the lofi girl website file server for files.
/// Scrapes a music source for files.
#[cfg(feature = "scrape")]
Scrape {
/// The file extension to search for, defaults to mp3.
#[clap(long, short, default_value = "mp3")]
extension: String,
/// Whether to include the full HTTP URL or just the distinguishing part.
#[clap(long, short)]
include_full: bool,
// The source to scrape from.
source: scrapers::Source,
},
}
/// Gets lowfi's data directory.
pub fn data_dir() -> eyre::Result<PathBuf, player::Error> {
let dir = dirs::data_dir()
.ok_or(player::Error::DataDir)?
.join("lowfi");
Ok(dir)
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
color_eyre::install()?;
let cli = Args::parse();
if let Some(command) = cli.command {
match command {
Commands::Scrape {
extension,
include_full,
} => scrape::scrape(extension, include_full).await,
#[cfg(feature = "scrape")]
Commands::Scrape { source } => match source {
Source::Archive => scrapers::archive::scrape().await?,
Source::Lofigirl => scrapers::lofigirl::scrape().await?,
Source::Chillhop => scrapers::chillhop::scrape().await?,
},
}
} else {
play::play(cli.alternate).await
}
play::play(cli).await?;
};
Ok(())
}

37
src/messages.rs Normal file
View File

@ -0,0 +1,37 @@
/// Handles communication between the frontend & audio player.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum Message {
/// 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),
/// Bookmark the current track.
Bookmark,
/// Quits gracefully.
Quit,
}

View File

@ -1,38 +1,78 @@
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
use std::{io::stderr, sync::Arc};
use crossterm::{cursor::SavePosition, terminal};
use tokio::{
sync::mpsc::{self},
task::{self},
};
use crossterm::cursor::Show;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::terminal::{self, Clear, ClearType};
use std::io::{stdout, IsTerminal};
use std::process::exit;
use std::sync::Arc;
use std::{env, panic};
use tokio::{sync::mpsc, task};
use crate::messages::Message;
use crate::player::persistent_volume::PersistentVolume;
use crate::player::Player;
use crate::player::{ui, Messages};
use crate::player::{self, ui};
use crate::Args;
/// Initializes the audio server, and then safely stops
/// it when the frontend quits.
pub async fn play(alternate: bool) -> eyre::Result<()> {
// Save the position. This is important since later on we can revert to this position
// and clear any potential error messages that may have showed up.
// TODO: Figure how to set some sort of flag to hide error messages within rodio,
// TODO: Instead of just ignoring & clearing them after.
crossterm::execute!(stderr(), SavePosition)?;
pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
// TODO: This isn't a great way of doing things,
// but it's better than vanilla behaviour at least.
let eyre_hook = panic::take_hook();
// Enable raw mode early in theory to prevent uncontrolled text in the terminal from the user.
terminal::enable_raw_mode()?;
panic::set_hook(Box::new(move |x| {
let mut lock = stdout().lock();
crossterm::execute!(
lock,
Clear(ClearType::FromCursorDown),
Show,
PopKeyboardEnhancementFlags
)
.unwrap();
terminal::disable_raw_mode().unwrap();
eyre_hook(x);
exit(1)
}));
// 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 = if stdout().is_terminal() && !(env::var("LOWFI_DISABLE_UI") == Ok("1".to_owned())) {
Some(task::spawn(ui::start(
Arc::clone(&player),
tx.clone(),
args.clone(),
)))
} else {
None
};
let player = Arc::new(Player::new().await?);
let audio = task::spawn(Player::play(Arc::clone(&player), tx.clone(), rx));
tx.send(Messages::Init).await?;
// Sends the player an "init" signal telling it to start playing a song straight away.
tx.send(Message::Init).await?;
ui::start(Arc::clone(&player), tx.clone(), alternate).await?;
// Actually starts the player.
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
audio.abort();
// Save the volume.txt file for the next session.
PersistentVolume::save(player.sink.volume())
.await
.map_err(player::Error::PersistentVolumeSave)?;
// Save the bookmarks for the next session.
player.bookmarks.save().await?;
drop(stream);
player.sink.stop();
if let Some(x) = ui {
x.abort();
}
Ok(())
}

View File

@ -5,9 +5,10 @@
use std::{collections::VecDeque, sync::Arc, time::Duration};
use arc_swap::ArcSwapOption;
use atomic_float::AtomicF32;
use downloader::Downloader;
use reqwest::Client;
use rodio::{OutputStream, OutputStreamHandle, Sink};
use rodio::{OutputStream, OutputStreamBuilder, Sink};
use tokio::{
select,
sync::{
@ -17,173 +18,297 @@ use tokio::{
task,
};
use crate::tracks::{DecodedTrack, Track, TrackInfo};
#[cfg(feature = "mpris")]
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
use crate::{
messages::Message,
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
tracks::{self, list::List},
Args,
};
pub mod audio;
pub mod bookmark;
pub mod downloader;
pub mod error;
pub mod persistent_volume;
pub mod queue;
pub mod ui;
/// Handles communication between the frontend & audio player.
pub enum Messages {
/// Notifies the audio server that it should update the track.
Next,
pub use error::Error;
/// 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,
/// Pauses the [Sink]. This will also unpause it if it is paused.
Pause,
}
const TIMEOUT: Duration = Duration::from_secs(8);
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Main struct responsible for queuing up & playing tracks.
// 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`.
// TODO:
// 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 {
/// [rodio]'s [`Sink`] which can control playback.
pub sink: Sink,
/// The internal buffer size.
pub buffer_size: usize,
/// The [`TrackInfo`] of the current track.
/// This is [`None`] when lowfi is buffering.
pub current: ArcSwapOption<TrackInfo>,
/// This is [`None`] when lowfi is buffering/loading.
current: ArcSwapOption<tracks::Info>,
/// The tracks, which is a [VecDeque] that holds
/// The current progress for downloading tracks, if
/// `current` is None.
progress: AtomicF32,
/// The tracks, which is a [`VecDeque`] that holds
/// *undecoded* [Track]s.
tracks: RwLock<VecDeque<Track>>,
///
/// This is populated specifically by the [Downloader].
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
/// The web client, which can contain a UserAgent & some
/// The bookmarks, which are saved on quit.
pub bookmarks: Bookmarks,
/// The timeout for track downloads, as a [Duration].
timeout: Duration,
/// The actual list of tracks to be played.
list: List,
/// The initial volume level.
volume: PersistentVolume,
/// The web client, which can contain a `UserAgent` & some
/// settings that help lowfi work more effectively.
client: Client,
/// The [OutputStreamHandle], which also can control some
/// playback, is for now unused and is here just to keep it
/// alive so the playback can function properly.
_handle: OutputStreamHandle,
/// The [OutputStream], which is just here to keep the playback
/// alive and functioning.
_stream: OutputStream,
}
/// SAFETY: This is necessary because [OutputStream] does not implement [Send],
/// SAFETY: even though it is perfectly possible.
unsafe impl Send for Player {}
/// SAFETY: See implementation for [Send].
unsafe impl Sync for Player {}
impl Player {
/// Initializes the entire player, including audio devices & sink.
pub async fn new() -> eyre::Result<Self> {
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
Ok(Self {
tracks: RwLock::new(VecDeque::with_capacity(5)),
current: ArcSwapOption::new(None),
client: Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(TIMEOUT)
.build()?,
sink,
_handle: handle,
_stream,
})
}
/// Just a shorthand for setting `current`.
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
fn set_current(&self, info: tracks::Info) {
self.current.store(Some(Arc::new(info)));
Ok(())
}
/// This will play the next track, as well as refilling the buffer in the background.
pub async fn next(queue: Arc<Self>) -> eyre::Result<DecodedTrack> {
let track = match queue.tracks.write().await.pop_front() {
Some(x) => x,
// If the queue is completely empty, then fallback to simply getting a new track.
// This is relevant particularly at the first song.
None => Track::random(&queue.client).await?,
/// 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, OutputStream), player::Error> {
// Load the bookmarks.
let bookmarks = Bookmarks::load().await?;
// Load the volume file.
let volume = PersistentVolume::load()
.await
.map_err(player::Error::PersistentVolumeLoad)?;
// Load the track list.
let list = List::load(args.track_list.as_ref())
.await
.map_err(player::Error::TrackListLoad)?;
// We should only shut up alsa forcefully on Linux if we really have to.
#[cfg(target_os = "linux")]
let mut stream = if !args.alternate && !args.debug {
audio::silent_get_output_stream()?
} else {
OutputStreamBuilder::open_default_stream()?
};
let decoded = track.decode()?;
queue.set_current(decoded.info.clone()).await?;
#[cfg(not(target_os = "linux"))]
let mut stream = OutputStreamBuilder::open_default_stream()?;
Ok(decoded)
stream.log_on_drop(false); // Frankly, this is a stupid feature. Stop shoving your crap into my beloved stderr!!!
let sink = Sink::connect_new(stream.mixer());
if args.paused {
sink.pause();
}
let client = Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(args.timeout * 5))
.build()?;
let player = Self {
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
buffer_size: args.buffer_size,
current: ArcSwapOption::new(None),
progress: AtomicF32::new(0.0),
timeout: Duration::from_secs(args.timeout),
bookmarks,
client,
sink,
volume,
list,
};
Ok((player, stream))
}
/// This is the main "audio server".
///
/// `rx` & `ts` are used to communicate with it, for example when to
/// `rx` & `tx` are used to communicate with it, for example when to
/// skip tracks or pause.
///
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
/// The [Downloader]s internal buffer size is determined by `buf_size`.
pub async fn play(
player: Arc<Self>,
tx: Sender<Messages>,
mut rx: Receiver<Messages>,
) -> eyre::Result<()> {
tx: Sender<Message>,
mut rx: Receiver<Message>,
debug: bool,
) -> eyre::Result<(), player::Error> {
// Initialize the mpris player.
//
// 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, itx) = Downloader::new(player.clone());
downloader.start().await;
let downloader = Downloader::new(Arc::clone(&player));
let (itx, downloader) = downloader.start(debug);
// Start buffering tracks immediately.
itx.send(()).await?;
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! {
Some(x) = rx.recv() => x,
let msg = select! {
biased;
Some(x) = rx.recv() => x,
// This future will finish only at the end of the current track.
Ok(_) = task::spawn_blocking(move || clone.sink.sleep_until_end()) => Messages::Next,
// 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 => Message::Next,
};
match msg {
Messages::Next | Messages::Init | Messages::TryAgain => {
// Skip as early as possible so that music doesn't play
// while lowfi is "loading".
player.sink.stop();
Message::Next | Message::Init | Message::TryAgain => {
// We manually skipped, so we shouldn't actually wait for the song
// to be over until we recieve the `NewSong` signal.
new = false;
// Serves as an indicator that the queue is "loading".
// This is also set by Player::next.
player.current.store(None);
// This basically just prevents `Next` while a song is still currently loading.
if msg == Message::Next && !player.current_exists() {
continue;
}
let track = Self::next(Arc::clone(&player)).await;
match track {
Ok(track) => {
player.sink.append(track.data);
// Notify the background downloader that there's an empty spot
// in the buffer.
itx.send(()).await?;
}
Err(error) => {
if !error.downcast::<reqwest::Error>()?.is_timeout() {
tokio::time::sleep(TIMEOUT).await;
}
tx.send(Messages::TryAgain).await?
}
};
// Handle the rest of the signal in the background,
// as to not block the main audio server thread.
task::spawn(Self::next(
Arc::clone(&player),
itx.clone(),
tx.clone(),
debug,
));
}
Messages::Pause => {
Message::Play => {
player.sink.play();
#[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Playing).await?;
}
Message::Pause => {
player.sink.pause();
#[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Paused).await?;
}
Message::PlayPause => {
if player.sink.is_paused() {
player.sink.play();
} else {
player.sink.pause();
}
#[cfg(feature = "mpris")]
mpris
.playback(mpris.player().playback_status().await?)
.await?;
}
Message::ChangeVolume(change) => {
player.set_volume(player.sink.volume() + change);
#[cfg(feature = "mpris")]
mpris
.changed(vec![Property::Volume(player.sink.volume().into())])
.await?;
}
// This basically just continues, but more importantly, it'll re-evaluate
// the select macro at the beginning of the loop.
// See the top section to find out why this matters.
Message::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;
}
Message::Bookmark => {
let current = player.current.load();
let current = current.as_ref().unwrap();
player.bookmarks.bookmark(current).await?;
}
Message::Quit => break,
}
}
downloader.abort();
Ok(())
}
}

40
src/player/audio.rs Normal file
View File

@ -0,0 +1,40 @@
/// 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")]
pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::player::Error> {
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)
}

107
src/player/bookmark.rs Normal file
View File

@ -0,0 +1,107 @@
//! Module for handling saving, loading, and adding
//! bookmarks.
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use tokio::sync::RwLock;
use tokio::{fs, io};
use crate::{data_dir, tracks};
/// Errors that might occur while managing bookmarks.
#[derive(Debug, thiserror::Error)]
pub enum BookmarkError {
#[error("data directory not found")]
DataDir,
#[error("io failure")]
Io(#[from] io::Error),
}
/// Manages the bookmarks in the current player.
pub struct Bookmarks {
/// The different entries in the bookmarks file.
entries: RwLock<Vec<String>>,
/// The internal bookmarked register, which keeps track
/// of whether a track is bookmarked or not.
///
/// This is much more efficient than checking every single frame.
bookmarked: AtomicBool,
}
impl Bookmarks {
/// Gets the path of the bookmarks file.
pub async fn path() -> eyre::Result<PathBuf, BookmarkError> {
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
fs::create_dir_all(data_dir.clone()).await?;
Ok(data_dir.join("bookmarks.txt"))
}
/// Loads bookmarks from the `bookmarks.txt` file.
pub async fn load() -> eyre::Result<Self, BookmarkError> {
let text = fs::read_to_string(Self::path().await?)
.await
.unwrap_or_default();
let lines: Vec<String> = text
.trim_start_matches("noheader")
.trim()
.lines()
.filter_map(|x| {
if x.is_empty() {
None
} else {
Some(x.to_string())
}
})
.collect();
Ok(Self {
entries: RwLock::new(lines),
bookmarked: AtomicBool::new(false),
})
}
// Saves the bookmarks to the `bookmarks.txt` file.
pub async fn save(&self) -> eyre::Result<(), BookmarkError> {
let text = format!("noheader\n{}", self.entries.read().await.join("\n"));
fs::write(Self::path().await?, text).await?;
Ok(())
}
/// Bookmarks a given track with a full path and optional custom name.
///
/// Returns whether the track is now bookmarked, or not.
pub async fn bookmark(&self, track: &tracks::Info) -> eyre::Result<(), BookmarkError> {
let entry = track.to_entry();
let idx = self.entries.read().await.iter().position(|x| **x == entry);
if let Some(idx) = idx {
self.entries.write().await.remove(idx);
} else {
self.entries.write().await.push(entry);
};
self.bookmarked
.swap(idx.is_none(), std::sync::atomic::Ordering::Relaxed);
Ok(())
}
/// Returns whether a track is bookmarked or not by using the internal
/// bookmarked register.
pub fn bookmarked(&self) -> bool {
self.bookmarked.load(std::sync::atomic::Ordering::Relaxed)
}
/// Sets the internal bookmarked register by checking against
/// the current track's info.
pub async fn set_bookmarked(&self, track: &tracks::Info) {
let val = self.entries.read().await.contains(&track.to_entry());
self.bookmarked
.swap(val, std::sync::atomic::Ordering::Relaxed);
}
}

View File

@ -1,15 +1,14 @@
//! Contains the [`Downloader`] struct.
use std::sync::Arc;
use std::{error::Error, sync::Arc};
use tokio::{
sync::mpsc::{self, Receiver, Sender},
task,
task::{self, JoinHandle},
time::sleep,
};
use crate::tracks::Track;
use super::{Player, BUFFER_SIZE};
use super::Player;
/// This struct is responsible for downloading tracks in the background.
///
@ -21,32 +20,59 @@ pub struct Downloader {
/// 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, Sender<()>) {
pub fn new(player: Arc<Player>) -> Self {
let (tx, rx) = mpsc::channel(8);
(Self { player, rx }, tx)
Self { player, rx, tx }
}
/// Push a new, random track onto the internal buffer.
pub async fn push_buffer(&self, debug: bool) {
let data = self.player.list.random(&self.player.client, None).await;
match data {
Ok(track) => self.player.tracks.write().await.push_back(track),
Err(error) => {
if debug {
panic!("{error} - {:?}", error.source())
}
if !error.is_timeout() {
sleep(self.player.timeout).await;
}
}
}
}
/// Actually starts & consumes the [Downloader].
pub async fn start(mut self) {
task::spawn(async move {
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
let tx = self.tx.clone();
let handle = 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 Ok(track) = Track::random(&self.player.client).await else {
continue;
};
self.player.tracks.write().await.push_back(track);
while self.player.tracks.read().await.len() < self.player.buffer_size {
self.push_buffer(debug).await;
}
}
});
(tx, handle)
}
}

51
src/player/error.rs Normal file
View File

@ -0,0 +1,51 @@
use std::ffi::NulError;
use crate::{messages::Message, player::bookmark::BookmarkError};
use tokio::sync::mpsc::error::SendError;
#[cfg(feature = "mpris")]
use mpris_server::zbus::{self, fdo};
/// Any errors which might occur when running or initializing the lowfi player.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("unable to load the persistent volume")]
PersistentVolumeLoad(eyre::Error),
#[error("unable to save the persistent volume")]
PersistentVolumeSave(eyre::Error),
#[error("sending internal message failed")]
Communication(#[from] SendError<Message>),
#[error("unable to load track list")]
TrackListLoad(eyre::Error),
#[error("interfacing with audio failed")]
Stream(#[from] rodio::StreamError),
#[error("NUL error, if you see this, something has gone VERY wrong")]
Nul(#[from] NulError),
#[error("unable to send or prepare network request")]
Reqwest(#[from] reqwest::Error),
#[cfg(feature = "mpris")]
#[error("mpris bus error")]
ZBus(#[from] zbus::Error),
// TODO: This has a terrible error message, mainly because I barely understand
// what this error even represents. What does fdo mean?!?!? Why, MPRIS!?!?
#[cfg(feature = "mpris")]
#[error("mpris fdo (zbus interface) error")]
Fdo(#[from] fdo::Error),
#[error("unable to notify downloader")]
DownloaderNotify(#[from] SendError<()>),
#[error("unable to find data directory")]
DataDir,
#[error("bookmarking load/unload failed")]
Bookmark(#[from] BookmarkError),
}

281
src/player/mpris.rs Normal file
View File

@ -0,0 +1,281 @@
//! Contains the code for the MPRIS server & other helper functions.
use std::{env, process, sync::Arc};
use mpris_server::{
zbus::{self, fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Time, TrackId, Volume,
};
use tokio::sync::mpsc::Sender;
use super::ui;
use super::Message;
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
/// The actual MPRIS player.
pub struct Player {
/// A reference to the [`super::Player`] itself.
pub player: Arc<super::Player>,
/// The audio server sender, which is used to communicate with
/// the audio sender for skips and a few other inputs.
pub sender: Sender<Message>,
}
impl RootInterface for Player {
async fn raise(&self) -> fdo::Result<()> {
Err(ERROR)
}
async fn quit(&self) -> fdo::Result<()> {
self.sender
.send(Message::Quit)
.await
.map_err(|_error| ERROR)
}
async fn can_quit(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_fullscreen(&self, _: bool) -> Result<()> {
Ok(())
}
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_raise(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn has_track_list(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn identity(&self) -> fdo::Result<String> {
Ok("lowfi".to_owned())
}
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok("dev.talwat.lowfi".to_owned())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["https".to_owned()])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["audio/mpeg".to_owned()])
}
}
impl PlayerInterface for Player {
async fn next(&self) -> fdo::Result<()> {
self.sender
.send(Message::Next)
.await
.map_err(|_error| ERROR)
}
async fn previous(&self) -> fdo::Result<()> {
Err(ERROR)
}
async fn pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::Pause)
.await
.map_err(|_error| ERROR)
}
async fn play_pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::PlayPause)
.await
.map_err(|_error| ERROR)
}
async fn stop(&self) -> fdo::Result<()> {
self.pause().await
}
async fn play(&self) -> fdo::Result<()> {
self.sender
.send(Message::Play)
.await
.map_err(|_error| ERROR)
}
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
Err(ERROR)
}
async fn set_position(&self, _track_id: TrackId, _position: Time) -> fdo::Result<()> {
Err(ERROR)
}
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
Err(ERROR)
}
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
Ok(if !self.player.current_exists() {
PlaybackStatus::Stopped
} else if self.player.sink.is_paused() {
PlaybackStatus::Paused
} else {
PlaybackStatus::Playing
})
}
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
Err(ERROR)
}
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
Ok(())
}
async fn rate(&self) -> fdo::Result<PlaybackRate> {
Ok(self.player.sink.speed().into())
}
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
self.player.sink.set_speed(rate as f32);
Ok(())
}
async fn shuffle(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
Ok(())
}
async fn metadata(&self) -> fdo::Result<Metadata> {
let metadata = self
.player
.current
.load()
.as_ref()
.map_or_else(Metadata::new, |track| {
let mut metadata = Metadata::builder()
.title(track.display_name.clone())
.album(self.player.list.name.clone())
.build();
metadata.set_length(
track
.duration
.map(|x| Time::from_micros(x.as_micros() as i64)),
);
metadata
});
Ok(metadata)
}
async fn volume(&self) -> fdo::Result<Volume> {
Ok(self.player.sink.volume().into())
}
async fn set_volume(&self, volume: Volume) -> Result<()> {
self.player.set_volume(volume as f32);
ui::flash_audio();
Ok(())
}
async fn position(&self) -> fdo::Result<Time> {
Ok(Time::from_micros(
self.player.sink.get_pos().as_micros() as i64
))
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(0.2f64)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(3.0f64)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_play(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_pause(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_seek(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_control(&self) -> fdo::Result<bool> {
Ok(true)
}
}
/// A struct which contains the MPRIS [Server], and has some helper functions
/// to make it easier to work with.
pub struct Server {
/// The inner MPRIS server.
inner: mpris_server::Server<Player>,
}
impl Server {
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
pub async fn changed(
&self,
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
) -> zbus::Result<()> {
self.inner.properties_changed(properties).await
}
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
pub async fn playback(&self, new: PlaybackStatus) -> zbus::Result<()> {
self.inner
.properties_changed(vec![Property::PlaybackStatus(new)])
.await
}
/// Shorthand to get the inner mpris player object.
pub fn player(&self) -> &Player {
self.inner.imp()
}
/// Creates a new MPRIS server.
pub async fn new(
player: Arc<super::Player>,
sender: Sender<Message>,
) -> eyre::Result<Self, zbus::Error> {
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
String::from("lowfi")
} else {
format!("lowfi.{}.instance{}", player.list.name, process::id())
};
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
Ok(Self { inner: server })
}
}

View File

@ -0,0 +1,70 @@
use eyre::eyre;
use std::path::PathBuf;
use tokio::fs;
/// 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"));
// Already rounded & absolute, therefore this should be safe.
#[expect(
clippy::as_conversions,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
let percentage = (volume * 100.0).abs().round() as u16;
fs::write(path, percentage.to_string()).await?;
Ok(())
}
}

88
src/player/queue.rs Normal file
View File

@ -0,0 +1,88 @@
use std::{
error::Error,
sync::{atomic::Ordering, Arc},
};
use tokio::{sync::mpsc::Sender, time::sleep};
use crate::{
messages::Message,
player::{downloader::Downloader, Player},
tracks,
};
impl Player {
/// Fetches the next track from the queue, or a random track if the queue is empty.
/// This will also set the current track to the fetched track's info.
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::Error> {
// 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.progress.store(0.0, Ordering::Relaxed);
self.list.random(&self.client, Some(&self.progress)).await?
};
let decoded = track.decode()?;
// Set the current track.
self.set_current(decoded.info.clone());
Ok(decoded)
}
/// Gets, decodes, and plays the next track in the queue while also handling the downloader.
///
/// 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 either a `NewSong` or `TryAgain` signal to `tx`.
pub async fn next(
player: Arc<Self>,
itx: Sender<()>,
tx: Sender<Message>,
debug: bool,
) -> eyre::Result<()> {
// Stop the sink.
player.sink.stop();
let track = player.fetch().await;
match track {
Ok(track) => {
// Start playing the new track.
player.sink.append(track.data);
// Set whether it's bookmarked.
player.bookmarks.set_bookmarked(&track.info).await;
// 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(Message::NewSong).await?;
}
Err(error) => {
if debug {
panic!("{error} - {:?}", error.source())
}
if !error.is_timeout() {
sleep(player.timeout).await;
}
tx.send(Message::TryAgain).await?;
}
};
Ok(())
}
}

View File

@ -1,199 +1,307 @@
//! The module which manages all user interface, including inputs.
use std::{io::stderr, sync::Arc, time::Duration};
#![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 crate::tracks::TrackInfo;
use std::{
fmt::Write as _,
io::{stdout, Stdout},
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
use crate::Args;
use super::Player;
use crossterm::{
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, RestorePosition, Show},
event::{self, KeyCode, KeyModifiers},
style::{Print, Stylize},
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
style::{Print, Stylize as _},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use tokio::{
sync::mpsc::Sender,
task::{self},
time::sleep,
};
use super::Messages;
use lazy_static::lazy_static;
use thiserror::Error;
use tokio::{sync::mpsc::Sender, task, time::sleep};
use unicode_segmentation::UnicodeSegmentation;
/// 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 = 5.0 / 60.0;
use super::Player;
use crate::messages::Message;
/// Small helper function to format durations.
fn format_duration(duration: &Duration) -> String {
let seconds = duration.as_secs() % 60;
let minutes = duration.as_secs() / 60;
mod components;
mod input;
format!("{:02}:{:02}", minutes, seconds)
/// The error type for the UI, which is used to handle errors that occur
/// while drawing the UI or handling input.
#[derive(Debug, Error)]
pub enum UIError {
#[error("unable to convert number")]
Conversion(#[from] std::num::TryFromIntError),
#[error("unable to write output")]
Write(#[from] std::io::Error),
#[error("sending message to backend from ui failed")]
Communication(#[from] tokio::sync::mpsc::error::SendError<Message>),
}
/// This represents the main "action" bars state.
enum ActionBar {
Paused(TrackInfo),
Playing(TrackInfo),
Loading,
/// How long the audio bar will be visible for when audio is adjusted.
/// This is in frames.
const AUDIO_BAR_DURATION: usize = 10;
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);
}
impl ActionBar {
/// Formats the action bar to be displayed.
/// The second value is the character length of the result.
fn format(&self) -> (String, usize) {
let (word, subject) = match self {
Self::Playing(x) => ("playing", Some(x.name.clone())),
Self::Paused(x) => ("paused", Some(x.name.clone())),
Self::Loading => ("loading", None),
/// 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 width of the window.
width: usize,
/// 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}")]
};
subject.map_or_else(
|| (word.to_owned(), word.len()),
|subject| {
(
format!("{} {}", word, subject.clone().bold()),
word.len() + 1 + subject.len(),
)
},
)
Self {
borders,
borderless,
width,
out: stdout(),
}
}
/// Actually draws the window, with each element in `content` being on a new line.
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<(), UIError> {
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()
};
write!(output, "{padding} {}{space} {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 interface itself.
async fn interface(queue: Arc<Player>) -> eyre::Result<()> {
/// The total width of the UI.
const WIDTH: usize = 27;
/// The width of the progress bar, not including the borders (`[` and `]`) or padding.
const PROGRESS_WIDTH: usize = WIDTH - 16;
/// The code for the terminal interface itself.
///
/// * `minimalist` - All this does is hide the bottom control bar.
async fn interface(
player: Arc<Player>,
minimalist: bool,
borderless: bool,
debug: bool,
fps: u8,
width: usize,
) -> eyre::Result<(), UIError> {
let mut window = Window::new(width, borderless || debug);
loop {
let (mut main, len) = queue
.current
.load()
.as_ref()
.map_or(ActionBar::Loading, |x| {
let name = (*Arc::clone(x)).clone();
if queue.sink.is_paused() {
ActionBar::Paused(name)
} else {
ActionBar::Playing(name)
}
})
.format();
// 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();
if len > WIDTH {
main = format!("{}...", &main[..=WIDTH]);
} else {
main = format!("{}{}", main, " ".repeat(WIDTH - len));
}
let action = components::action(&player, current, width);
let mut duration = Duration::new(0, 0);
let elapsed = queue.sink.get_pos();
let volume = player.sink.volume();
let percentage = format!("{}%", (volume * 100.0).round().abs());
let mut filled = 0;
if let Some(current) = queue.current.load().as_ref() {
if let Some(x) = current.duration {
duration = x;
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
filled = (elapsed * PROGRESS_WIDTH as f32).round() as usize;
}
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),
};
let progress = format!(
" [{}{}] {}/{} ",
"/".repeat(filled),
" ".repeat(PROGRESS_WIDTH.saturating_sub(filled)),
format_duration(&elapsed),
format_duration(&duration),
);
let bar = [
format!("{}kip", "[s]".bold()),
format!("{}ause", "[p]".bold()),
format!("{}uit", "[q]".bold()),
];
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);
}
// Formats the menu properly
let menu = [main, progress, bar.join(" ")]
.map(|x| format!("{}\r\n", x.reset()).to_string());
let controls = components::controls(width);
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?;
crossterm::execute!(
stderr(),
MoveToColumn(0),
Print(format!("{}\r\n", "".repeat(WIDTH + 2))),
Print(menu.join("")),
Print(format!("{}", "".repeat(WIDTH + 2))),
MoveToColumn(0),
MoveUp(4)
)?;
let menu = match (minimalist, debug, player.current.load().as_ref()) {
(true, _, _) => vec![action, middle],
(false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
_ => vec![action, middle, controls],
};
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
window.draw(menu, false)?;
let delta = 1.0 / f32::from(fps);
sleep(Duration::from_secs_f32(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, UIError> {
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<(), UIError> {
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
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
/// previous terminal history.
pub async fn start(
queue: Arc<Player>,
sender: Sender<Messages>,
alternate: bool,
) -> eyre::Result<()> {
crossterm::execute!(
stderr(),
RestorePosition,
Clear(ClearType::FromCursorDown),
Hide
)?;
player: Arc<Player>,
sender: Sender<Message>,
args: Args,
) -> eyre::Result<(), UIError> {
let environment = Environment::ready(args.alternate)?;
if alternate {
crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?;
}
let interface = task::spawn(interface(
Arc::clone(&player),
args.minimalist,
args.borderless,
args.debug,
args.fps,
21 + args.width.min(32) * 2,
));
task::spawn(interface(Arc::clone(&queue)));
input::listen(sender.clone()).await?;
interface.abort();
loop {
let event::Event::Key(event) = event::read()? else {
continue;
};
let KeyCode::Char(code) = event.code else {
continue;
};
match code {
'c' => {
// Handles Ctrl+C.
if event.modifiers == KeyModifiers::CONTROL {
break;
}
}
'q' => {
break;
}
's' => {
if !queue.current.load().is_none() {
sender.send(Messages::Next).await?
}
}
'p' => {
sender.send(Messages::Pause).await?;
}
_ => {}
}
}
if alternate {
crossterm::execute!(stderr(), LeaveAlternateScreen)?;
}
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown), Show)?;
terminal::disable_raw_mode()?;
environment.cleanup()?;
Ok(())
}

152
src/player/ui/components.rs Normal file
View File

@ -0,0 +1,152 @@
//! Various different individual components that
//! appear in lowfi's UI, like the progress bar.
use std::{ops::Deref as _, sync::Arc, time::Duration};
use crossterm::style::Stylize as _;
use unicode_segmentation::UnicodeSegmentation as _;
use crate::{player::Player, tracks::Info};
/// Small helper function to format durations.
pub fn format_duration(duration: &Duration) -> String {
let seconds = duration.as_secs() % 60;
let minutes = duration.as_secs() / 60;
format!("{minutes:02}:{seconds:02}")
}
/// Creates the progress bar, as well as all the padding needed.
pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
let mut duration = Duration::new(0, 0);
let elapsed = if current.is_some() {
player.sink.get_pos()
} else {
Duration::new(0, 0)
};
let mut filled = 0;
if let Some(current) = current {
if let Some(x) = current.duration {
duration = x;
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
filled = (elapsed * width as f32).round() as usize;
}
};
format!(
" [{}{}] {}/{} ",
"/".repeat(filled),
" ".repeat(width.saturating_sub(filled)),
format_duration(&elapsed),
format_duration(&duration),
)
}
/// Creates the audio bar, as well as all the padding needed.
pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
let audio = (volume * width as f32).round() as usize;
format!(
" volume: [{}{}] {}{} ",
"/".repeat(audio),
" ".repeat(width.saturating_sub(audio)),
" ".repeat(4usize.saturating_sub(percentage.len())),
percentage,
)
}
/// This represents the main "action" bars state.
enum ActionBar {
/// When the app is paused.
Paused(Info),
/// When the app is playing.
Playing(Info),
/// When the app is loading.
Loading(f32),
/// When the app is muted.
Muted,
}
impl ActionBar {
/// Formats the action bar to be displayed.
/// The second value is the character length of the result.
fn format(&self, star: bool) -> (String, usize) {
let (word, subject) = match self {
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
Self::Loading(progress) => {
let progress = format!("{: <2.0}%", (progress * 100.0).min(99.0));
("loading", Some((progress, 3)))
}
Self::Muted => {
let msg = "+ to increase volume";
("muted,", Some((String::from(msg), msg.len())))
}
};
subject.map_or_else(
|| (word.to_owned(), word.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.
/// This also creates all the needed padding.
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
let (main, len) = current
.map_or_else(
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|info| {
let info = info.deref().clone();
if player.sink.volume() < 0.01 {
return ActionBar::Muted;
}
if player.sink.is_paused() {
ActionBar::Paused(info)
} else {
ActionBar::Playing(info)
}
},
)
.format(player.bookmarks.bookmarked());
if len > width {
let chopped: String = main.graphemes(true).take(width + 1).collect();
format!("{chopped}...")
} else {
format!("{}{}", main, " ".repeat(width - len))
}
}
/// Creates the bottom controls bar, and also spaces it properly.
pub fn controls(width: usize) -> String {
let controls = [["[s]", "kip"], ["[p]", "ause"], ["[q]", "uit"]];
let len: usize = controls.concat().iter().map(|x| x.len()).sum();
let controls = controls.map(|x| format!("{}{}", x[0].bold(), x[1]));
let mut controls = controls.join(&" ".repeat((width - len) / (controls.len() - 1)));
// This is needed because changing the above line
// only works for when the width is even
controls.push_str(match width % 2 {
0 => " ",
_ => "",
});
controls
}

75
src/player/ui/input.rs Normal file
View File

@ -0,0 +1,75 @@
//! 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::{self, UIError},
Message,
};
/// Starts the listener to recieve input from the terminal for various events.
pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
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,
};
if let Message::ChangeVolume(_) = messages {
ui::flash_audio();
}
sender.send(messages).await?;
}
}

88
src/scrapers.rs Normal file
View File

@ -0,0 +1,88 @@
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
View 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 futures::{stream::FuturesOrdered, StreamExt};
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{Html, Selector};
use crate::scrapers::{get, Source};
lazy_static! {
static ref SELECTOR: Selector = 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(())
}

223
src/scrapers/chillhop.rs Normal file
View File

@ -0,0 +1,223 @@
use eyre::eyre;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use indicatif::ProgressBar;
use lazy_static::lazy_static;
use std::fmt;
use std::str::FromStr;
use reqwest::Client;
use scraper::{Html, Selector};
use serde::{
de::{self, Visitor},
Deserialize, Deserializer,
};
use tokio::fs;
use crate::scrapers::{get, Source};
lazy_static! {
static ref RELEASES: Selector = Selector::parse(".table-body > a").unwrap();
static ref RELEASE_LABEL: Selector = Selector::parse("label").unwrap();
// static ref RELEASE_DATE: Selector = Selector::parse(".release-feat-props > .text-xs").unwrap();
// static ref RELEASE_NAME: Selector = Selector::parse(".release-feat-props > h2").unwrap();
static ref RELEASE_AUTHOR: Selector = Selector::parse(".release-feat-props .artist-link").unwrap();
static ref RELEASE_TEXTAREA: Selector = 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 futures::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; 20] = [
// 404
74707, // Lyrics
21655, 21773, 8172, 55397, 75135, 24827, 8141, 8157, 64052, 31612, 41956, 8001, 9217,
55372, // 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)
}

View File

@ -1,20 +1,23 @@
//! 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 futures::{stream::FuturesUnordered, StreamExt};
use futures::{stream::FuturesOrdered, StreamExt};
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{Html, Selector};
const BASE_URL: &str = "https://lofigirl.com/wp-content/uploads/";
use crate::scrapers::get;
lazy_static! {
static ref SELECTOR: Selector = 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);
Ok(html
.select(&SELECTOR)
.skip(5)
@ -26,12 +29,11 @@ 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.
/// 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>> {
let extension = &format!(".{}", extension);
async fn scan() -> eyre::Result<Vec<String>> {
let client = Client::new();
let items = parse(&client, "/").await?;
let items = parse("").await?;
let years: Vec<u32> = items
let mut years: Vec<u32> = items
.iter()
.filter_map(|x| {
let year = x.strip_suffix("/")?;
@ -39,26 +41,25 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
})
.collect();
years.sort();
// A little bit of async to run all of the months concurrently.
let mut futures = FuturesUnordered::new();
let mut futures = FuturesOrdered::new();
for year in years {
let months = parse(&year.to_string()).await?;
let months = parse(&client, &year.to_string()).await?;
for month in months {
futures.push(async move {
let client = client.clone();
futures.push_back(async move {
let path = format!("{}/{}", year, month);
let items = parse(&path).await.unwrap();
let items = parse(&client, &path).await.unwrap();
items
.into_iter()
.filter_map(|x| {
if x.ends_with(extension) {
if include_full {
Some(format!("{BASE_URL}{path}{x}"))
} else {
Some(format!("{path}{x}"))
}
if x.ends_with(".mp3") {
Some(format!("{path}{x}"))
} else {
None
}
@ -76,10 +77,10 @@ async fn scan(extension: &str, include_full: bool) -> eyre::Result<Vec<String>>
eyre::Result::Ok(files)
}
pub async fn scrape(extension: String, include_full: bool) -> eyre::Result<()> {
let files = scan(&extension, include_full).await?;
pub async fn scrape() -> eyre::Result<()> {
let files = scan().await?;
for file in files {
println!("{}", file);
println!("{file}");
}
Ok(())

View File

@ -1,93 +1,218 @@
//! Has all of the structs for managing the state
//! of tracks, as well as downloading them &
//! finding new ones.
//! of tracks, as well as downloading them & finding new ones.
//!
//! There are several structs which represent the different stages
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
//! and what structs are relevant in each step, are as follows.
//!
//! First Stage, when a track is initially fetched.
//! 1. Raw entry selected from track list.
//! 2. Raw entry split into path & display name.
//! 3. Track data fetched, and [`QueuedTrack`] is created which includes a [`TrackName`] that may be raw.
//!
//! Second Stage, when a track is played.
//! 1. Track data is decoded.
//! 2. [`Info`] created from decoded data.
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
use std::{io::Cursor, time::Duration};
use std::{io::Cursor, path::Path, time::Duration};
use bytes::Bytes;
use inflector::Inflector;
use rand::Rng;
use reqwest::Client;
use rodio::{Decoder, Source};
use convert_case::{Case, Casing};
use regex::Regex;
use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation;
use url::form_urlencoded;
/// Downloads a raw track, but doesn't decode it.
async fn download(track: &str, client: &Client) -> eyre::Result<Bytes> {
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
let response = client.get(url).send().await?;
let data = response.bytes().await?;
pub mod error;
pub mod list;
Ok(data)
}
pub use error::Error;
/// Gets a random track from `tracks.txt` and returns it.
fn random() -> &'static str {
let tracks: Vec<&str> = include_str!("../data/tracks.txt")
.split_ascii_whitespace()
.collect();
let random = rand::thread_rng().gen_range(0..tracks.len());
tracks[random]
}
use crate::tracks::error::Context;
use lazy_static::lazy_static;
/// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>;
/// The TrackInfo struct, which has the name and duration of a track.
/// Specifies a track's name, and specifically,
/// whether it has already been formatted or if it
/// is still in it's raw path 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),
}
/// Tracks which are still waiting in the queue, and can't be played yet.
///
/// This means that only the data & track name are included.
pub struct QueuedTrack {
/// Name of the track, which may be raw.
pub name: TrackName,
/// Full downloadable path/url of the track.
pub full_path: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
impl QueuedTrack {
/// 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<DecodedTrack, Error> {
DecodedTrack::new(self)
}
}
/// 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
/// from the decoded data and not from the raw data.
#[derive(Debug, PartialEq, Clone)]
pub struct TrackInfo {
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Info {
/// The full downloadable path/url of the track.
pub full_path: String,
/// Whether the track entry included a custom name, or not.
pub custom_name: bool,
/// This is a formatted name, so it doesn't include the full path.
pub name: String,
pub display_name: String,
/// This is the *actual* terminal width of the track name, used to make
/// the UI consistent.
pub width: usize,
/// The duration of the track, this is an [Option] because there are
/// cases where the duration of a track is unknown.
pub duration: Option<Duration>,
}
impl TrackInfo {
/// Formats a name with [Inflector].
/// This will also strip the first few numbers that are
/// usually present on most lofi tracks.
fn format_name(name: &'static str) -> String {
let mut formatted = name
.split("/")
.nth(2)
.unwrap()
.strip_suffix(".mp3")
.unwrap()
.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(" M ", "'m ");
lazy_static! {
static ref MASTER_PATTERNS: [Regex; 5] = [
// (master), (master v2)
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(),
// mstr or - mstr or (mstr) — now also matches "mstr v3", "mstr2", etc.
Regex::new(r"\s*[-(]?\s*mstr(?:\s*v?\d+)?\s*\)?$").unwrap(),
// - master, master at end without parentheses
Regex::new(r"\s*[-]?\s*master(?:\s*v?\d+)?$").unwrap(),
// kupla master1, kupla master v2 (without parentheses or separator)
Regex::new(r"\s+kupla\s+master(?:\s*v?\d+|\d+)?$").unwrap(),
// (kupla master) followed by trailing parenthetical numbers, e.g. "... (kupla master) (1)"
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)(?:\s*\(\d+\))+$").unwrap(),
];
static ref ID_PATTERN: Regex = Regex::new(r"^[a-z]\d[ .]").unwrap();
}
impl Info {
/// Converts the info back into a full track list entry.
pub fn to_entry(&self) -> String {
let mut entry = self.full_path.clone();
if self.custom_name {
entry.push('!');
entry.push_str(&self.display_name);
}
entry
}
/// 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.
fn format_name(name: &str) -> eyre::Result<String, Error> {
let path = Path::new(name);
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or((name, error::Kind::InvalidName))?;
let name = Self::decode_url(name).to_lowercase();
let mut name = name
.replace("masster", "master")
.replace("(online-audio-converter.com)", "") // Some of these names, man...
.replace('_', " ");
// Get rid of "master" suffix with a few regex patterns.
for regex in MASTER_PATTERNS.iter() {
name = regex.replace(&name, "").to_string();
}
name = ID_PATTERN.replace(&name, "").to_string();
let name = name
.replace("13lufs", "")
.to_case(Case::Title)
.replace(" .", "")
.replace(" Ft ", " ft. ")
.replace("Ft.", "ft.")
.replace("Feat.", "ft.")
.replace(" W ", " w/ ");
// This is incremented for each digit in front of the song name.
let mut skip = 0;
// SAFETY: All of the track names originate with the `'static` lifetime,
// SAFETY: so basically this has already been checked.
for character in unsafe { formatted.as_bytes_mut() } {
if character.is_ascii_digit() {
for character in name.as_bytes() {
if character.is_ascii_digit()
|| *character == b'.'
|| *character == b')'
|| *character == b'('
{
skip += 1;
} else {
break;
}
}
String::from(&formatted[skip..])
// If the entire name of the track is a number, then just return it.
if skip == name.len() {
Ok(name.trim().to_string())
} else {
// We've already checked before that the bound is at an ASCII digit.
#[allow(clippy::string_slice)]
Ok(String::from(name[skip..].trim()))
}
}
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
pub fn new(name: &'static str, decoded: &DecodedData) -> Self {
Self {
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
pub fn new(
name: TrackName,
full_path: String,
decoded: &DecodedData,
) -> eyre::Result<Self, Error> {
let (display_name, custom_name) = match name {
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
TrackName::Formatted(custom) => (custom, true),
};
Ok(Self {
duration: decoded.total_duration(),
name: Self::format_name(name),
}
width: display_name.graphemes(true).count(),
full_path,
custom_name,
display_name,
})
}
}
@ -95,7 +220,7 @@ impl TrackInfo {
/// a track, and not when the track is first downloaded.
pub struct DecodedTrack {
/// Has both the formatted name and some information from the decoded data.
pub info: TrackInfo,
pub info: Info,
/// The decoded data, which is able to be played by [rodio].
pub data: DecodedData,
@ -103,38 +228,16 @@ pub struct DecodedTrack {
impl DecodedTrack {
/// Creates a new track.
/// This is equivalent to [Track::decode].
pub fn new(track: Track) -> eyre::Result<Self> {
let data = Decoder::new(Cursor::new(track.data))?;
let info = TrackInfo::new(track.name, &data);
/// This is equivalent to [`QueuedTrack::decode`].
pub fn new(track: QueuedTrack) -> eyre::Result<Self, Error> {
let data = Decoder::builder()
.with_byte_len(track.data.len().try_into().unwrap())
.with_data(Cursor::new(track.data))
.build()
.track(track.full_path.clone())?;
let info = Info::new(track.name, track.full_path, &data)?;
Ok(Self { info, data })
}
}
/// The main track struct, which only includes data & the track name.
pub struct Track {
/// This name is not formatted, and also includes the month & year of the track.
pub name: &'static str,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
impl Track {
/// Fetches and downloads a random track from the tracklist.
pub async fn random(client: &Client) -> eyre::Result<Self> {
let name = random();
let data = download(name, client).await?;
Ok(Self { data, name })
}
/// 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<DecodedTrack> {
DecodedTrack::new(self)
}
}

73
src/tracks/error.rs Normal file
View File

@ -0,0 +1,73 @@
#[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),
}
#[derive(Debug, thiserror::Error)]
#[error("{kind} (track: {track})")]
pub struct Error {
pub track: String,
#[source]
pub kind: Kind,
}
impl Error {
pub fn is_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: track.into(),
kind: Kind::from(err),
}
}
}
pub trait Context<T> {
fn track(self, name: impl Into<String>) -> Result<T, Error>;
}
impl<T, E> Context<T> for Result<T, E>
where
(String, E): Into<Error>,
E: Into<Kind>,
{
fn track(self, name: impl Into<String>) -> 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()
})
}
}

189
src/tracks/list.rs Normal file
View File

@ -0,0 +1,189 @@
//! The module containing all of the logic behind track lists,
//! as well as obtaining track names & downloading the raw audio data
use std::{cmp::min, sync::atomic::Ordering};
use atomic_float::AtomicF32;
use bytes::{BufMut, Bytes, BytesMut};
use eyre::OptionExt as _;
use futures::StreamExt;
use reqwest::Client;
use tokio::fs;
use crate::{
data_dir,
tracks::{self, error::Context},
};
use super::QueuedTrack;
/// Represents a list of tracks that can be played.
///
/// See the [README](https://github.com/talwat/lowfi?tab=readme-ov-file#the-format) for more details about the format.
#[derive(Clone)]
pub struct List {
/// The "name" of the list, usually derived from a filename.
#[allow(dead_code)]
pub name: String,
/// Just the raw file, but seperated by `/n` (newlines).
/// `lines[0]` is the base/heaeder, with the rest being tracks.
lines: Vec<String>,
/// The file path which the list was read from.
#[allow(dead_code)]
pub path: Option<String>,
}
impl List {
/// Gets the base URL of the [List].
pub fn base(&self) -> &str {
self.lines[0].trim()
}
/// Gets the path of a random track.
///
/// The second value in the tuple specifies whether the
/// track has a custom display name.
fn random_path(&self) -> (String, Option<String>) {
// 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
// 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.
let random = fastrand::usize(1..self.lines.len());
let line = self.lines[random].clone();
if let Some((first, second)) = line.split_once('!') {
(first.to_owned(), Some(second.to_owned()))
} else {
(line, None)
}
}
/// Downloads a raw track, but doesn't decode it.
async fn download(
&self,
track: &str,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<(Bytes, String), tracks::Error> {
// If the track has a protocol, then we should ignore the base for it.
let full_path = if track.contains("://") {
track.to_owned()
} else {
format!("{}{}", self.base(), track)
};
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
let path = if x.starts_with('~') {
let home_path =
dirs::home_dir().ok_or((track, tracks::error::Kind::InvalidPath))?;
let home = home_path
.to_str()
.ok_or((track, tracks::error::Kind::InvalidPath))?;
x.replace('~', home)
} else {
x.to_owned()
};
let result = tokio::fs::read(path.clone()).await.track(track)?;
result.into()
} else {
let response = client.get(full_path.clone()).send().await.track(track)?;
if let Some(progress) = progress {
let total = response
.content_length()
.ok_or((track, tracks::error::Kind::UnknownLength))?;
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)?;
let new = min(downloaded + (chunk.len() as u64), total);
downloaded = new;
progress.store((new as f32) / (total as f32), Ordering::Relaxed);
bytes.put(chunk);
}
bytes.into()
} else {
response.bytes().await.track(track)?
}
};
Ok((data, full_path))
}
/// Fetches and downloads a random track from the [List].
///
/// The Result's error is a bool, which is true if a timeout error occured,
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
pub async fn random(
&self,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<QueuedTrack, tracks::Error> {
let (path, custom_name) = self.random_path();
let (data, full_path) = self.download(&path, client, progress).await?;
let name = custom_name.map_or_else(
|| super::TrackName::Raw(path.clone()),
super::TrackName::Formatted,
);
Ok(QueuedTrack {
name,
full_path,
data,
})
}
/// Parses text into a [List].
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
let lines: Vec<String> = text
.trim_end()
.lines()
.map(|x| x.trim_end().to_owned())
.collect();
Self {
lines,
path: path.map(ToOwned::to_owned),
name: name.to_owned(),
}
}
/// Reads a [List] from the filesystem using the CLI argument provided.
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
if let Some(arg) = tracks {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let path = data_dir()?.join(format!("{arg}.txt"));
let path = if path.exists() { path } else { arg.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(raw.as_ref(), |stripped| stripped);
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or_eyre("invalid track path")?;
Ok(Self::new(name, raw, path.to_str()))
} else {
Ok(Self::new(
"chillhop",
include_str!("../../data/chillhop.txt"),
None,
))
}
}
}