Compare commits

..

No commits in common. "main" and "2.0.1-dev" have entirely different histories.

23 changed files with 303 additions and 595 deletions

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`.

View File

@ -1,7 +1,5 @@
# Contributing to lowfi
[[Version française](./docs/fr/CONTRIBUER.md)]
There are a few guidelines outlined here that will make it more likely for your PR to be accepted.
Only ones that are less obvious are going to be listed. If you need to ask, it's probably a no.

2
Cargo.lock generated
View File

@ -1311,7 +1311,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lowfi"
version = "2.0.2-dev"
version = "2.0.1-dev"
dependencies = [
"arc-swap",
"bytes",

View File

@ -1,6 +1,6 @@
[package]
name = "lowfi"
version = "2.0.2-dev"
version = "2.0.1-dev"
rust-version = "1.83.0"
edition = "2021"
description = "An extremely simple lofi player."
@ -18,7 +18,7 @@ homepage = "https://github.com/talwat/lowfi"
repository = "https://github.com/talwat/lowfi"
[features]
mpris = ["dep:mpris-server", "dep:arc-swap"]
mpris = ["dep:mpris-server"]
extra-audio-formats = ["rodio/default"]
scrape = [
"dep:serde",
@ -38,7 +38,7 @@ thiserror = "2.0.12"
# Async
tokio = { version = "1.41.1", features = ["macros", "rt", "fs", "io-util", "sync", "time"], default-features = false }
futures-util = { version = "0.3.31", default-features = false }
arc-swap = { version = "1.7.1", optional = true }
arc-swap = "1.7.1"
# Data
reqwest = { version = "0.12.9", features = ["stream", "http2", "default-tls"], default-features = false }

View File

@ -1,9 +1,7 @@
# Environment Variables
[[Version française](./fr/ENVIRONMENT_VARS.md)]
lowfi has some more specific options, usually as a result of minor feature requests, which are only documented here.
Lowfi has some more specific options, usually as a result of minor feature requests, which are only documented here.
If you have some behavior you'd like to change, which is quite specific, then see if one of these options suits you.
* `LOWFI_FIXED_MPRIS_NAME` - Limits the number of lowfi instances to one, but ensures the player name is always `lowfi`.
* `LOWFI_DISABLE_UI` - Disables the UI. This requires MPRIS, so that you can still actually control lowfi.
* `LOWFI_DISABLE_UI` - Disables the UI.

View File

@ -1,6 +1,4 @@
# The State of lowfi's Music
[[Version française](./fr/MUSIQUE.md)]
# The State of Lowfi's Music
> [!WARNING]
> This document will be a bit long and has almost nothing to do with the actual
@ -14,7 +12,7 @@ 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,
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

View File

@ -1,17 +1,15 @@
# lowfi
[[Version française](./docs/fr/README.md)]
lowfi is a tiny rust app that serves a single purpose: play lofi.
It'll do this as simply as it can: no albums, no ads, just lofi.
![example image](./docs/media/example1.png)
![example image](media/example1.png)
## Disclaimer
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](./docs/MUSIC.md) for more information.
[MUSIC.md](MUSIC.md) for more information.
## Why?
@ -147,7 +145,7 @@ and as such are also stored in the same directory.
### Extra Flags
If you have something you'd like to tweak about lowfi, you use additional flags which
slightly tweak the UI or behavior of the menu. The flags can be viewed with `lowfi --help`.
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi --help`.
| Flag | Function |
| ----------------------------------- | --------------------------------------------------- |
@ -162,8 +160,6 @@ slightly tweak the UI or behavior of the menu. The flags can be viewed with `low
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
If you need something even more specific, see [ENVIRONMENT_VARS](./docs/ENVIRONMENT_VARS.md).
### Extra Features
lowfi uses cargo/rust's "feature" system to make certain parts of the program optional,

View File

@ -1,23 +0,0 @@
# Contribuer à lowfi
Il y a quelque directives listées ici qui vont augmenter les chances pour votre PR d'être acceptée.
Seules les moins évidentes seront listées, si vous avez besoin de demander la réponse est probablement non.
## 1. IA
Vous pouvez utiliser l'IA pour chercher, ou s'il y a quelque chose de mineur et fastidieux(eg. des tests) que vous préférez éviter de faire manuellement.
Cela dit, si l'usage d'IA est visible, c'est déjà trop.
Les PR générées par IA n'aident pas les développeurs, elles sont juste embêtantes et leur font perdre leur temps.
## 2. Petit = mieux
Faites en sorte que chaque PR ne contienne qu'une fonctionnalité distincte. Ajouter plusieurs fonctionnalités dans une seule PR est généralement une mauvaise idée.
Cela permet aussi que des fonctionnalités spécifiques soit approuvées ou refusées au cas par cas, plutot qu'un seul bloc de code important.
## 3. Keep lowfi simple
lowfi est censé être un programme simple. Pour l'instant aucune modification de l'interface initiale ne sera acceptée.
L'interface de lowfi pendant la lecture est restée la même depuis les premières versions, la compliquer irait à l'encontre de son but initial.
Des fonctionnalités plus complexes, comme des couleurs fantaisistes ou des pochettes d'albums, ne seront jamais acceptées. L'implémentation de fonctionnalitées acceptables doit être simple et discrète, si une fonctionnalité est simple mais que sa mise en oeuvre est très complexe elle ne sera pas acceptée.

View File

@ -1,7 +0,0 @@
# Variables d'Environment
lowfi a quelques options précises, généralement dûe à des demandes de fonctionnalité mineures, qui sont uniquement documentées ici.
S'il y a quelque chose de spécifique que vous souhaitez changer, voyez si l'une des ces options vous va.
* `LOWFI_FIXED_MPRIS_NAME` - Limite le nombre d'instances de lowfi à 1, mais force le nom du lecteur à toujours être `lowfi`.
* `LOWFI_DISABLE_UI` - Désactive l'interface utilisateur.

View File

@ -1,48 +0,0 @@
# La Musique de lowfi
> [!WARNING]
> Ce document sera un peu long et n'aura presque rien à voir avec l'utilisation pratique de lowfi, juste avec la musique intégrée par défaut.
Mais avant cela, un peu de contexte. lowfi comprend une longue liste de musiques intégrés au programme, vous pouvez donc l'installer et l'utiliser directement.
J'ai toujours détesté les applications qui nécessitent une configuration complexe juste pour pouvoir les utiliser. Occasionnellement, cela se justifie, mais souvent, cela n'a aucun sens, car la plupart des utilisateurs finissent par utiliser les mêmes paramètres, qui ne sont même pas vraiment les mêmes.
lowfi est super et simple grâce à son aspect « plug and play », mais c'est devenu beaucoup plus difficile de continuer comme ça les derniers temps.
## La Liste Lofi Girl
À l'origine, il était prévu que lowfi utilise la musique récupérée sur le site web de Lofi Girl. Croyez-le ou non, le scraper est en fait apparu avant le reste du programme.
Cependant, après une longue période d'indisponibilité, le site web de Lofi Girl a été refait sans les fichiers mp3. Ceux-ci sont désormais pratiquement inaccessibles, sauf en achetant chaque album individuellement sur Bandcamp, ce qui revient très vite très cher.
*Scraper* n'a jamais été interdit, mais c'est désormais tout simplement impossible. La question était donc : que faire après avoir perdu la principale source de musique de lowfi ?
## Listes de Morceaux
Au départ, j'étais contre l'idée de créer des listes de morceaux personnalisées, car j'avais une vision presque puriste d'un lowfi 100 % sans configuration. J'ai cependant finis par céder, ce qui s'est avéré être une très bonne décision. Maintenant peu importe le choix que je fais par rapport à la musique qui est intégrée, tout le monde peut ne pas utiliser celle-ci et choisir ce qu'il veut.
Cela a aboutit à quelque *templates*, stockés dans le dossier [data](../../data), et en particulier la liste chillhop par [danielwerg](https://github.com/danielwerg).
## Le Changement
Après que `lofigirl.com` deviennent inaccessible, j'ai réfléchi un coup puis ai finalement décidé de serrer les dents et passer à la liste chillhop. Et ce malgré que chillhop bannis tous les lecteurs tiers dans leur CGU. Ils interdisent aussi les *scrappers*, ce que j'ai appris seulement après en avoir écrit un.
Bon, est-ce que lowfi va vraiment devoir violer les CGU de son fournisseur de musique ?
Eh bien oui. J'y ai réfléchi et je suis arrivé à la conclusion que lowfi n'est probablement pas une grande menace pour plusieurs raisons.
Premièrement, il émule exactement le lecteur "radio" de chillhop. La seule différence étant que l'un force l'utilisation d'un navigateur web, et l'autre celle d'une beau terminal.
Ensuite j'ai réalisé que lowfi est juste un petit programme utilisé par peu.
Je ne gagne pas d'argent avec, et je pense que dégrader l'expérience de mes cher nerds qui veulent juste écouter de la lofi sans toute la merde ne vaut pas le coup.
Au final, lowfi a un `UserAgent` unique, si chillhop a un jour un problème avec, le bannir est extrêmement simple. Je ne souhaite pas que cela arrive, mais je comprendrais.
## Well, *je* Deteste la Musique Chillhop
Ce n'est pas aussi « lofi ». C'est presque un compromis, ça je ne peux même pas prétendre le nier. J'utilise le bouton « skip » presque trois fois plus souvent avec chillhop.
Si vous n'êtes pas assez découragé par les CGU pour avoir lu jusqu'ici, vous pouvez utiliser la liste [archive.txt](../../data/archive.txt) dans le dossier [data](../../data). Cette liste est le fruit de mon inquiétude quant à la possibilité que les morceaux sur `lofigirl.com` aient pu être perdus d'une manière ou d'une autre, en raison de la fermeture du site web.
Elle est hébergée sur `archive.org` et pourrait être supprimée à tout moment pour n'importe quelle raison.
Provenant de mes propres archives locales, elle contient environ 2 700 des 3 700 morceaux.
Elle n'est pas parfaite, son organisation est également *mauvaise*, mais elle existe.

View File

@ -1,243 +0,0 @@
# lowfi
lowfi est une petite application écrite en Rust qui sert un objectif unique : écouter de la lofi.
Elle le fait de la manière la plus simple possible : pas dalbums, pas de pubs, juste de la lofi.
![exemple image](../media/example1.png)
## Attention
À partir de la version 1.7.0 de lowfi, **tous** les fichiers audio intégrés par défaut proviennent de [chillhop](https://chillhop.com/).
Consultez [MUSIQUE](./MUSIQUE.md) pour plus dinformations.
## Pourquoi ?
Je déteste les plateformes de musique modernes, et je voulais une application, petite et simple, qui mettrait simplement de la lofi aléatoire, sans vidéo ni autres fioritures.
Au-delà de ça, elle a aussi été conçue pour être assez résistante aux connections instables, et *cache* 5 morceaux entiers à la fois.
## Installation
> [!NOTE]
> Si vous êtes intéressé par la maintenance dun paquet pour `lowfi` sur des gestionnaires de paquets comme Homebrew ou autres, ouvrez une issue.
### Dépendances
Sur toutes les plateformes : Rust 1.83.0+.
Sur macOS et Windows, aucune dépendance supplémentaire nest nécessaire.
Sur Linux, vous aurez aussi besoin dopenssl et dalsa.
* `alsa-lib` sur Arch, `libasound2-dev` sur Ubuntu, `alsa-lib-devel` sur Fedora.
* `openssl` sur Arch, `libssl-dev` sur Ubuntu, `openssl-devel` sur Fedora.
Si vous utilisez PulseAudio vous aurez aussi besoin dinstaller `pulseaudio-alsa`.
### Cargo
La méthode dinstallation recommandée est cargo :
```sh
cargo install lowfi
# Si vous voulez utiliser le protocole MPRIS.
cargo install lowfi --features mpris
```
Assurez-vous que `$HOME/.cargo/bin` est ajouté à votre `$PATH`.
Voir également [Fonctionnalités supplémentaires](#fonctionnalités-supplémentaires) pour des fonctionnalités étendues.
### Packets précompilés
Si vous rencontrez des difficultés ou ne souhaitez pas utiliser cargo, vous pouvez simplement télécharger les exécutables précompilés depuis la [dernière release](https://github.com/talwat/lowfi/releases/latest).
### AUR
```sh
yay -S lowfi
```
### openSUSE
```sh
zypper install lowfi
```
### Debian
> [!NOTE]
> Ce packet est sur un dépôt non officiel maintenu par [Dario Griffo](https://github.com/dariogriffo).
```sh
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
sudo apt install -y lowfi
```
### Fedora (COPR)
> [!NOTE]
> Ce packet utilise un dépôt COPR non officiel par [FurqanHun](https://github.com/FurqanHun).
```sh
sudo dnf copr enable furqanhun/lowfi
sudo dnf install lowfi
```
### Manuel
Utile pour le débogage.
```sh
git clone https://github.com/talwat/lowfi
cd lowfi
# Si vous voulez un exécutable
cargo build --release --all-features
./target/release/lowfi
# Si vous voulez juste tester
cargo run --all-features
```
## Utilisation
`lowfi`
Oui, cest tout.
### Contrôles
| Touche | Fonction |
| ------------------ | ------------------- |
| `s`, `n`, `l` | Passer le morceau |
| `p`, Espace | Lecture / Pause |
| `+`, `=`, `k`, `↑` | Volume +10 % |
| `→` | Volume +1 % |
| `-`, `_`, `j`, `↓` | Volume -10 % |
| `←` | Volume -1 % |
| `q`, CTRL+C | Quitter |
| `b` | Ajouter aux Favoris |
> [!NOTE]
> En plus de ces contrôles habituels, lowfi est compatible avec les touches multimédia de votre machine ainsi qu'avec le standard [MPRIS](https://wiki.archlinux.org/title/MPRIS) (avec des outils comme `playerctl`).
>
> MPRIS est actuellement une [fonctionnalité optionnelle](#fonctionnalités-supplémentaires) dans Cargo (activée avec `--features mpris`) car elle est uniquement destinée à Linux, et parce que le but principal de lowfi est son interface unique et minimaliste.
### Favoris
Les favoris sont la réponse extrêmement simple de lowfi à la question « et si je voulais garder un morceau ? ».
Vous pouvez ajouter ou retirer des morceaux des favoris avec `b`, et les lire avec `lowfi -t bookmarks`.
Dun point de vue technique, vos favoris ne sont pas différents de nimporte quelle autre liste de morceaux, et sont donc stockés dans le même répertoire.
### Options supplémentaires
Si vous avez quelque chose que vous souhaitez ajuster dans lowfi, vous pouvez utiliser des options supplémentaires qui modifient légèrement linterface ou le comportement du menu.
Les options peuvent être consultées avec `lowfi --help`.
| Option | Fonction |
| ----------------------------------- | ------------------------------------------------------------------------------ |
| `-a`, `--alternate` | Utiliser un écran de terminal alternatif |
| `-m`, `--minimalist` | Masquer la barre de contrôle inférieure |
| `-b`, `--borderless` | Exclure les bordures de linterface |
| `-p`, `--paused` | Lancer lowfi en pause, |
| `-f`, `--fps` | FPS de linterface [défaut : 12] |
| `--timeout` | Délai dattente en secondes pour les téléchargements |
| `-d`, `--debug` | Inclure les logs ALSA et autres |
| `-w`, `--width <WIDTH>` | Largeur du lecteur, de 0 à 32 [défaut : 3] |
| `-t`, `--track-list <TRACK_LIST>` | Utiliser une [liste de pistes personnalisée](#listes-de-pistes-personnalisées) |
| `-s`, `--buffer-size <BUFFER_SIZE>` | Nombre de morceaux ajoutés au cache en avance [défaut : 5] |
### Fonctionnalités supplémentaires
lowfi utilise le système de « features » de cargo/rust pour rendre certaines parties du programme optionnelles, notamment celles qui ne sont censées être utilisées que par une minorité dutilisateurs.
#### `scrape` - Scraping
Cette fonctionnalité fournit la commande `scrape`.
Elle nest généralement pas très utile, mais est incluse par souci de transparence.
Plus dinformations sont disponibles en exécutant `lowfi help scrape`.
#### `mpris` - MPRIS
Active MPRIS.
#### `extra-audio-formats` - Formats audio supplémentaires
Ceci est uniquement pertinent pour les utilisateurs de listes de pistes personnalisées ; dans ce cas, cela permet plus de formats que le simple MP3, à savoir FLAC, Vorbis et WAV.
Ces formats devraient couvrir environ 99 % des fichiers audio que les gens souhaitent lire. Si vous faites partie du 1 % utilisant un autre format audio, et présent dans [cette liste](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#codecs-decoders), ouvrez une issue.
### Listes de pistes personnalisées
> [!NOTE]
> Certains gentils utilisateurs, en particulier [danielwerg](https://github.com/danielwerg), ont déjà créé des listes alternatives situées dans le dossier [data](https://github.com/talwat/lowfi/blob/main/data/) de ce dépôt. Vous pouvez les utiliser avec lowfi en utilisant loption `--track-list`.
>
> Nhésitez pas à proposer votre propre liste via une pull request.
lowfi prend également en charge les listes de pistes personnalisées, bien que celle par défaut de chillhop soit intégrée directement dans l'exécutable.
Pour utiliser une liste personnalisée, utilisez loption `--track-list`. Cela peut être soit un chemin vers un fichier, soit le nom dun fichier (sans lextension `.txt`) présent dans le dossier données.
> [!NOTE]
> Répertoires de données par plateforme :
>
> * Linux - `~/.local/share/lowfi`
> * macOS - `~/Library/Application Support/lowfi`
> * Windows - `%appdata%\Roaming\lowfi`
Par exemple, `lowfi --track-list minipop` chargera `~/.local/share/lowfi/minipop.txt`.
Tandis que `lowfi --track-list ~/Music/minipop.txt` chargera depuis le répertoire spécifié.
Tous les morceaux doivent être au format MP3, sauf si lowfi a été compilé avec la fonctionnalité `extra-audio-formats`, qui ajoute la prise en charge de certains autres formats.
#### Le format
Dans les listes, la première ligne est appelée len-tête, suivie du reste des pistes.
Chaque piste sera dabord concaténée à len-tête, puis lensemble sera utilisé pour télécharger le morceau.
> [!NOTE]
> lowfi *najoutera pas* de `/` entre la base et la piste pour plus de flexibilité ;
> dans la plupart des cas, vous devriez donc avoir un `/` final dans votre en-tête.
Lexception à cette règle est lorsque le nom de la piste commence par un protocole tel que `https://`, auquel cas la base ne sera pas préfixée. Si toutes vos pistes sont de ce type, vous pouvez mettre `noheader` comme première ligne et ne pas avoir den-tête du tout.
Par exemple, dans cette liste :
```txt
https://lofigirl.com/wp-content/uploads/
2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/04/2-In-Front-Of-Me.mp3
https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
```
lowfi téléchargerait ces trois URL :
* `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
* `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
* `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
De plus, vous pouvez choisir un nom daffichage personnalisé pour une piste,
indiqué par un `!`. Par exemple, avec une entrée comme celle-ci :
```txt
2023/04/2-In-Front-Of-Me.mp3!nom personnalisé
```
lowfi téléchargera depuis la première partie et affichera la seconde comme nom du morceau.
`file://` peut être utilisé devant une piste ou un en-tête pour que lowfi le traite comme un fichier local.
Cest utile si vous souhaitez utiliser un fichier local comme URL de base, par exemple :
```txt
file:///home/utilisateur/Musique/
fichier.mp3
file:///home/utilisateur/Musique 2/deuxieme-fichier.mp3
```
Dautres exemples sont disponibles dans le dossier
[data](https://github.com/talwat/lowfi/tree/main/data).

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -3,51 +3,57 @@ use std::{sync::Arc, time::Duration};
use rodio::Sink;
use tokio::{
sync::{mpsc, Notify},
task::{self, JoinHandle},
time,
};
/// Background loop that waits for the sink to drain and then attempts
/// to send a `Message::Next` to the provided channel.
async fn waiter(
sink: Arc<Sink>,
tx: mpsc::Sender<crate::Message>,
notify: Arc<Notify>,
) -> crate::Result<()> {
loop {
notify.notified().await;
while !sink.empty() {
time::sleep(Duration::from_millis(16)).await;
}
if tx.try_send(crate::Message::Next).is_err() {
break Ok(());
}
}
}
/// Lightweight helper that waits for the current sink to drain and then
/// notifies the player to advance to the next track.
pub struct Handle {
/// Background task monitoring the sink.
task: JoinHandle<()>,
/// Notification primitive used to wake the waiter.
notify: Arc<Notify>,
}
impl Drop for Handle {
fn drop(&mut self) {
self.task.abort();
}
}
impl Handle {
/// Create a new `Handle` which watches the provided `sink` and sends
/// `Message::Next` down `tx` when the sink becomes empty.
pub fn new(sink: Arc<Sink>, tx: mpsc::Sender<crate::Message>) -> Self {
let notify = Arc::new(Notify::new());
Self {
task: task::spawn(Self::waiter(sink, tx, Arc::clone(&notify))),
notify,
}
}
/// Notify the waiter that playback state may have changed and it should
/// re-check the sink emptiness condition.
pub fn notify(&self) {
self.notify.notify_one();
}
/// Background loop that waits for the sink to drain and then attempts
/// to send a `Message::Next` to the provided channel.
async fn waiter(sink: Arc<Sink>, tx: mpsc::Sender<crate::Message>, notify: Arc<Notify>) {
loop {
notify.notified().await;
while !sink.empty() {
time::sleep(Duration::from_millis(8)).await;
}
impl crate::Tasks {
/// Create a new `Handle` which watches the provided `sink` and sends
/// `Message::Next` down `tx` when the sink becomes empty.
pub fn waiter(&mut self, sink: Arc<Sink>) -> Handle {
let notify = Arc::new(Notify::new());
self.spawn(waiter(sink, self.tx(), notify.clone()));
Handle { notify }
if tx.try_send(crate::Message::Next).is_err() {
break;
}
}
}
}

View File

@ -5,7 +5,7 @@ use std::{
use crate::tracks;
use reqwest::Client;
use tokio::sync::mpsc;
use tokio::sync::mpsc::{self, Receiver, Sender};
/// Flag indicating whether the downloader is actively fetching a track.
///
@ -28,14 +28,14 @@ pub struct Downloader {
/// The track queue itself, which in this case is actually
/// just an asynchronous sender.
///
/// It is a [`mpsc::Sender`] because the tracks will have to be
/// It is a [`Sender`] because the tracks will have to be
/// received by a completely different thread, so this avoids
/// the need to use an explicit [`tokio::sync::Mutex`].
queue: mpsc::Sender<tracks::Queued>,
queue: Sender<tracks::Queued>,
/// The [`mpsc::Sender`] which is used to inform the
/// The [`Sender`] which is used to inform the
/// [`crate::Player`] with [`crate::Message::Loaded`].
tx: mpsc::Sender<crate::Message>,
tx: Sender<crate::Message>,
/// The list of tracks to download from.
tracks: tracks::List,
@ -48,6 +48,39 @@ pub struct Downloader {
}
impl Downloader {
/// Initializes the downloader with a track list.
///
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
pub fn init(
size: usize,
timeout: u64,
tracks: tracks::List,
tx: Sender<crate::Message>,
) -> crate::Result<Handle> {
let client = Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(timeout))
.build()?;
let (qtx, qrx) = mpsc::channel(size - 1);
let downloader = Self {
queue: qtx,
tx,
tracks,
client,
rng: fastrand::Rng::new(),
};
Ok(Handle {
queue: qrx,
task: crate::Tasks([tokio::spawn(downloader.run())]),
})
}
/// Actually runs the downloader, consuming it and beginning
/// the cycle of downloading tracks and reporting to the
/// rest of the program.
@ -84,7 +117,10 @@ impl Downloader {
pub struct Handle {
/// The queue receiver, which can be used to actually
/// fetch a track from the queue.
queue: mpsc::Receiver<tracks::Queued>,
queue: Receiver<tracks::Queued>,
/// The downloader task, which can be aborted.
task: crate::Tasks<crate::Error, 1>,
}
/// The output when a track is requested from the downloader.
@ -110,37 +146,10 @@ impl Handle {
}, Output::Queued,
)
}
}
impl crate::Tasks {
/// Initializes the downloader with a track list.
///
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
pub fn downloader(
&mut self,
size: usize,
timeout: u64,
tracks: tracks::List,
) -> crate::Result<Handle> {
let client = Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(timeout))
.build()?;
let (qtx, qrx) = mpsc::channel(size - 1);
let downloader = Downloader {
queue: qtx,
tx: self.tx(),
tracks,
client,
rng: fastrand::Rng::new(),
};
self.spawn(downloader.run());
Ok(Handle { queue: qrx })
/// Shuts down the downloader task, returning any errors.
pub async fn close(self) -> crate::Result<()> {
let [result] = self.task.shutdown().await;
result
}
}

View File

@ -89,18 +89,11 @@ enum Commands {
},
}
/// Shorthand to get a boolean environment variable.
#[inline]
pub fn env(name: &str) -> bool {
std::env::var(name).is_ok_and(|x| x == "1")
}
/// Returns the application data directory used for persistency.
///
/// The function returns the platform-specific user data directory with
/// a `lowfi` subfolder. Callers may use this path to store config,
/// bookmarks, and other persistent files.
#[inline]
pub fn data_dir() -> crate::Result<PathBuf> {
let dir = dirs::data_dir().unwrap().join("lowfi");
@ -128,12 +121,12 @@ async fn main() -> eyre::Result<()> {
}
let stream = audio::stream()?;
let environment = ui::Environment::ready(&args)?;
let (mut player, mut tasks) = Player::init(args, stream.mixer())
let environment = ui::Environment::ready(args.alternate)?;
let mut player = Player::init(args, stream.mixer())
.await
.inspect_err(|_| environment.cleanup(false).unwrap())?;
let result = tasks.wait(player.run()).await;
let result = player.run().await;
environment.cleanup(result.is_ok())?;
player.close().await?;

View File

@ -1,22 +1,25 @@
use std::sync::Arc;
use tokio::sync::mpsc::{self, Receiver};
use tokio::sync::{
broadcast,
mpsc::{self, Receiver},
};
use crate::{
audio::waiter,
bookmark::Bookmarks,
download,
download::{self, Downloader},
tracks::{self, List},
ui,
volume::PersistentVolume,
Message, Tasks,
Message,
};
#[derive(Clone, Debug)]
/// Represents the currently known playback state.
///
/// * [`Current::Loading`] indicates the player is waiting for data.
/// * [`Current::Track`] indicates the player has a decoded track available.
#[derive(Clone, Debug)]
pub enum Current {
/// Waiting for a track to arrive. The optional `Progress` is used to
/// indicate global download progress when present.
@ -45,20 +48,23 @@ impl Current {
/// `Player` composes the downloader, UI, audio sink and bookkeeping state.
/// It owns background `Handle`s and drives the main message loop in `run`.
pub struct Player {
/// Background downloader that fills the internal queue.
downloader: download::Handle,
/// Persistent bookmark storage used by the player.
bookmarks: Bookmarks,
/// Current playback state (loading or track).
current: Current,
/// Background downloader that fills the internal queue.
downloader: download::Handle,
/// Shared audio sink used for playback.
sink: Arc<rodio::Sink>,
/// Receiver for incoming `Message` commands.
rx: Receiver<crate::Message>,
/// Shared audio sink used for playback.
sink: Arc<rodio::Sink>,
/// Broadcast channel used to send UI updates.
updater: broadcast::Sender<ui::Update>,
/// Current playback state (loading or track).
current: Current,
/// UI handle for rendering and input.
ui: ui::Handle,
@ -74,34 +80,38 @@ impl Player {
/// based on persistent bookmarks.
pub fn set_current(&mut self, current: Current) -> crate::Result<()> {
self.current = current.clone();
self.ui.update(ui::Update::Track(current))?;
self.update(ui::Update::Track(current))?;
let Current::Track(track) = &self.current else {
return Ok(());
};
let bookmarked = self.bookmarks.bookmarked(track);
self.ui.update(ui::Update::Bookmarked(bookmarked))?;
self.update(ui::Update::Bookmarked(bookmarked))?;
Ok(())
}
/// Sends a `ui::Update` to the broadcast channel.
pub fn update(&mut self, update: ui::Update) -> crate::Result<()> {
self.updater.send(update)?;
Ok(())
}
/// Initialize a `Player` with the provided CLI `args` and audio `mixer`.
///
/// This sets up the audio sink, UI, downloader, bookmarks and persistent
/// volume state. The function returns a fully constructed `Player` ready
/// to be driven via `run`.
pub async fn init(
args: crate::Args,
mixer: &rodio::mixer::Mixer,
) -> crate::Result<(Self, crate::Tasks)> {
pub async fn init(args: crate::Args, mixer: &rodio::mixer::Mixer) -> crate::Result<Self> {
let (tx, rx) = mpsc::channel(8);
let mut tasks = Tasks::new(tx.clone());
if args.paused {
tx.send(Message::Pause).await?;
}
tx.send(Message::Init).await?;
let (utx, urx) = broadcast::channel(8);
let list = List::load(args.track_list.as_ref()).await?;
let sink = Arc::new(rodio::Sink::connect_new(mixer));
@ -110,25 +120,40 @@ impl Player {
let volume = PersistentVolume::load().await?;
sink.set_volume(volume.float());
let player = Self {
ui: tasks.ui(state, &args).await?,
downloader: tasks.downloader(args.buffer_size as usize, args.timeout, list)?,
waiter: tasks.waiter(Arc::clone(&sink)),
Ok(Self {
ui: ui::Handle::init(tx.clone(), urx, state, &args).await?,
downloader: Downloader::init(
args.buffer_size as usize,
args.timeout,
list,
tx.clone(),
)?,
waiter: waiter::Handle::new(Arc::clone(&sink), tx),
bookmarks: Bookmarks::load().await?,
current: Current::default(),
updater: utx,
rx,
sink,
};
Ok((player, tasks))
})
}
/// Close any outlying processes, as well as persist state that
/// should survive such as bookmarks and volume.
pub async fn close(self) -> crate::Result<()> {
// We should prioritize reporting UI/Downloader errors,
// but still save persistent state before so that if either one fails,
// state is saved.
let saves = (
self.bookmarks.save().await,
PersistentVolume::save(self.sink.volume()).await,
);
self.ui.close().await?;
self.downloader.close().await?;
self.sink.stop();
self.bookmarks.save().await?;
PersistentVolume::save(self.sink.volume()).await?;
saves.0?;
saves.1?;
Ok(())
}
@ -180,11 +205,11 @@ impl Player {
Message::ChangeVolume(change) => {
self.sink
.set_volume((self.sink.volume() + change).clamp(0.0, 1.0));
self.ui.update(ui::Update::Volume)?;
self.update(ui::Update::Volume)?;
}
Message::SetVolume(set) => {
self.sink.set_volume(set.clamp(0.0, 1.0));
self.ui.update(ui::Update::Volume)?;
self.update(ui::Update::Volume)?;
}
Message::Bookmark => {
let Current::Track(current) = &self.current else {
@ -192,7 +217,7 @@ impl Player {
};
let bookmarked = self.bookmarks.bookmark(current)?;
self.ui.update(ui::Update::Bookmarked(bookmarked))?;
self.update(ui::Update::Bookmarked(bookmarked))?;
}
Message::Quit => break,
}

View File

@ -1,64 +1,47 @@
//! Task management.
//!
//! This file aims to abstract a lot of annoying Rust async logic, which may be subject to change.
//! For those who are not intimately familiar with async rust, this will be very confusing.
use std::{future::Future, mem::MaybeUninit};
use futures_util::TryFutureExt;
use std::future::Future;
use tokio::{select, sync::mpsc, task::JoinSet};
/// Handles all of the processes within lowfi.
/// This entails initializing/closing tasks, and handling any potential errors that arise.
pub struct Tasks {
/// The [`JoinSet`], which contains all of the task handles.
pub set: JoinSet<crate::Result<()>>,
/// A sender, which is kept for convenience to be used when
/// initializing various other tasks.
tx: mpsc::Sender<crate::Message>,
trait AsyncArrayMap<T, const N: usize> {
async fn async_map<U, F, Fut>(self, f: F) -> [U; N]
where
F: FnMut(T) -> Fut,
Fut: Future<Output = U>;
}
impl Tasks {
/// Creates a new task manager.
pub fn new(tx: mpsc::Sender<crate::Message>) -> Self {
Self {
tx,
set: JoinSet::new(),
impl<T, const N: usize> AsyncArrayMap<T, N> for [T; N] {
async fn async_map<U, F, Fut>(self, mut f: F) -> [U; N]
where
F: FnMut(T) -> Fut,
Fut: Future<Output = U>,
{
let mut out: [MaybeUninit<U>; N] = unsafe { MaybeUninit::uninit().assume_init() };
for (i, v) in self.into_iter().enumerate() {
out[i].write(f(v).await);
}
unsafe { std::mem::transmute_copy(&out) }
}
}
/// Processes a task, and adds it to the internal [`JoinSet`].
pub fn spawn<E: Into<crate::Error> + Send + Sync + 'static>(
&mut self,
future: impl Future<Output = Result<(), E>> + Send + 'static,
) {
self.set.spawn(future.map_err(Into::into));
/// Wrapper around an array of JoinHandles to provide better error reporting & shutdown.
pub struct Tasks<E, const S: usize>(pub [tokio::task::JoinHandle<Result<(), E>>; S]);
impl<T: Send + 'static + Into<crate::Error>, const S: usize> Tasks<T, S> {
/// Abort tasks, and report either errors thrown from within each task
/// or from tokio about joining the task.
pub async fn shutdown(self) -> [crate::Result<()>; S] {
self.0
.async_map(async |handle| {
if !handle.is_finished() {
handle.abort();
}
/// Gets a copy of the internal [`mpsc::Sender`].
pub fn tx(&self) -> mpsc::Sender<crate::Message> {
self.tx.clone()
}
/// Actively polls all of the handles previously added.
///
/// An additional `runner` is for the main player future, which
/// can't be added as a "task" because it shares data with the
/// main thread.
///
/// This either returns when the runner completes, or if an error occurs
/// in any of the internally held tasks.
pub async fn wait(
&mut self,
runner: impl Future<Output = Result<(), crate::Error>> + std::marker::Send,
) -> crate::Result<()> {
select! {
result = runner => result,
Some(result) = self.set.join_next() => match result {
Ok(res) => res,
Err(e) if !e.is_cancelled() => Err(crate::Error::JoinError(e)),
Err(_) => Ok(()),
}
match handle.await {
Ok(Err(error)) => Err(error.into()),
Err(error) if !error.is_cancelled() => Err(crate::Error::JoinError(error)),
Ok(Ok(())) | Err(_) => Ok(()),
}
})
.await
}
}

View File

@ -1,10 +1,12 @@
use std::sync::Arc;
use crate::player::Current;
use tokio::{sync::broadcast, time::Instant};
use crate::{player::Current, ui, Args};
use tokio::{
sync::{broadcast, mpsc::Sender},
time::Instant,
};
pub mod environment;
pub mod init;
pub use environment::Environment;
pub mod input;
pub mod interface;
@ -106,20 +108,38 @@ pub enum Update {
/// The UI handle for controlling the state of the UI, as well as
/// updating MPRIS information and other small interfacing tasks.
pub struct Handle {
/// Broadcast channel used to send UI updates.
updater: broadcast::Sender<Update>,
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
#[cfg(feature = "mpris")]
pub mpris: mpris::Server,
/// The UI's running tasks.
tasks: Option<crate::Tasks<ui::Error, 2>>,
}
impl Handle {
/// Sends a `ui::Update` to the broadcast channel.
pub fn update(&mut self, update: Update) -> crate::Result<()> {
self.updater.send(update)?;
Ok(())
/// Actually takes care of spawning the tasks for the UI.
fn spawn(
tx: Sender<crate::Message>,
updater: broadcast::Receiver<ui::Update>,
state: State,
params: interface::Params,
) -> crate::Tasks<Error, 2> {
crate::Tasks([
tokio::spawn(Handle::ui(updater, state, params)),
tokio::spawn(input::listen(tx)),
])
}
/// Shuts down the UI tasks, returning any encountered errors.
pub async fn close(self) -> crate::Result<()> {
let Some(tasks) = self.tasks else {
return Ok(());
};
for result in tasks.shutdown().await {
result?
}
Ok(())
}
/// The main UI process, which will both render the UI to the terminal
@ -130,7 +150,7 @@ impl Handle {
///
/// `rx` is the receiver for state updates, `state` the initial state,
/// and `params` specifies aesthetic options that are specified by the user.
pub async fn run(
async fn ui(
mut updater: broadcast::Receiver<Update>,
mut state: State,
params: interface::Params,
@ -153,3 +173,23 @@ pub async fn run(
Ok(())
}
/// Initializes the UI itself, along with all of the tasks that are related to it.
#[allow(clippy::unused_async)]
pub async fn init(
tx: Sender<crate::Message>,
updater: broadcast::Receiver<ui::Update>,
state: State,
args: &Args,
) -> Result<Self> {
let params = interface::Params::try_from(args)?;
Ok(Self {
#[cfg(feature = "mpris")]
mpris: mpris::Server::new(state.clone(), tx.clone(), updater.resubscribe()).await?,
tasks: params
.enabled
.then(|| Self::spawn(tx, updater, state, params)),
})
}
}

View File

@ -15,29 +15,16 @@ pub struct Environment {
/// Whether the terminal is in an alternate screen or not.
alternate: bool,
/// Whether the UI is actually enabled at all.
/// This will effectively make the environment just do nothing.
enabled: bool,
}
impl Environment {
/// This prepares the terminal, returning an [Environment] helpful
/// for cleaning up afterwards.
pub fn ready(args: &crate::Args) -> super::Result<Self> {
let enabled = !crate::env("LOWFI_DISABLE_UI");
if !enabled {
return Ok(Self {
enhancement: false,
alternate: args.alternate,
enabled,
});
}
pub fn ready(alternate: bool) -> super::Result<Self> {
let mut lock = stdout().lock();
crossterm::execute!(lock, Hide)?;
if args.alternate {
if alternate {
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
}
@ -52,9 +39,8 @@ impl Environment {
}
let environment = Self {
enabled,
enhancement,
alternate: args.alternate,
alternate,
};
panic::set_hook(Box::new(move |info| {
@ -68,10 +54,6 @@ impl Environment {
/// Uses the information collected from initialization to safely close down
/// the terminal & restore it to it's previous state.
pub fn cleanup(&self, elegant: bool) -> super::Result<()> {
if !self.enabled {
return Ok(());
}
let mut lock = stdout().lock();
if self.alternate {

View File

@ -1,27 +0,0 @@
//! Contains the code for initializing the UI and creating a [`ui::Handle`].
use crate::ui::{self, input, interface};
use tokio::sync::broadcast;
impl crate::Tasks {
/// Initializes the UI itself, along with all of the tasks that are related to it.
#[allow(clippy::unused_async)]
pub async fn ui(&mut self, state: ui::State, args: &crate::Args) -> crate::Result<ui::Handle> {
let (utx, urx) = broadcast::channel(8);
#[cfg(feature = "mpris")]
let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?;
let params = interface::Params::try_from(args)?;
if params.enabled {
self.spawn(ui::run(urx, state, params));
self.spawn(input::listen(self.tx()));
}
Ok(ui::Handle {
updater: utx,
#[cfg(feature = "mpris")]
mpris,
})
}
}

View File

@ -2,7 +2,7 @@ use crate::{
ui::{self, State},
Args,
};
use std::{io::stdout, time::Duration};
use std::{env, io::stdout, time::Duration};
pub mod clock;
pub mod components;
@ -59,7 +59,7 @@ impl TryFrom<&Args> for Params {
let delta = 1.0 / f32::from(args.fps);
let delta = Duration::from_secs_f32(delta);
let disabled = crate::env("LOWFI_DISABLE_UI");
let disabled = env::var("LOWFI_DISABLE_UI").is_ok_and(|x| x == "1");
if disabled && !cfg!(feature = "mpris") {
return Err(ui::Error::RejectedDisable);
}
@ -141,9 +141,7 @@ impl Interface {
/// Draws the terminal. This will also wait for the specified
/// delta to pass before completing.
pub async fn draw(&mut self, state: &State) -> super::Result<()> {
if let Some(x) = self.clock.as_mut() {
x.update(&mut self.window);
}
self.clock.as_mut().map(|x| x.update(&mut self.window));
let menu = self.menu(state);
self.window.draw(stdout().lock(), menu)?;

View File

@ -1,6 +1,7 @@
//! Contains the code for the MPRIS server & other helper functions.
use std::{
env,
hash::{DefaultHasher, Hash, Hasher},
process,
sync::Arc,
@ -327,7 +328,7 @@ impl Server {
sender: mpsc::Sender<Message>,
receiver: broadcast::Receiver<Update>,
) -> ui::Result<Server> {
let suffix = if crate::env("LOWFI_FIXED_MPRIS_NAME") {
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
String::from("lowfi")
} else {
format!("lowfi.{}.instance{}", state.tracklist, process::id())