mirror of
https://github.com/talwat/lowfi
synced 2025-06-09 21:12:36 +00:00
Compare commits
46 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
1af976ad77 | ||
|
e8b4b17f98 | ||
|
1a76699afc | ||
|
2ccf073646 | ||
|
315fa105bf | ||
|
7cdd2e7694 | ||
|
a89854e46f | ||
|
f1c6cbf026 | ||
|
d24c6b1a74 | ||
|
a83a052ae9 | ||
|
a9cd30550c | ||
|
29dab7a77a | ||
|
fe70800502 | ||
|
d05f36a0bb | ||
|
5db5146b8e | ||
|
34577efe8f | ||
|
968c1ee670 | ||
|
bbdcfdd6f2 | ||
|
8e843c12a2 | ||
|
adcb20f2d0 | ||
|
27fc505830 | ||
|
66ccc44099 | ||
|
ca746c0902 | ||
|
768f976e89 | ||
|
84f386e0eb | ||
|
b68ce27d19 | ||
|
ed4b79d2bf | ||
|
a4dd55fb28 | ||
|
3db4f9d402 | ||
|
2a36bc72f3 | ||
|
ce8f8d2845 | ||
|
f0123fd2bc | ||
|
ece88de1ae | ||
|
a720e9d2cf | ||
|
503b4fe9db | ||
|
67a4c4f0ea | ||
|
2b20bf7709 | ||
|
945b420cd8 | ||
|
1e3c66679c | ||
|
840b1663e7 | ||
|
7502d1cd17 | ||
|
1480b62be9 | ||
|
923ac05cf8 | ||
|
6a6823d078 | ||
|
1e491bb36f | ||
|
b87a525c74 |
11
Cargo.lock
generated
11
Cargo.lock
generated
@ -973,7 +973,7 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
||||
dependencies = [
|
||||
"unicode-width 0.1.14",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1453,7 +1453,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
|
||||
[[package]]
|
||||
name = "lowfi"
|
||||
version = "1.5.6"
|
||||
version = "1.6.0"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"arc-swap",
|
||||
@ -1472,7 +1472,6 @@ dependencies = [
|
||||
"scraper",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
@ -2819,12 +2818,6 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lowfi"
|
||||
version = "1.5.6"
|
||||
version = "1.6.0"
|
||||
edition = "2021"
|
||||
description = "An extremely simple lofi player."
|
||||
license = "MIT"
|
||||
@ -50,5 +50,4 @@ Inflector = "0.11.4"
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.167"
|
||||
url = "2.5.4"
|
||||
unicode-width = "0.2.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
|
6
ENVIRONMENT_VARS.md
Normal file
6
ENVIRONMENT_VARS.md
Normal file
@ -0,0 +1,6 @@
|
||||
# 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`.
|
99
README.md
99
README.md
@ -7,20 +7,19 @@ 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,
|
||||
**All** of the audio files embedded into in lowfi by default 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
|
||||
If, god forbid, you're planning to use lowfi in a commercial setting, please
|
||||
follow their rules.
|
||||
|
||||
## 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.
|
||||
app that would just play random lofi without video.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@ -44,7 +43,7 @@ On Linux, you'll also need openssl & alsa, as well as their headers.
|
||||
- `alsa-lib` on Arch, `libasound2-dev` on Ubuntu.
|
||||
- `openssl` on Arch, `libssl-dev` on Ubuntu.
|
||||
|
||||
Make sure to also install `pulseaudio-alsa` if you're using pulseaudio.
|
||||
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
|
||||
|
||||
### Cargo
|
||||
|
||||
@ -66,8 +65,6 @@ precompiled binaries from the [latest release](https://github.com/talwat/lowfi/r
|
||||
|
||||
### AUR
|
||||
|
||||
If you're on Arch, you can also use the AUR:
|
||||
|
||||
```sh
|
||||
yay -S lowfi
|
||||
```
|
||||
@ -78,6 +75,27 @@ yay -S lowfi
|
||||
zypper install lowfi
|
||||
```
|
||||
|
||||
### Debian
|
||||
|
||||
> [!NOTE]
|
||||
> This uses an unofficial Debian repository maintained by [Dario Griffo](https://github.com/dariogriffo).
|
||||
|
||||
```sh
|
||||
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
|
||||
echo "deb https://debian.griffo.io//apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
|
||||
sudo apt install -y lowfi
|
||||
```
|
||||
|
||||
### Fedora (COPR)
|
||||
|
||||
> [!NOTE]
|
||||
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
|
||||
|
||||
```sh
|
||||
sudo dnf copr enable furqanhun/lowfi
|
||||
sudo dnf install lowfi
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
This is good for debugging, especially in issues.
|
||||
@ -102,17 +120,39 @@ Yeah, that's it.
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Function |
|
||||
|-------|----------------|
|
||||
| `s` | Skip song |
|
||||
| `p` | Play/Pause |
|
||||
| `+/-` | Volume Up/Down |
|
||||
| `q` | Quit |
|
||||
| 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 |
|
||||
|
||||
> [!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 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 have something you'd like to tweak about lowfi, you can run `lowfi help`
|
||||
to view the available options.
|
||||
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`.
|
||||
|
||||
| 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 |
|
||||
| `-d`, `--debug` | Include ALSA & other logs |
|
||||
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
||||
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
|
||||
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
|
||||
|
||||
### Scraping
|
||||
|
||||
@ -145,19 +185,21 @@ or it could also be the name of a file (without the `.txt` extension) in the dat
|
||||
directory, so on Linux it's `~/.local/share/lowfi`.
|
||||
|
||||
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||
Whereas if you did `lowfi --tracks /home/user/Music/minipop.txt` it would load from that
|
||||
Whereas if you did `lowfi --tracks ~/Music/minipop.txt` it would load from that
|
||||
specified directory.
|
||||
|
||||
#### The Format
|
||||
|
||||
In Lists, the first line should be the base URL, followed by the rest of the tracks.
|
||||
In lists, the first line should be the base URL, followed by the rest of the tracks.
|
||||
This is also known as the "header", because it comes first.
|
||||
|
||||
Each track will be first appended to the base URL, and then the result use to download
|
||||
the track. All tracks should end in `.mp3` and as such must be in the MP3 format.
|
||||
the track. All tracks must be in the MP3 format, as lowfi doesn't support any others currently.
|
||||
|
||||
lowfi won't put a `/` between the base & track for added flexibility, so for most cases you
|
||||
should have a trailing `/` in your base url. The exception to this is if the track name begins
|
||||
with something like `https://`, where in that case the base will not be prepended to it.
|
||||
Additionally, lowfi _won't_ put a `/` between the base & track for added flexibility,
|
||||
so for most cases you should have a trailing `/` in your base url.
|
||||
The exception to this is if the track name begins with something like `https://`,
|
||||
where in that case the base will not be prepended to it.
|
||||
|
||||
For example, in this list:
|
||||
|
||||
@ -173,3 +215,14 @@ 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/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.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.
|
||||
|
||||
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
||||
|
1140
data/chillhop.txt
Normal file
1140
data/chillhop.txt
Normal file
File diff suppressed because it is too large
Load Diff
2
data/file.txt
Normal file
2
data/file.txt
Normal file
@ -0,0 +1,2 @@
|
||||
file:///home/user/Music/
|
||||
Anomaly.mp3
|
88
src/main.rs
88
src/main.rs
@ -1,45 +1,13 @@
|
||||
//! An extremely simple lofi player.
|
||||
|
||||
#![warn(clippy::all, clippy::restriction, clippy::pedantic, clippy::nursery)]
|
||||
#![allow(
|
||||
clippy::single_call_fn,
|
||||
clippy::struct_excessive_bools,
|
||||
clippy::implicit_return,
|
||||
clippy::question_mark_used,
|
||||
clippy::shadow_reuse,
|
||||
clippy::indexing_slicing,
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::std_instead_of_core,
|
||||
clippy::print_stdout,
|
||||
clippy::float_arithmetic,
|
||||
clippy::integer_division_remainder_used,
|
||||
clippy::used_underscore_binding,
|
||||
clippy::print_stderr,
|
||||
clippy::semicolon_outside_block,
|
||||
clippy::non_send_fields_in_send_ty,
|
||||
clippy::non_ascii_literal,
|
||||
clippy::let_underscore_untyped,
|
||||
clippy::let_underscore_must_use,
|
||||
clippy::shadow_unrelated,
|
||||
clippy::std_instead_of_alloc,
|
||||
clippy::partial_pub_fields,
|
||||
clippy::unseparated_literal_suffix,
|
||||
clippy::self_named_module_files,
|
||||
// TODO: Disallow these lints later.
|
||||
clippy::unwrap_used,
|
||||
clippy::pattern_type_mismatch,
|
||||
clippy::tuple_array_conversions,
|
||||
clippy::as_conversions,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::wildcard_enum_match_arm,
|
||||
clippy::integer_division,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_lossless,
|
||||
)]
|
||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use eyre::OptionExt;
|
||||
|
||||
mod messages;
|
||||
mod play;
|
||||
mod player;
|
||||
mod tracks;
|
||||
@ -48,32 +16,48 @@ mod tracks;
|
||||
mod scrape;
|
||||
|
||||
/// An extremely simple lofi player.
|
||||
#[derive(Parser)]
|
||||
#[derive(Parser, Clone)]
|
||||
#[command(about, version)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "señor clippy, i assure you this is not a state machine"
|
||||
)]
|
||||
struct Args {
|
||||
/// Whether to use an alternate terminal screen.
|
||||
/// Use an alternate terminal screen.
|
||||
#[clap(long, short)]
|
||||
alternate: bool,
|
||||
|
||||
/// Whether to hide the bottom control bar.
|
||||
/// Hide the bottom control bar.
|
||||
#[clap(long, short)]
|
||||
minimalist: bool,
|
||||
|
||||
/// Whether to start lowfi paused.
|
||||
/// Exclude borders in UI.
|
||||
#[clap(long, short)]
|
||||
borderless: bool,
|
||||
|
||||
/// Start lowfi paused.
|
||||
#[clap(long, short)]
|
||||
paused: bool,
|
||||
|
||||
/// Whether to include ALSA & other logs.
|
||||
/// FPS of the UI.
|
||||
#[clap(long, short, default_value_t = 12)]
|
||||
fps: u8,
|
||||
|
||||
/// Include ALSA & other logs.
|
||||
#[clap(long, short)]
|
||||
debug: bool,
|
||||
|
||||
/// The width of the player, from 0 to 32.
|
||||
/// Width of the player, from 0 to 32.
|
||||
#[clap(long, short, default_value_t = 3)]
|
||||
width: usize,
|
||||
|
||||
/// This is either a path, or a name of a file in the data directory (eg. ~/.local/share/lowfi).
|
||||
/// Use a custom track list
|
||||
#[clap(long, short, alias = "list", short_alias = 'l')]
|
||||
tracks: Option<String>,
|
||||
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.
|
||||
@ -82,7 +66,7 @@ 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.
|
||||
Scrape {
|
||||
@ -96,8 +80,20 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
/// Gets lowfi's data directory.
|
||||
pub fn data_dir() -> eyre::Result<PathBuf> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")?
|
||||
.join("lowfi");
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
#[cfg(target_os = "android")]
|
||||
compile_error!("Android Audio API not supported due to threading shenanigans");
|
||||
|
||||
let cli = Args::parse();
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
|
37
src/messages.rs
Normal file
37
src/messages.rs
Normal file
@ -0,0 +1,37 @@
|
||||
/// Handles communication between the frontend & audio player.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
pub enum Messages {
|
||||
/// Notifies the audio server that it should update the track.
|
||||
Next,
|
||||
|
||||
/// Special in that this isn't sent in a "client to server" sort of way,
|
||||
/// but rather is sent by a child of the server when a song has not only
|
||||
/// been requested but also downloaded aswell.
|
||||
NewSong,
|
||||
|
||||
/// This signal is only sent if a track timed out. In that case,
|
||||
/// lowfi will try again and again to retrieve the track.
|
||||
TryAgain,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Unpause the [Sink].
|
||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||
Play,
|
||||
|
||||
/// Pauses the [Sink].
|
||||
Pause,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
PlayPause,
|
||||
|
||||
/// Change the volume of playback.
|
||||
ChangeVolume(f32),
|
||||
|
||||
/// Bookmark the current track.
|
||||
Bookmark,
|
||||
|
||||
/// Quits gracefully.
|
||||
Quit,
|
||||
}
|
60
src/play.rs
60
src/play.rs
@ -1,5 +1,7 @@
|
||||
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
|
||||
|
||||
use std::env;
|
||||
use std::io::{stdout, IsTerminal};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -7,8 +9,9 @@ use eyre::eyre;
|
||||
use tokio::fs;
|
||||
use tokio::{sync::mpsc, task};
|
||||
|
||||
use crate::messages::Messages;
|
||||
use crate::player::ui;
|
||||
use crate::player::Player;
|
||||
use crate::player::{ui, Messages};
|
||||
use crate::Args;
|
||||
|
||||
/// This is the representation of the persistent volume,
|
||||
@ -23,7 +26,7 @@ impl PersistentVolume {
|
||||
/// Retrieves the config directory.
|
||||
async fn config() -> eyre::Result<PathBuf> {
|
||||
let config = dirs::config_dir()
|
||||
.ok_or(eyre!("Couldn't find config directory"))?
|
||||
.ok_or_else(|| eyre!("Couldn't find config directory"))?
|
||||
.join(PathBuf::from("lowfi"));
|
||||
|
||||
if !config.exists() {
|
||||
@ -35,7 +38,7 @@ impl PersistentVolume {
|
||||
|
||||
/// Returns the volume as a float from 0 to 1.
|
||||
pub fn float(self) -> f32 {
|
||||
self.inner as f32 / 100.0
|
||||
f32::from(self.inner) / 100.0
|
||||
}
|
||||
|
||||
/// Loads the [`PersistentVolume`] from [`dirs::config_dir()`].
|
||||
@ -64,31 +67,72 @@ impl PersistentVolume {
|
||||
let config = Self::config().await?;
|
||||
let path = config.join(PathBuf::from("volume.txt"));
|
||||
|
||||
fs::write(path, ((volume * 100.0).abs().round() as u16).to_string()).await?;
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "already rounded & absolute, therefore this should be safe"
|
||||
)]
|
||||
let percentage = (volume * 100.0).abs().round() as u16;
|
||||
|
||||
fs::write(path, percentage.to_string()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around [`rodio::OutputStream`] to implement [Send], currently unsafely.
|
||||
///
|
||||
/// This is more of a temporary solution until cpal implements [Send] on it's output stream.
|
||||
pub struct SendableOutputStream(pub rodio::OutputStream);
|
||||
|
||||
// SAFETY: This is necessary because [OutputStream] does not implement [Send],
|
||||
// due to some limitation with Android's Audio API.
|
||||
// I'm pretty sure nobody will use lowfi with android, so this is safe.
|
||||
#[expect(
|
||||
clippy::non_send_fields_in_send_ty,
|
||||
reason = "this is expected because of the nature of the struct"
|
||||
)]
|
||||
unsafe impl Send for SendableOutputStream {}
|
||||
|
||||
/// Initializes the audio server, and then safely stops
|
||||
/// it when the frontend quits.
|
||||
pub async fn play(args: Args) -> eyre::Result<()> {
|
||||
// Actually initializes the player.
|
||||
let player = Arc::new(Player::new(&args).await?);
|
||||
// Stream kept here in the master thread to keep it alive.
|
||||
let (player, stream) = Player::new(&args).await?;
|
||||
let player = Arc::new(player);
|
||||
|
||||
// Initialize the UI, as well as the internal communication channel.
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
|
||||
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
|
||||
};
|
||||
|
||||
// Sends the player an "init" signal telling it to start playing a song straight away.
|
||||
tx.send(Messages::Init).await?;
|
||||
|
||||
// Actually starts the player.
|
||||
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
|
||||
Player::play(
|
||||
Arc::clone(&player),
|
||||
tx.clone(),
|
||||
rx,
|
||||
args.buffer_size,
|
||||
args.debug,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Save the volume.txt file for the next session.
|
||||
PersistentVolume::save(player.sink.volume()).await?;
|
||||
drop(stream.0);
|
||||
player.sink.stop();
|
||||
ui.abort();
|
||||
ui.and_then(|x| Some(x.abort()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
137
src/player.rs
137
src/player.rs
@ -2,11 +2,17 @@
|
||||
//! This also has the code for the underlying
|
||||
//! audio server which adds new tracks.
|
||||
|
||||
use std::{collections::VecDeque, ffi::CString, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use downloader::Downloader;
|
||||
use libc::freopen;
|
||||
use reqwest::Client;
|
||||
use rodio::{OutputStream, OutputStreamHandle, Sink};
|
||||
use tokio::{
|
||||
@ -23,8 +29,9 @@ use tokio::{
|
||||
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
||||
|
||||
use crate::{
|
||||
play::PersistentVolume,
|
||||
tracks::{self, list::List},
|
||||
messages::Messages,
|
||||
play::{PersistentVolume, SendableOutputStream},
|
||||
tracks::{self, bookmark, list::List, TrackError},
|
||||
Args,
|
||||
};
|
||||
|
||||
@ -34,45 +41,8 @@ pub mod ui;
|
||||
#[cfg(feature = "mpris")]
|
||||
pub mod mpris;
|
||||
|
||||
/// Handles communication between the frontend & audio player.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
pub enum Messages {
|
||||
/// Notifies the audio server that it should update the track.
|
||||
Next,
|
||||
|
||||
/// Special in that this isn't sent in a "client to server" sort of way,
|
||||
/// but rather is sent by a child of the server when a song has not only
|
||||
/// been requested but also downloaded aswell.
|
||||
NewSong,
|
||||
|
||||
/// This signal is only sent if a track timed out. In that case,
|
||||
/// lowfi will try again and again to retrieve the track.
|
||||
TryAgain,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Unpause the [Sink].
|
||||
Play,
|
||||
|
||||
/// Pauses the [Sink].
|
||||
Pause,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
PlayPause,
|
||||
|
||||
/// Change the volume of playback.
|
||||
ChangeVolume(f32),
|
||||
|
||||
/// Quits gracefully.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// The time to wait in between errors.
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The amount of songs to buffer up.
|
||||
const BUFFER_SIZE: usize = 5;
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Main struct responsible for queuing up & playing tracks.
|
||||
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
|
||||
@ -85,6 +55,9 @@ pub struct Player {
|
||||
/// [rodio]'s [`Sink`] which can control playback.
|
||||
pub sink: Sink,
|
||||
|
||||
/// Whether the current track has been bookmarked.
|
||||
bookmarked: AtomicBool,
|
||||
|
||||
/// The [`TrackInfo`] of the current track.
|
||||
/// This is [`None`] when lowfi is buffering/loading.
|
||||
current: ArcSwapOption<tracks::Info>,
|
||||
@ -109,23 +82,16 @@ pub struct Player {
|
||||
/// 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],
|
||||
// due to some limitation with Android's Audio API.
|
||||
// I'm pretty sure nobody will use lowfi with android, so this is safe.
|
||||
unsafe impl Send for Player {}
|
||||
|
||||
// SAFETY: See implementation for [Send].
|
||||
unsafe impl Sync for Player {}
|
||||
|
||||
impl Player {
|
||||
/// This gets the output stream while also shutting up alsa with [libc].
|
||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn silent_get_output_stream() -> eyre::Result<(OutputStream, OutputStreamHandle)> {
|
||||
use libc::freopen;
|
||||
use std::ffi::CString;
|
||||
|
||||
// Get the file descriptor to stderr from libc.
|
||||
extern "C" {
|
||||
static stderr: *mut libc::FILE;
|
||||
@ -177,20 +143,25 @@ impl Player {
|
||||
/// 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> {
|
||||
pub async fn new(args: &Args) -> eyre::Result<(Self, SendableOutputStream)> {
|
||||
// Load the volume file.
|
||||
let volume = PersistentVolume::load().await?;
|
||||
|
||||
// Load the track list.
|
||||
let list = List::load(&args.tracks).await?;
|
||||
let list = List::load(args.track_list.as_ref()).await?;
|
||||
|
||||
// We should only shut up alsa forcefully if we really have to.
|
||||
let (_stream, handle) = if cfg!(target_os = "linux") && !args.alternate && !args.debug {
|
||||
// We should only shut up alsa forcefully on Linux if we really have to.
|
||||
#[cfg(target_os = "linux")]
|
||||
let (stream, handle) = if !args.alternate && !args.debug {
|
||||
Self::silent_get_output_stream()?
|
||||
} else {
|
||||
OutputStream::try_default()?
|
||||
};
|
||||
|
||||
// If we're not on Linux, then there's no problem.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (stream, handle) = OutputStream::try_default()?;
|
||||
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
if args.paused {
|
||||
sink.pause();
|
||||
@ -206,24 +177,26 @@ impl Player {
|
||||
.build()?;
|
||||
|
||||
let player = Self {
|
||||
tracks: RwLock::new(VecDeque::with_capacity(5)),
|
||||
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
||||
current: ArcSwapOption::new(None),
|
||||
client,
|
||||
sink,
|
||||
volume,
|
||||
list,
|
||||
_handle: handle,
|
||||
_stream,
|
||||
bookmarked: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
Ok(player)
|
||||
Ok((player, SendableOutputStream(stream)))
|
||||
}
|
||||
|
||||
/// This will play the next track, as well as refilling the buffer in the background.
|
||||
///
|
||||
/// This will also set `current` to the newly loaded song.
|
||||
pub async fn next(&self) -> eyre::Result<tracks::Decoded> {
|
||||
let track = if let Some(track) = self.tracks.write().await.pop_front() {
|
||||
pub async fn next(&self) -> Result<tracks::Decoded, tracks::TrackError> {
|
||||
// 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.
|
||||
@ -233,11 +206,10 @@ impl Player {
|
||||
// We're doing it here so that we don't get the "loading" display
|
||||
// for only a frame in the other case that the buffer is not empty.
|
||||
self.current.store(None);
|
||||
|
||||
self.list.random(&self.client).await?
|
||||
};
|
||||
|
||||
let decoded = track.decode()?;
|
||||
let decoded = track.decode().map_err(|x| TrackError::new_eyre(false, x))?;
|
||||
|
||||
// Set the current track.
|
||||
self.set_current(decoded.info.clone());
|
||||
@ -257,6 +229,7 @@ impl Player {
|
||||
player: Arc<Self>,
|
||||
itx: Sender<()>,
|
||||
tx: Sender<Messages>,
|
||||
debug: bool,
|
||||
) -> eyre::Result<()> {
|
||||
// Stop the sink.
|
||||
player.sink.stop();
|
||||
@ -276,7 +249,11 @@ impl Player {
|
||||
tx.send(Messages::NewSong).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
if !error.downcast::<reqwest::Error>()?.is_timeout() {
|
||||
if !error.timeout {
|
||||
if debug {
|
||||
panic!("{:?}", error)
|
||||
}
|
||||
|
||||
sleep(TIMEOUT).await;
|
||||
}
|
||||
|
||||
@ -293,10 +270,13 @@ impl Player {
|
||||
/// 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>,
|
||||
buf_size: usize,
|
||||
debug: bool,
|
||||
) -> eyre::Result<()> {
|
||||
// Initialize the mpris player.
|
||||
//
|
||||
@ -312,8 +292,8 @@ impl Player {
|
||||
})?;
|
||||
|
||||
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
||||
let downloader = Downloader::new(Arc::clone(&player));
|
||||
let (itx, downloader) = downloader.start();
|
||||
let downloader = Downloader::new(Arc::clone(&player), buf_size);
|
||||
let (itx, downloader) = downloader.start(debug);
|
||||
|
||||
// Start buffering tracks immediately.
|
||||
Downloader::notify(&itx).await?;
|
||||
@ -325,7 +305,7 @@ impl Player {
|
||||
// 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.
|
||||
// loaded and it'll be `false` if a track is still currently loading.
|
||||
let mut new = false;
|
||||
|
||||
loop {
|
||||
@ -352,6 +332,8 @@ impl Player {
|
||||
|
||||
match msg {
|
||||
Messages::Next | Messages::Init | Messages::TryAgain => {
|
||||
player.bookmarked.swap(false, Ordering::Relaxed);
|
||||
|
||||
// We manually skipped, so we shouldn't actually wait for the song
|
||||
// to be over until we recieve the `NewSong` signal.
|
||||
new = false;
|
||||
@ -367,6 +349,7 @@ impl Player {
|
||||
Arc::clone(&player),
|
||||
itx.clone(),
|
||||
tx.clone(),
|
||||
debug,
|
||||
));
|
||||
}
|
||||
Messages::Play => {
|
||||
@ -419,6 +402,22 @@ impl Player {
|
||||
|
||||
continue;
|
||||
}
|
||||
Messages::Bookmark => {
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref().unwrap();
|
||||
|
||||
let bookmarked = bookmark::bookmark(
|
||||
current.full_path.clone(),
|
||||
if current.custom_name {
|
||||
Some(current.display_name.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
player.bookmarked.swap(bookmarked, Ordering::Relaxed);
|
||||
}
|
||||
Messages::Quit => break,
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ use tokio::{
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use super::{Player, BUFFER_SIZE, TIMEOUT};
|
||||
use super::{Player, TIMEOUT};
|
||||
|
||||
/// This struct is responsible for downloading tracks in the background.
|
||||
///
|
||||
@ -24,6 +24,9 @@ pub struct Downloader {
|
||||
/// A copy of the internal sender, which can be useful for keeping
|
||||
/// track of it.
|
||||
tx: Sender<()>,
|
||||
|
||||
/// The size of the internal download buffer.
|
||||
buf_size: usize,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
@ -37,24 +40,34 @@ impl 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 {
|
||||
pub fn new(player: Arc<Player>, buf_size: usize) -> Self {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
Self { player, rx, tx }
|
||||
Self {
|
||||
player,
|
||||
rx,
|
||||
tx,
|
||||
buf_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually starts & consumes the [Downloader].
|
||||
pub fn start(mut self) -> (Sender<()>, JoinHandle<()>) {
|
||||
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
|
||||
(
|
||||
self.tx,
|
||||
task::spawn(async move {
|
||||
// Loop through each update notification.
|
||||
while self.rx.recv().await == Some(()) {
|
||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
||||
while self.player.tracks.read().await.len() < BUFFER_SIZE {
|
||||
match self.player.list.random(&self.player.client).await {
|
||||
while self.player.tracks.read().await.len() < self.buf_size {
|
||||
let data = self.player.list.random(&self.player.client).await;
|
||||
match data {
|
||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
||||
Err(error) => {
|
||||
if !error.is_timeout() {
|
||||
if !error.timeout {
|
||||
if debug {
|
||||
panic!("{}", error)
|
||||
}
|
||||
|
||||
sleep(TIMEOUT).await;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//! Contains the code for the MPRIS server & other helper functions.
|
||||
|
||||
use std::{process, sync::Arc};
|
||||
use std::{env, process, sync::Arc};
|
||||
|
||||
use mpris_server::{
|
||||
zbus::{self, fdo, Result},
|
||||
@ -9,6 +9,7 @@ use mpris_server::{
|
||||
};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use super::ui;
|
||||
use super::Messages;
|
||||
|
||||
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
|
||||
@ -167,7 +168,10 @@ impl PlayerInterface for Player {
|
||||
.load()
|
||||
.as_ref()
|
||||
.map_or_else(Metadata::new, |track| {
|
||||
let mut metadata = Metadata::builder().title(track.name.clone()).build();
|
||||
let mut metadata = Metadata::builder()
|
||||
.title(track.display_name.clone())
|
||||
.album(self.player.list.name.clone())
|
||||
.build();
|
||||
|
||||
metadata.set_length(
|
||||
track
|
||||
@ -187,6 +191,7 @@ impl PlayerInterface for Player {
|
||||
|
||||
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||
self.player.set_volume(volume as f32);
|
||||
ui::flash_audio();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -262,7 +267,11 @@ impl Server {
|
||||
|
||||
/// Creates a new MPRIS server.
|
||||
pub async fn new(player: Arc<super::Player>, sender: Sender<Messages>) -> eyre::Result<Self> {
|
||||
let suffix = format!("lowfi.{}.instance{}", player.list.name, process::id());
|
||||
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?;
|
||||
|
||||
|
109
src/player/ui.rs
109
src/player/ui.rs
@ -1,7 +1,15 @@
|
||||
//! The module which manages all user interface, including inputs.
|
||||
|
||||
#![allow(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "the ui is full of these because of various layout & positioning aspects, and for a simple music player making all casts safe is not worth the effort"
|
||||
)]
|
||||
|
||||
use std::{
|
||||
fmt::Write,
|
||||
fmt::Write as _,
|
||||
io::{stdout, Stdout},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@ -15,30 +23,23 @@ use crate::Args;
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
|
||||
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
||||
style::{Print, Stylize},
|
||||
style::{Print, Stylize as _},
|
||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::{Messages, Player};
|
||||
|
||||
mod components;
|
||||
mod input;
|
||||
|
||||
/// Self explanitory.
|
||||
const FPS: usize = 12;
|
||||
|
||||
/// How long the audio bar will be visible for when audio is adjusted.
|
||||
/// This is in frames.
|
||||
const AUDIO_BAR_DURATION: usize = 10;
|
||||
|
||||
/// How long to wait in between frames.
|
||||
/// This is fairly arbitrary, but an ideal value should be enough to feel
|
||||
/// snappy but not require too many resources.
|
||||
const FRAME_DELTA: f32 = 1.0 / FPS as f32;
|
||||
|
||||
lazy_static! {
|
||||
/// The volume timer, which controls how long the volume display should
|
||||
/// show up and when it should disappear.
|
||||
@ -48,57 +49,83 @@ lazy_static! {
|
||||
static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0);
|
||||
}
|
||||
|
||||
/// Sets the volume timer to one, effectively flashing the audio display in lowfi's UI.
|
||||
///
|
||||
/// The amount of frames the audio display is visible for is determined by [`AUDIO_BAR_DURATION`].
|
||||
pub fn flash_audio() {
|
||||
VOLUME_TIMER.store(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Represents an abstraction for drawing the actual lowfi window itself.
|
||||
///
|
||||
/// The main purpose of this struct is just to add the fancy border,
|
||||
/// as well as clear the screen before drawing.
|
||||
pub struct Window {
|
||||
/// Whether or not to include borders in the output.
|
||||
borderless: bool,
|
||||
|
||||
/// The top & bottom borders, which are here since they can be
|
||||
/// prerendered, as they don't change from window to window.
|
||||
///
|
||||
/// If the option to not include borders is set, these will just be empty [String]s.
|
||||
borders: [String; 2],
|
||||
|
||||
/// The width of the window.
|
||||
width: usize,
|
||||
|
||||
/// The output, currently just an [`Stdout`].
|
||||
out: Stdout,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Initializes a new [Window].
|
||||
pub fn new(width: usize) -> Self {
|
||||
///
|
||||
/// * `width` - Width of the windows.
|
||||
/// * `borderless` - Whether to include borders in the window, or not.
|
||||
pub fn new(width: usize, borderless: bool) -> Self {
|
||||
let borders = if borderless {
|
||||
[String::new(), String::new()]
|
||||
} else {
|
||||
let middle = "─".repeat(width + 2);
|
||||
|
||||
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
||||
};
|
||||
|
||||
Self {
|
||||
borders: [
|
||||
format!("┌{}┐\r\n", "─".repeat(width + 2)),
|
||||
// This one doesn't have a leading \r\n to avoid extra space under the window.
|
||||
format!("└{}┘", "─".repeat(width + 2)),
|
||||
],
|
||||
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>) -> eyre::Result<()> {
|
||||
let len = content.len() as u16;
|
||||
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<()> {
|
||||
let len: u16 = content.len().try_into()?;
|
||||
|
||||
// Note that this will have a trailing newline, which we use later.
|
||||
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
||||
write!(output, "│ {} │\r\n", x.reset()).unwrap();
|
||||
// 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. Again, it's stupid.
|
||||
// writing to the last line repeatedly.
|
||||
#[cfg(windows)]
|
||||
let (rendered, height) = (
|
||||
format!("{}{}{}\r\n", self.borders[0], menu, self.borders[1]),
|
||||
len + 2,
|
||||
);
|
||||
|
||||
// Unix has no such ridiculous limitations, so we calculate
|
||||
// the height of the window accurately.
|
||||
let (height, suffix) = (len + 2, "\r\n");
|
||||
#[cfg(not(windows))]
|
||||
let (rendered, height) = (
|
||||
format!("{}{}{}", self.borders[0], menu, self.borders[1]),
|
||||
len + 1,
|
||||
);
|
||||
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,
|
||||
@ -116,9 +143,16 @@ impl Window {
|
||||
/// The code for the terminal interface itself.
|
||||
///
|
||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||
/// * `borderless` - Whether to include borders or not.
|
||||
/// * `width` - The width of player
|
||||
async fn interface(player: Arc<Player>, minimalist: bool, width: usize) -> eyre::Result<()> {
|
||||
let mut window = Window::new(width);
|
||||
async fn interface(
|
||||
player: Arc<Player>,
|
||||
minimalist: bool,
|
||||
borderless: bool,
|
||||
fps: u8,
|
||||
width: usize,
|
||||
) -> eyre::Result<()> {
|
||||
let mut window = Window::new(width, borderless);
|
||||
|
||||
loop {
|
||||
// Load `current` once so that it doesn't have to be loaded over and over
|
||||
@ -140,7 +174,7 @@ async fn interface(player: Arc<Player>, minimalist: bool, width: usize) -> eyre:
|
||||
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 timer > AUDIO_BAR_DURATION {
|
||||
} else {
|
||||
// If enough time has passed, we'll reset it back to 0.
|
||||
VOLUME_TIMER.store(0, Ordering::Relaxed);
|
||||
}
|
||||
@ -153,9 +187,10 @@ async fn interface(player: Arc<Player>, minimalist: bool, width: usize) -> eyre:
|
||||
vec![action, middle, controls]
|
||||
};
|
||||
|
||||
window.draw(menu)?;
|
||||
window.draw(menu, false)?;
|
||||
|
||||
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
|
||||
let delta = 1.0 / (fps as f32);
|
||||
sleep(Duration::from_secs_f32(delta)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,6 +272,8 @@ pub async fn start(player: Arc<Player>, sender: Sender<Messages>, args: Args) ->
|
||||
let interface = task::spawn(interface(
|
||||
Arc::clone(&player),
|
||||
args.minimalist,
|
||||
args.borderless,
|
||||
args.fps,
|
||||
21 + args.width.min(32) * 2,
|
||||
));
|
||||
|
||||
|
@ -1,10 +1,14 @@
|
||||
//! Various different individual components that
|
||||
//! appear in lowfi's UI, like the progress bar.
|
||||
|
||||
use std::{ops::Deref, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
ops::Deref as _,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use crossterm::style::Stylize as _;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use crate::{player::Player, tracks::Info};
|
||||
|
||||
@ -72,16 +76,21 @@ enum ActionBar {
|
||||
impl ActionBar {
|
||||
/// Formats the action bar to be displayed.
|
||||
/// The second value is the character length of the result.
|
||||
fn format(&self) -> (String, usize) {
|
||||
fn format(&self, star: bool) -> (String, usize) {
|
||||
let (word, subject) = match self {
|
||||
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
|
||||
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
|
||||
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
||||
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
||||
Self::Loading => ("loading", None),
|
||||
};
|
||||
|
||||
subject.map_or_else(
|
||||
|| (word.to_owned(), word.len()),
|
||||
|(subject, len)| (format!("{} {}", word, subject.bold()), word.len() + 1 + len),
|
||||
|(subject, len)| {
|
||||
(
|
||||
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
|
||||
word.len() + 1 + len + if star { 1 } else { 0 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -99,12 +108,12 @@ pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> Str
|
||||
ActionBar::Playing(info)
|
||||
}
|
||||
})
|
||||
.format();
|
||||
.format(player.bookmarked.load(Ordering::Relaxed));
|
||||
|
||||
if len > width {
|
||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||
|
||||
format!("{}...", chopped)
|
||||
format!("{chopped}...")
|
||||
} else {
|
||||
format!("{}{}", main, " ".repeat(width - len))
|
||||
}
|
||||
|
@ -1,15 +1,11 @@
|
||||
//! Responsible for specifically recieving terminal input
|
||||
//! using [`crossterm`].
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use futures::{FutureExt as _, StreamExt as _};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::player::Messages;
|
||||
|
||||
use super::VOLUME_TIMER;
|
||||
use crate::player::{ui, Messages};
|
||||
|
||||
/// Starts the listener to recieve input from the terminal for various events.
|
||||
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
||||
@ -38,14 +34,17 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
||||
'q' => Messages::Quit,
|
||||
|
||||
// Skip/Next
|
||||
's' | 'n' => Messages::Next,
|
||||
's' | 'n' | 'l' => Messages::Next,
|
||||
|
||||
// Pause
|
||||
'p' => Messages::PlayPause,
|
||||
'p' | ' ' => Messages::PlayPause,
|
||||
|
||||
// Volume up & down
|
||||
'+' | '=' => Messages::ChangeVolume(0.1),
|
||||
'-' | '_' => Messages::ChangeVolume(-0.1),
|
||||
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
|
||||
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
|
||||
|
||||
// Bookmark
|
||||
'b' => Messages::Bookmark,
|
||||
|
||||
_ => continue,
|
||||
},
|
||||
@ -64,10 +63,8 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// If it's modifying the volume, then we'll set the `VOLUME_TIMER` to 1
|
||||
// so that the UI thread will know that it should show the audio bar.
|
||||
if let Messages::ChangeVolume(_) = messages {
|
||||
VOLUME_TIMER.store(1, Ordering::Relaxed);
|
||||
ui::flash_audio();
|
||||
}
|
||||
|
||||
sender.send(messages).await?;
|
||||
|
202
src/tracks.rs
202
src/tracks.rs
@ -1,28 +1,85 @@
|
||||
//! 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 [`Track`] 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::{error::Error, io::Cursor, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use inflector::Inflector;
|
||||
use rodio::{Decoder, Source};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use eyre::OptionExt as _;
|
||||
use inflector::Inflector as _;
|
||||
use rodio::{Decoder, Source as _};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use url::form_urlencoded;
|
||||
|
||||
pub mod bookmark;
|
||||
pub mod list;
|
||||
|
||||
/// Just a shorthand for a decoded [Bytes].
|
||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// The main track struct, which only includes data & the track name.
|
||||
pub struct Track {
|
||||
/// 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 Track {
|
||||
/// This will actually decode and format the track,
|
||||
/// returning a [`DecodedTrack`] which can be played
|
||||
/// and also has a duration & formatted name.
|
||||
pub fn decode(self) -> eyre::Result<Decoded> {
|
||||
Decoded::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, 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.
|
||||
@ -36,6 +93,10 @@ pub struct Info {
|
||||
impl Info {
|
||||
/// Decodes a URL string into normal UTF-8.
|
||||
fn decode_url(text: &str) -> String {
|
||||
#[expect(
|
||||
clippy::tuple_array_conversions,
|
||||
reason = "the tuple contains smart pointers, so it's not really practical to use `into()`"
|
||||
)]
|
||||
form_urlencoded::parse(text.as_bytes())
|
||||
.map(|(key, val)| [key, val].concat())
|
||||
.collect()
|
||||
@ -44,26 +105,26 @@ impl Info {
|
||||
/// 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: &str) -> String {
|
||||
let formatted = Self::decode_url(
|
||||
name.split('/')
|
||||
.last()
|
||||
.unwrap()
|
||||
.strip_suffix(".mp3")
|
||||
.unwrap(),
|
||||
)
|
||||
.to_lowercase()
|
||||
.to_title_case()
|
||||
// Inflector doesn't like contractions...
|
||||
// Replaces a few very common ones.
|
||||
// TODO: Properly handle these.
|
||||
.replace(" S ", "'s ")
|
||||
.replace(" T ", "'t ")
|
||||
.replace(" D ", "'d ")
|
||||
.replace(" Ve ", "'ve ")
|
||||
.replace(" Ll ", "'ll ")
|
||||
.replace(" Re ", "'re ")
|
||||
.replace(" M ", "'m ");
|
||||
fn format_name(name: &str) -> eyre::Result<String> {
|
||||
let split = name
|
||||
.split('/')
|
||||
.last()
|
||||
.ok_or_eyre("split is never supposed to return nothing")?;
|
||||
|
||||
let stripped = split.strip_suffix(".mp3").unwrap_or(split);
|
||||
let formatted = Self::decode_url(stripped)
|
||||
.to_lowercase()
|
||||
.to_title_case()
|
||||
// Inflector doesn't like contractions...
|
||||
// Replaces a few very common ones.
|
||||
// TODO: Properly handle these.
|
||||
.replace(" S ", "'s ")
|
||||
.replace(" T ", "'t ")
|
||||
.replace(" D ", "'d ")
|
||||
.replace(" Ve ", "'ve ")
|
||||
.replace(" Ll ", "'ll ")
|
||||
.replace(" Re ", "'re ")
|
||||
.replace(" M ", "'m ");
|
||||
|
||||
// This is incremented for each digit in front of the song name.
|
||||
let mut skip = 0;
|
||||
@ -76,19 +137,32 @@ impl Info {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::string_slice, /* We've already checked before that the bound is at an ASCII digit. */)]
|
||||
String::from(&formatted[skip..])
|
||||
// If the entire name of the track is a number, then just return it.
|
||||
if skip == formatted.len() {
|
||||
Ok(formatted)
|
||||
} else {
|
||||
#[expect(
|
||||
clippy::string_slice,
|
||||
reason = "We've already checked before that the bound is at an ASCII digit."
|
||||
)]
|
||||
Ok(String::from(&formatted[skip..]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
|
||||
pub fn new(name: &str, decoded: &DecodedData) -> Self {
|
||||
let name = Self::format_name(name);
|
||||
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
|
||||
pub fn new(name: TrackName, full_path: String, decoded: &DecodedData) -> eyre::Result<Self> {
|
||||
let (display_name, custom_name) = match name {
|
||||
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||
TrackName::Formatted(custom) => (custom, true),
|
||||
};
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
duration: decoded.total_duration(),
|
||||
width: name.width(),
|
||||
name,
|
||||
}
|
||||
width: display_name.graphemes(true).count(),
|
||||
full_path,
|
||||
custom_name,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,27 +181,53 @@ impl Decoded {
|
||||
/// This is equivalent to [`Track::decode`].
|
||||
pub fn new(track: Track) -> eyre::Result<Self> {
|
||||
let data = Decoder::new(Cursor::new(track.data))?;
|
||||
let info = Info::new(&track.name, &data);
|
||||
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: String,
|
||||
|
||||
/// The raw data of the track, which is not decoded and
|
||||
/// therefore much more memory efficient.
|
||||
pub data: Bytes,
|
||||
#[derive(Debug)]
|
||||
pub struct TrackError {
|
||||
pub timeout: bool,
|
||||
inner: Option<eyre::Error>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// This will actually decode and format the track,
|
||||
/// returning a [`DecodedTrack`] which can be played
|
||||
/// and also has a duration & formatted name.
|
||||
pub fn decode(self) -> eyre::Result<Decoded> {
|
||||
Decoded::new(self)
|
||||
impl<'a> std::fmt::Display for TrackError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"TrackError (timeout: {}): {:?}",
|
||||
self.timeout, self.inner
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::error::Error for TrackError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
self.inner.as_ref().map(|e| e.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackError {
|
||||
pub fn new<T: Error + 'static + Send + Sync>(timeout: bool, inner: T) -> Self {
|
||||
Self {
|
||||
inner: Some(eyre::eyre!(inner)),
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_eyre(timeout: bool, inner: eyre::Error) -> Self {
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty(timeout: bool) -> Self {
|
||||
Self {
|
||||
inner: None,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
47
src/tracks/bookmark.rs
Normal file
47
src/tracks/bookmark.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use std::io::SeekFrom;
|
||||
|
||||
use tokio::fs::{create_dir_all, OpenOptions};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use crate::data_dir;
|
||||
|
||||
/// 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(path: String, custom: Option<String>) -> eyre::Result<bool> {
|
||||
let mut entry = format!("{path}");
|
||||
if let Some(custom) = custom {
|
||||
entry.push('!');
|
||||
entry.push_str(&custom);
|
||||
}
|
||||
|
||||
let data_dir = data_dir()?;
|
||||
create_dir_all(data_dir.clone()).await?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.read(true)
|
||||
.append(false)
|
||||
.open(data_dir.join("bookmarks.txt"))
|
||||
.await?;
|
||||
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).await?;
|
||||
|
||||
let mut lines: Vec<&str> = text.trim().lines().filter(|x| !x.is_empty()).collect();
|
||||
let idx = lines.iter().position(|x| **x == entry);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
lines.remove(idx);
|
||||
} else {
|
||||
lines.push(&entry);
|
||||
}
|
||||
|
||||
let text = format!("\n{}", lines.join("\n"));
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
file.set_len(0).await?;
|
||||
file.write_all(text.as_bytes()).await?;
|
||||
|
||||
Ok(idx.is_none())
|
||||
}
|
@ -2,11 +2,13 @@
|
||||
//! as well as obtaining track names & downloading the raw mp3 data.
|
||||
|
||||
use bytes::Bytes;
|
||||
use eyre::OptionExt;
|
||||
use rand::Rng;
|
||||
use eyre::OptionExt as _;
|
||||
use rand::Rng as _;
|
||||
use reqwest::Client;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{data_dir, tracks::TrackError};
|
||||
|
||||
use super::Track;
|
||||
|
||||
/// Represents a list of tracks that can be played.
|
||||
@ -15,6 +17,7 @@ use super::Track;
|
||||
#[derive(Clone)]
|
||||
pub struct List {
|
||||
/// The "name" of the list, usually derived from a filename.
|
||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||
pub name: String,
|
||||
|
||||
/// Just the raw file, but seperated by `/n` (newlines).
|
||||
@ -28,45 +31,89 @@ impl List {
|
||||
self.lines[0].trim()
|
||||
}
|
||||
|
||||
/// Gets the name of a random track.
|
||||
fn random_name(&self) -> String {
|
||||
/// 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, sinceslow to drain only a single element from
|
||||
// how rust vectors work, since it is slower to drain only a single element from
|
||||
// the start, so it's faster to just keep it in & work around it.
|
||||
let random = rand::thread_rng().gen_range(1..self.lines.len());
|
||||
self.lines[random].clone()
|
||||
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) -> reqwest::Result<Bytes> {
|
||||
async fn download(&self, track: &str, client: &Client) -> Result<(Bytes, String), TrackError> {
|
||||
// If the track has a protocol, then we should ignore the base for it.
|
||||
let url = if track.contains("://") {
|
||||
let full_path = if track.contains("://") {
|
||||
track.to_owned()
|
||||
} else {
|
||||
format!("{}{}", self.base(), track)
|
||||
};
|
||||
|
||||
let response = client.get(url).send().await?;
|
||||
let data = response.bytes().await?;
|
||||
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(TrackError::empty(false))?;
|
||||
let home = home_path.to_str().ok_or(TrackError::empty(false))?;
|
||||
|
||||
Ok(data)
|
||||
x.replace("~", home)
|
||||
} else {
|
||||
x.to_owned()
|
||||
};
|
||||
|
||||
let result = tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|x| TrackError::new(false, x))?;
|
||||
result.into()
|
||||
} else {
|
||||
let response = client
|
||||
.get(full_path.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|x| TrackError::new(x.is_timeout(), x))?;
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|x| TrackError::new(false, x))?
|
||||
};
|
||||
|
||||
Ok((data, full_path))
|
||||
}
|
||||
|
||||
/// Fetches and downloads a random track from the [List].
|
||||
pub async fn random(&self, client: &Client) -> reqwest::Result<Track> {
|
||||
let name = self.random_name();
|
||||
let data = self.download(&name, client).await?;
|
||||
///
|
||||
/// 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) -> Result<Track, TrackError> {
|
||||
let (path, custom_name) = self.random_path();
|
||||
let (data, full_path) = self.download(&path, client).await?;
|
||||
|
||||
Ok(Track { name, data })
|
||||
let name = custom_name.map_or(super::TrackName::Raw(path.clone()), |formatted| {
|
||||
super::TrackName::Formatted(formatted)
|
||||
});
|
||||
|
||||
Ok(Track {
|
||||
name,
|
||||
data,
|
||||
full_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses text into a [List].
|
||||
pub fn new(name: &str, text: &str) -> Self {
|
||||
let lines: Vec<String> = text
|
||||
.split_ascii_whitespace()
|
||||
.map(ToOwned::to_owned)
|
||||
.trim()
|
||||
.lines()
|
||||
.map(|x| x.trim_end().to_owned())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
@ -76,13 +123,10 @@ impl List {
|
||||
}
|
||||
|
||||
/// Reads a [List] from the filesystem using the CLI argument provided.
|
||||
pub async fn load(tracks: &Option<String>) -> eyre::Result<Self> {
|
||||
pub async fn load(tracks: 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 name = dirs::data_dir()
|
||||
.unwrap()
|
||||
.join("lowfi")
|
||||
.join(format!("{}.txt", arg));
|
||||
let name = data_dir()?.join(format!("{arg}.txt"));
|
||||
|
||||
let name = if name.exists() { name } else { arg.into() };
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user