mirror of
https://github.com/talwat/lowfi
synced 2025-07-04 09:13:24 +00:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
69747ff8b4 | ||
|
0b15ce8e1b | ||
|
6fadfe6304 | ||
|
b6a81c9634 | ||
|
1af976ad77 | ||
|
e8b4b17f98 | ||
|
1a76699afc | ||
|
2ccf073646 | ||
|
315fa105bf | ||
|
7cdd2e7694 | ||
|
a89854e46f | ||
|
f1c6cbf026 | ||
|
d24c6b1a74 | ||
|
a83a052ae9 | ||
|
a9cd30550c | ||
|
29dab7a77a | ||
|
fe70800502 | ||
|
d05f36a0bb |
31
Cargo.lock
generated
31
Cargo.lock
generated
@ -1361,7 +1361,7 @@ dependencies = [
|
|||||||
"combine",
|
"combine",
|
||||||
"jni-sys",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
@ -1453,7 +1453,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "1.6.4-dev"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Inflector",
|
"Inflector",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@ -1470,6 +1470,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"rodio",
|
"rodio",
|
||||||
"scraper",
|
"scraper",
|
||||||
|
"thiserror 2.0.12",
|
||||||
"tokio",
|
"tokio",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
@ -1593,7 +1594,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2018,7 +2019,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2607,7 +2608,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2621,6 +2631,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "1.6.4-dev"
|
version = "1.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "An extremely simple lofi player."
|
description = "An extremely simple lofi player."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -51,3 +51,4 @@ lazy_static = "1.5.0"
|
|||||||
libc = "0.2.167"
|
libc = "0.2.167"
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
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`.
|
42
README.md
42
README.md
@ -7,10 +7,10 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
|
**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).
|
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.
|
follow their rules.
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
@ -21,9 +21,8 @@ app that would just play random lofi without video.
|
|||||||
It was also designed to be fairly resilient to inconsistent networks,
|
It was also designed to be fairly resilient to inconsistent networks,
|
||||||
and as such it buffers 5 whole songs at a time instead of parts of the same song.
|
and as such it buffers 5 whole songs at a time instead of parts of the same song.
|
||||||
|
|
||||||
Although, lowfi is yet to be properly tested in difficult conditions,
|
See [Scraping](#scraping) if you're interested in downloading the tracks.
|
||||||
so don't rely on it too much until I do that. See [Scraping](#scraping) if
|
Beware, there's a lot of them.
|
||||||
you're interested in downloading the tracks. Beware, there's a lot of them.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@ -86,6 +85,16 @@ echo "deb https://debian.griffo.io//apt $(lsb_release -sc 2>/dev/null) main" | s
|
|||||||
sudo apt install -y lowfi
|
sudo apt install -y lowfi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Fedora (COPR)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo dnf copr enable furqanhun/lowfi
|
||||||
|
sudo dnf install lowfi
|
||||||
|
```
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
This is good for debugging, especially in issues.
|
This is good for debugging, especially in issues.
|
||||||
@ -122,7 +131,7 @@ Yeah, that's it.
|
|||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
> Besides its regular controls, lowfi offers compatibility with Media Keys
|
||||||
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`)
|
> and [MPRIS](https://wiki.archlinux.org/title/MPRIS) (with tools like `playerctl`).
|
||||||
>
|
>
|
||||||
> MPRIS is currently optional feature in cargo (enabled with `--features mpris`)
|
> MPRIS is currently 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
|
> due to it being only for Linux, as well as the fact that the main point of
|
||||||
@ -133,15 +142,16 @@ Yeah, that's it.
|
|||||||
If you have something you'd like to tweak about lowfi, you use additional flags which
|
If you have something you'd like to tweak about lowfi, you use additional flags which
|
||||||
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
|
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
|
||||||
|
|
||||||
| Flag | Function |
|
| Flag | Function |
|
||||||
| ------------------------------- | ---------------------------------------------- |
|
| ----------------------------------- | ---------------------------------------------- |
|
||||||
| `-a`, `--alternate` | Use an alternate terminal screen |
|
| `-a`, `--alternate` | Use an alternate terminal screen |
|
||||||
| `-m`, `--minimalist` | Hide the bottom control bar |
|
| `-m`, `--minimalist` | Hide the bottom control bar |
|
||||||
| `-b`, `--borderless` | Exclude borders in UI |
|
| `-b`, `--borderless` | Exclude borders in UI |
|
||||||
| `-p`, `--paused` | Start lowfi paused |
|
| `-p`, `--paused` | Start lowfi paused |
|
||||||
| `-d`, `--debug` | Include ALSA & other logs |
|
| `-d`, `--debug` | Include ALSA & other logs |
|
||||||
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
|
||||||
| `-t`, `--tracklist <TRACKLIST>` | Use a [custom track list](#custom-track-lists) |
|
| `-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
|
### Scraping
|
||||||
|
|
||||||
@ -213,3 +223,5 @@ For example, if you had an entry like this:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then lowfi would download from the first section, and display the second as the track name.
|
Then lowfi would download from the first section, and display the second as the track name.
|
||||||
|
|
||||||
|
Further examples can be found in the [data](https://github.com/talwat/lowfi/tree/main/data) folder.
|
||||||
|
21
src/main.rs
21
src/main.rs
@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use eyre::OptionExt;
|
||||||
|
|
||||||
|
mod messages;
|
||||||
mod play;
|
mod play;
|
||||||
mod player;
|
mod player;
|
||||||
mod tracks;
|
mod tracks;
|
||||||
@ -35,6 +39,10 @@ struct Args {
|
|||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
paused: bool,
|
paused: bool,
|
||||||
|
|
||||||
|
/// FPS of the UI.
|
||||||
|
#[clap(long, short, default_value_t = 12)]
|
||||||
|
fps: u8,
|
||||||
|
|
||||||
/// Include ALSA & other logs.
|
/// Include ALSA & other logs.
|
||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
@ -47,7 +55,7 @@ struct Args {
|
|||||||
#[clap(long, short, alias = "list", short_alias = 'l')]
|
#[clap(long, short, alias = "list", short_alias = 'l')]
|
||||||
track_list: Option<String>,
|
track_list: Option<String>,
|
||||||
|
|
||||||
/// Song buffer size.
|
/// Internal song buffer size.
|
||||||
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
|
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
|
||||||
buffer_size: usize,
|
buffer_size: usize,
|
||||||
|
|
||||||
@ -72,6 +80,15 @@ 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]
|
#[tokio::main]
|
||||||
async fn main() -> eyre::Result<()> {
|
async fn main() -> eyre::Result<()> {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
|
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,
|
||||||
|
}
|
19
src/play.rs
19
src/play.rs
@ -1,5 +1,7 @@
|
|||||||
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
|
//! 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::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -7,8 +9,9 @@ use eyre::eyre;
|
|||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::{sync::mpsc, task};
|
use tokio::{sync::mpsc, task};
|
||||||
|
|
||||||
|
use crate::messages::Messages;
|
||||||
|
use crate::player::ui;
|
||||||
use crate::player::Player;
|
use crate::player::Player;
|
||||||
use crate::player::{ui, Messages};
|
|
||||||
use crate::Args;
|
use crate::Args;
|
||||||
|
|
||||||
/// This is the representation of the persistent volume,
|
/// This is the representation of the persistent volume,
|
||||||
@ -102,19 +105,27 @@ pub async fn play(args: Args) -> eyre::Result<()> {
|
|||||||
|
|
||||||
// Initialize the UI, as well as the internal communication channel.
|
// Initialize the UI, as well as the internal communication channel.
|
||||||
let (tx, rx) = mpsc::channel(8);
|
let (tx, rx) = mpsc::channel(8);
|
||||||
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args.clone()));
|
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.
|
// Sends the player an "init" signal telling it to start playing a song straight away.
|
||||||
tx.send(Messages::Init).await?;
|
tx.send(Messages::Init).await?;
|
||||||
|
|
||||||
// Actually starts the player.
|
// Actually starts the player.
|
||||||
Player::play(Arc::clone(&player), tx.clone(), rx, args.buffer_size).await?;
|
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
|
||||||
|
|
||||||
// Save the volume.txt file for the next session.
|
// Save the volume.txt file for the next session.
|
||||||
PersistentVolume::save(player.sink.volume()).await?;
|
PersistentVolume::save(player.sink.volume()).await?;
|
||||||
drop(stream.0);
|
drop(stream.0);
|
||||||
player.sink.stop();
|
player.sink.stop();
|
||||||
ui.abort();
|
ui.and_then(|x| Some(x.abort()));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
202
src/player.rs
202
src/player.rs
@ -2,7 +2,14 @@
|
|||||||
//! This also has the code for the underlying
|
//! This also has the code for the underlying
|
||||||
//! audio server which adds new tracks.
|
//! audio server which adds new tracks.
|
||||||
|
|
||||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use arc_swap::ArcSwapOption;
|
use arc_swap::ArcSwapOption;
|
||||||
use downloader::Downloader;
|
use downloader::Downloader;
|
||||||
@ -15,59 +22,27 @@ use tokio::{
|
|||||||
RwLock,
|
RwLock,
|
||||||
},
|
},
|
||||||
task,
|
task,
|
||||||
time::sleep,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
messages::Messages,
|
||||||
play::{PersistentVolume, SendableOutputStream},
|
play::{PersistentVolume, SendableOutputStream},
|
||||||
tracks::{self, list::List},
|
tracks::{self, list::List},
|
||||||
Args,
|
Args,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod audio;
|
||||||
|
pub mod bookmark;
|
||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
|
pub mod queue;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
pub mod 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].
|
|
||||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
|
||||||
Play,
|
|
||||||
|
|
||||||
/// Pauses the [Sink].
|
|
||||||
Pause,
|
|
||||||
|
|
||||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
|
||||||
PlayPause,
|
|
||||||
|
|
||||||
/// Change the volume of playback.
|
|
||||||
ChangeVolume(f32),
|
|
||||||
|
|
||||||
/// Quits gracefully.
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The time to wait in between errors.
|
/// The time to wait in between errors.
|
||||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
@ -82,6 +57,12 @@ pub struct Player {
|
|||||||
/// [rodio]'s [`Sink`] which can control playback.
|
/// [rodio]'s [`Sink`] which can control playback.
|
||||||
pub sink: Sink,
|
pub sink: Sink,
|
||||||
|
|
||||||
|
/// The internal buffer size.
|
||||||
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Whether the current track has been bookmarked.
|
||||||
|
bookmarked: AtomicBool,
|
||||||
|
|
||||||
/// The [`TrackInfo`] of the current track.
|
/// The [`TrackInfo`] of the current track.
|
||||||
/// This is [`None`] when lowfi is buffering/loading.
|
/// This is [`None`] when lowfi is buffering/loading.
|
||||||
current: ArcSwapOption<tracks::Info>,
|
current: ArcSwapOption<tracks::Info>,
|
||||||
@ -90,7 +71,7 @@ pub struct Player {
|
|||||||
/// *undecoded* [Track]s.
|
/// *undecoded* [Track]s.
|
||||||
///
|
///
|
||||||
/// This is populated specifically by the [Downloader].
|
/// This is populated specifically by the [Downloader].
|
||||||
tracks: RwLock<VecDeque<tracks::Track>>,
|
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
|
||||||
|
|
||||||
/// The actual list of tracks to be played.
|
/// The actual list of tracks to be played.
|
||||||
list: List,
|
list: List,
|
||||||
@ -109,49 +90,6 @@ pub struct Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
|
||||||
///
|
|
||||||
/// In other words, for the younger generation, we're telling alsa
|
|
||||||
/// to simply just the audio in the bag, lil api.
|
|
||||||
#[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
|
|
||||||
// output to `/dev/null` so that it wont be shoved down our throats.
|
|
||||||
|
|
||||||
// The mode which to redirect terminal output with.
|
|
||||||
let mode = CString::new("w")?;
|
|
||||||
|
|
||||||
// First redirect to /dev/null, which basically silences alsa.
|
|
||||||
let null = CString::new("/dev/null")?;
|
|
||||||
|
|
||||||
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
|
||||||
unsafe {
|
|
||||||
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the OutputStream while stderr is still redirected to /dev/null.
|
|
||||||
let (stream, handle) = OutputStream::try_default()?;
|
|
||||||
|
|
||||||
// Redirect back to the current terminal, so that other output isn't silenced.
|
|
||||||
let tty = CString::new("/dev/tty")?;
|
|
||||||
|
|
||||||
// SAFETY: See the first call to `freopen`.
|
|
||||||
unsafe {
|
|
||||||
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((stream, handle))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Just a shorthand for setting `current`.
|
/// Just a shorthand for setting `current`.
|
||||||
fn set_current(&self, info: tracks::Info) {
|
fn set_current(&self, info: tracks::Info) {
|
||||||
self.current.store(Some(Arc::new(info)));
|
self.current.store(Some(Arc::new(info)));
|
||||||
@ -180,7 +118,7 @@ impl Player {
|
|||||||
// We should only shut up alsa forcefully on Linux if we really have to.
|
// We should only shut up alsa forcefully on Linux if we really have to.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let (stream, handle) = if !args.alternate && !args.debug {
|
let (stream, handle) = if !args.alternate && !args.debug {
|
||||||
Self::silent_get_output_stream()?
|
audio::silent_get_output_stream()?
|
||||||
} else {
|
} else {
|
||||||
OutputStream::try_default()?
|
OutputStream::try_default()?
|
||||||
};
|
};
|
||||||
@ -205,86 +143,19 @@ impl Player {
|
|||||||
|
|
||||||
let player = Self {
|
let player = Self {
|
||||||
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
||||||
|
buffer_size: args.buffer_size,
|
||||||
current: ArcSwapOption::new(None),
|
current: ArcSwapOption::new(None),
|
||||||
client,
|
client,
|
||||||
sink,
|
sink,
|
||||||
volume,
|
volume,
|
||||||
list,
|
list,
|
||||||
_handle: handle,
|
_handle: handle,
|
||||||
|
bookmarked: AtomicBool::new(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((player, SendableOutputStream(stream)))
|
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) -> Result<tracks::Decoded, bool> {
|
|
||||||
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
|
||||||
let track = self.tracks.write().await.pop_front();
|
|
||||||
let track = if let Some(track) = track {
|
|
||||||
track
|
|
||||||
} else {
|
|
||||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
|
||||||
// This is relevant particularly at the first song.
|
|
||||||
|
|
||||||
// Serves as an indicator that the queue is "loading".
|
|
||||||
// We're doing it here so that we don't get the "loading" display
|
|
||||||
// for only a frame in the other case that the buffer is not empty.
|
|
||||||
self.current.store(None);
|
|
||||||
self.list.random(&self.client).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let decoded = track.decode().map_err(|_| false)?;
|
|
||||||
|
|
||||||
// Set the current track.
|
|
||||||
self.set_current(decoded.info.clone());
|
|
||||||
|
|
||||||
Ok(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This basically just calls [`Player::next`], and then appends the new track to the player.
|
|
||||||
///
|
|
||||||
/// This also notifies the background thread to get to work, and will send `TryAgain`
|
|
||||||
/// if it fails. This functions purpose is to be called in the background, so that
|
|
||||||
/// when the audio server recieves a `Next` signal it will still be able to respond to other
|
|
||||||
/// signals while it's loading.
|
|
||||||
///
|
|
||||||
/// This also sends the `NewSong` signal to `tx` apon successful completion.
|
|
||||||
async fn handle_next(
|
|
||||||
player: Arc<Self>,
|
|
||||||
itx: Sender<()>,
|
|
||||||
tx: Sender<Messages>,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
// Stop the sink.
|
|
||||||
player.sink.stop();
|
|
||||||
|
|
||||||
let track = player.next().await;
|
|
||||||
|
|
||||||
match track {
|
|
||||||
Ok(track) => {
|
|
||||||
// Start playing the new track.
|
|
||||||
player.sink.append(track.data);
|
|
||||||
|
|
||||||
// Notify the background downloader that there's an empty spot
|
|
||||||
// in the buffer.
|
|
||||||
Downloader::notify(&itx).await?;
|
|
||||||
|
|
||||||
// Notify the audio server that the next song has actually been downloaded.
|
|
||||||
tx.send(Messages::NewSong).await?;
|
|
||||||
}
|
|
||||||
Err(timeout) => {
|
|
||||||
if !timeout {
|
|
||||||
sleep(TIMEOUT).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.send(Messages::TryAgain).await?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This is the main "audio server".
|
/// This is the main "audio server".
|
||||||
///
|
///
|
||||||
/// `rx` & `tx` are used to communicate with it, for example when to
|
/// `rx` & `tx` are used to communicate with it, for example when to
|
||||||
@ -296,7 +167,7 @@ impl Player {
|
|||||||
player: Arc<Self>,
|
player: Arc<Self>,
|
||||||
tx: Sender<Messages>,
|
tx: Sender<Messages>,
|
||||||
mut rx: Receiver<Messages>,
|
mut rx: Receiver<Messages>,
|
||||||
buf_size: usize,
|
debug: bool,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
// Initialize the mpris player.
|
// Initialize the mpris player.
|
||||||
//
|
//
|
||||||
@ -312,8 +183,8 @@ impl Player {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
||||||
let downloader = Downloader::new(Arc::clone(&player), buf_size);
|
let downloader = Downloader::new(Arc::clone(&player));
|
||||||
let (itx, downloader) = downloader.start();
|
let (itx, downloader) = downloader.start(debug);
|
||||||
|
|
||||||
// Start buffering tracks immediately.
|
// Start buffering tracks immediately.
|
||||||
Downloader::notify(&itx).await?;
|
Downloader::notify(&itx).await?;
|
||||||
@ -325,7 +196,7 @@ impl Player {
|
|||||||
// only want to autoplay if there hasn't been any manual intervention.
|
// 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
|
// 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;
|
let mut new = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -352,6 +223,8 @@ impl Player {
|
|||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
Messages::Next | Messages::Init | Messages::TryAgain => {
|
Messages::Next | Messages::Init | Messages::TryAgain => {
|
||||||
|
player.bookmarked.swap(false, Ordering::Relaxed);
|
||||||
|
|
||||||
// We manually skipped, so we shouldn't actually wait for the song
|
// We manually skipped, so we shouldn't actually wait for the song
|
||||||
// to be over until we recieve the `NewSong` signal.
|
// to be over until we recieve the `NewSong` signal.
|
||||||
new = false;
|
new = false;
|
||||||
@ -363,10 +236,11 @@ impl Player {
|
|||||||
|
|
||||||
// Handle the rest of the signal in the background,
|
// Handle the rest of the signal in the background,
|
||||||
// as to not block the main audio server thread.
|
// as to not block the main audio server thread.
|
||||||
task::spawn(Self::handle_next(
|
task::spawn(Self::next(
|
||||||
Arc::clone(&player),
|
Arc::clone(&player),
|
||||||
itx.clone(),
|
itx.clone(),
|
||||||
tx.clone(),
|
tx.clone(),
|
||||||
|
debug,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Messages::Play => {
|
Messages::Play => {
|
||||||
@ -419,6 +293,22 @@ impl Player {
|
|||||||
|
|
||||||
continue;
|
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,
|
Messages::Quit => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
42
src/player/audio.rs
Normal file
42
src/player/audio.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use rodio::{OutputStream, OutputStreamHandle};
|
||||||
|
|
||||||
|
/// This gets the output stream while also shutting up alsa with [libc].
|
||||||
|
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn silent_get_output_stream() -> eyre::Result<(OutputStream, OutputStreamHandle)> {
|
||||||
|
use libc::freopen;
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
// Get the file descriptor to stderr from libc.
|
||||||
|
extern "C" {
|
||||||
|
static stderr: *mut libc::FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a bit of an ugly hack that basically just uses `libc` to redirect alsa's
|
||||||
|
// output to `/dev/null` so that it wont be shoved down our throats.
|
||||||
|
|
||||||
|
// The mode which to redirect terminal output with.
|
||||||
|
let mode = CString::new("w")?;
|
||||||
|
|
||||||
|
// First redirect to /dev/null, which basically silences alsa.
|
||||||
|
let null = CString::new("/dev/null")?;
|
||||||
|
|
||||||
|
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
||||||
|
unsafe {
|
||||||
|
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the OutputStream while stderr is still redirected to /dev/null.
|
||||||
|
let (stream, handle) = OutputStream::try_default()?;
|
||||||
|
|
||||||
|
// Redirect back to the current terminal, so that other output isn't silenced.
|
||||||
|
let tty = CString::new("/dev/tty")?;
|
||||||
|
|
||||||
|
// SAFETY: See the first call to `freopen`.
|
||||||
|
unsafe {
|
||||||
|
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((stream, handle))
|
||||||
|
}
|
49
src/player/bookmark.rs
Normal file
49
src/player/bookmark.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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?;
|
||||||
|
|
||||||
|
// TODO: Only open and close the file at startup and shutdown, not every single bookmark.
|
||||||
|
// TODO: Sort of like PersistentVolume, but for bookmarks.
|
||||||
|
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())
|
||||||
|
}
|
@ -24,9 +24,6 @@ pub struct Downloader {
|
|||||||
/// A copy of the internal sender, which can be useful for keeping
|
/// A copy of the internal sender, which can be useful for keeping
|
||||||
/// track of it.
|
/// track of it.
|
||||||
tx: Sender<()>,
|
tx: Sender<()>,
|
||||||
|
|
||||||
/// The size of the internal download buffer.
|
|
||||||
buf_size: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Downloader {
|
impl Downloader {
|
||||||
@ -40,37 +37,41 @@ impl Downloader {
|
|||||||
///
|
///
|
||||||
/// This also sends a [`Sender`] which can be used to notify
|
/// This also sends a [`Sender`] which can be used to notify
|
||||||
/// when the downloader needs to begin downloading more tracks.
|
/// when the downloader needs to begin downloading more tracks.
|
||||||
pub fn new(player: Arc<Player>, buf_size: usize) -> Self {
|
pub fn new(player: Arc<Player>) -> Self {
|
||||||
let (tx, rx) = mpsc::channel(8);
|
let (tx, rx) = mpsc::channel(8);
|
||||||
Self {
|
Self { player, rx, tx }
|
||||||
player,
|
}
|
||||||
rx,
|
|
||||||
tx,
|
/// Push a new, random track onto the internal buffer.
|
||||||
buf_size,
|
pub async fn push_buffer(&self, debug: bool) {
|
||||||
|
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 debug {
|
||||||
|
panic!("{}", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(TIMEOUT).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actually starts & consumes the [Downloader].
|
/// Actually starts & consumes the [Downloader].
|
||||||
pub fn start(mut self) -> (Sender<()>, JoinHandle<()>) {
|
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
|
||||||
(
|
let tx = self.tx.clone();
|
||||||
self.tx,
|
|
||||||
task::spawn(async move {
|
let handle = task::spawn(async move {
|
||||||
// Loop through each update notification.
|
// Loop through each update notification.
|
||||||
while self.rx.recv().await == Some(()) {
|
while self.rx.recv().await == Some(()) {
|
||||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
// For each update notification, we'll push tracks until the buffer is completely full.
|
||||||
while self.player.tracks.read().await.len() < self.buf_size {
|
while self.player.tracks.read().await.len() < self.player.buffer_size {
|
||||||
let data = self.player.list.random(&self.player.client).await;
|
self.push_buffer(debug).await;
|
||||||
match data {
|
|
||||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
|
||||||
Err(timeout) => {
|
|
||||||
if !timeout {
|
|
||||||
sleep(TIMEOUT).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
)
|
});
|
||||||
|
|
||||||
|
return (tx, handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -169,7 +169,7 @@ impl PlayerInterface for Player {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Metadata::new, |track| {
|
.map_or_else(Metadata::new, |track| {
|
||||||
let mut metadata = Metadata::builder()
|
let mut metadata = Metadata::builder()
|
||||||
.title(track.name.clone())
|
.title(track.display_name.clone())
|
||||||
.album(self.player.list.name.clone())
|
.album(self.player.list.name.clone())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
81
src/player/queue.rs
Normal file
81
src/player/queue.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::{sync::mpsc::Sender, time::sleep};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
messages::Messages,
|
||||||
|
player::{downloader::Downloader, Player, TIMEOUT},
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
/// Fetches the next track from the queue, or a random track if the queue is empty.
|
||||||
|
/// This will also set the current track to the fetched track's info.
|
||||||
|
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::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.
|
||||||
|
// This is relevant particularly at the first song.
|
||||||
|
|
||||||
|
// Serves as an indicator that the queue is "loading".
|
||||||
|
// We're doing it here so that we don't get the "loading" display
|
||||||
|
// for only a frame in the other case that the buffer is not empty.
|
||||||
|
self.current.store(None);
|
||||||
|
self.list.random(&self.client).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded = track.decode()?;
|
||||||
|
|
||||||
|
// Set the current track.
|
||||||
|
self.set_current(decoded.info.clone());
|
||||||
|
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets, decodes, and plays the next track in the queue while also handling the downloader.
|
||||||
|
///
|
||||||
|
/// This functions purpose is to be called in the background, so that when the audio server recieves a
|
||||||
|
/// `Next` signal it will still be able to respond to other signals while it's loading.
|
||||||
|
///
|
||||||
|
/// This also sends the either a `NewSong` or `TryAgain` signal to `tx`.
|
||||||
|
pub async fn next(
|
||||||
|
player: Arc<Self>,
|
||||||
|
itx: Sender<()>,
|
||||||
|
tx: Sender<Messages>,
|
||||||
|
debug: bool,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
// Stop the sink.
|
||||||
|
player.sink.stop();
|
||||||
|
|
||||||
|
let track = player.fetch().await;
|
||||||
|
|
||||||
|
match track {
|
||||||
|
Ok(track) => {
|
||||||
|
// Start playing the new track.
|
||||||
|
player.sink.append(track.data);
|
||||||
|
|
||||||
|
// Notify the background downloader that there's an empty spot
|
||||||
|
// in the buffer.
|
||||||
|
Downloader::notify(&itx).await?;
|
||||||
|
|
||||||
|
// Notify the audio server that the next song has actually been downloaded.
|
||||||
|
tx.send(Messages::NewSong).await?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
if !error.is_timeout() {
|
||||||
|
if debug {
|
||||||
|
panic!("{:?}", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(TIMEOUT).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(Messages::TryAgain).await?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@ -28,6 +28,7 @@ use crossterm::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use thiserror::Error;
|
||||||
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
@ -36,18 +37,24 @@ use super::{Messages, Player};
|
|||||||
mod components;
|
mod components;
|
||||||
mod input;
|
mod input;
|
||||||
|
|
||||||
/// Self explanitory.
|
/// The error type for the UI, which is used to handle errors that occur
|
||||||
const FPS: usize = 12;
|
/// while drawing the UI or handling input.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum UIError {
|
||||||
|
#[error("unable to convert number")]
|
||||||
|
Conversion(#[from] std::num::TryFromIntError),
|
||||||
|
|
||||||
|
#[error("unable to write output")]
|
||||||
|
Write(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("sending message to backend failed")]
|
||||||
|
Communication(#[from] tokio::sync::mpsc::error::SendError<Messages>),
|
||||||
|
}
|
||||||
|
|
||||||
/// How long the audio bar will be visible for when audio is adjusted.
|
/// How long the audio bar will be visible for when audio is adjusted.
|
||||||
/// This is in frames.
|
/// This is in frames.
|
||||||
const AUDIO_BAR_DURATION: usize = 10;
|
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! {
|
lazy_static! {
|
||||||
/// The volume timer, which controls how long the volume display should
|
/// The volume timer, which controls how long the volume display should
|
||||||
/// show up and when it should disappear.
|
/// show up and when it should disappear.
|
||||||
@ -108,7 +115,7 @@ impl Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Actually draws the window, with each element in `content` being on a new line.
|
/// Actually draws the window, with each element in `content` being on a new line.
|
||||||
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<()> {
|
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<(), UIError> {
|
||||||
let len: u16 = content.len().try_into()?;
|
let len: u16 = content.len().try_into()?;
|
||||||
|
|
||||||
// Note that this will have a trailing newline, which we use later.
|
// Note that this will have a trailing newline, which we use later.
|
||||||
@ -157,8 +164,9 @@ async fn interface(
|
|||||||
player: Arc<Player>,
|
player: Arc<Player>,
|
||||||
minimalist: bool,
|
minimalist: bool,
|
||||||
borderless: bool,
|
borderless: bool,
|
||||||
|
fps: u8,
|
||||||
width: usize,
|
width: usize,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<(), UIError> {
|
||||||
let mut window = Window::new(width, borderless);
|
let mut window = Window::new(width, borderless);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -196,7 +204,8 @@ async fn interface(
|
|||||||
|
|
||||||
window.draw(menu, false)?;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,7 +222,7 @@ pub struct Environment {
|
|||||||
impl Environment {
|
impl Environment {
|
||||||
/// This prepares the terminal, returning an [Environment] helpful
|
/// This prepares the terminal, returning an [Environment] helpful
|
||||||
/// for cleaning up afterwards.
|
/// for cleaning up afterwards.
|
||||||
pub fn ready(alternate: bool) -> eyre::Result<Self> {
|
pub fn ready(alternate: bool) -> eyre::Result<Self, UIError> {
|
||||||
let mut lock = stdout().lock();
|
let mut lock = stdout().lock();
|
||||||
|
|
||||||
crossterm::execute!(lock, Hide)?;
|
crossterm::execute!(lock, Hide)?;
|
||||||
@ -240,7 +249,7 @@ impl Environment {
|
|||||||
|
|
||||||
/// Uses the information collected from initialization to safely close down
|
/// Uses the information collected from initialization to safely close down
|
||||||
/// the terminal & restore it to it's previous state.
|
/// the terminal & restore it to it's previous state.
|
||||||
pub fn cleanup(&self) -> eyre::Result<()> {
|
pub fn cleanup(&self) -> eyre::Result<(), UIError> {
|
||||||
let mut lock = stdout().lock();
|
let mut lock = stdout().lock();
|
||||||
|
|
||||||
if self.alternate {
|
if self.alternate {
|
||||||
@ -273,12 +282,17 @@ impl Drop for Environment {
|
|||||||
///
|
///
|
||||||
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
|
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
|
||||||
/// previous terminal history.
|
/// previous terminal history.
|
||||||
pub async fn start(player: Arc<Player>, sender: Sender<Messages>, args: Args) -> eyre::Result<()> {
|
pub async fn start(
|
||||||
|
player: Arc<Player>,
|
||||||
|
sender: Sender<Messages>,
|
||||||
|
args: Args,
|
||||||
|
) -> eyre::Result<(), UIError> {
|
||||||
let environment = Environment::ready(args.alternate)?;
|
let environment = Environment::ready(args.alternate)?;
|
||||||
let interface = task::spawn(interface(
|
let interface = task::spawn(interface(
|
||||||
Arc::clone(&player),
|
Arc::clone(&player),
|
||||||
args.minimalist,
|
args.minimalist,
|
||||||
args.borderless,
|
args.borderless,
|
||||||
|
args.fps,
|
||||||
21 + args.width.min(32) * 2,
|
21 + args.width.min(32) * 2,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
//! Various different individual components that
|
//! Various different individual components that
|
||||||
//! appear in lowfi's UI, like the progress bar.
|
//! appear in lowfi's UI, like the progress bar.
|
||||||
|
|
||||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
ops::Deref as _,
|
||||||
|
sync::{atomic::Ordering, Arc},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crossterm::style::Stylize as _;
|
use crossterm::style::Stylize as _;
|
||||||
use unicode_segmentation::UnicodeSegmentation as _;
|
use unicode_segmentation::UnicodeSegmentation as _;
|
||||||
@ -72,16 +76,21 @@ enum ActionBar {
|
|||||||
impl ActionBar {
|
impl ActionBar {
|
||||||
/// Formats the action bar to be displayed.
|
/// Formats the action bar to be displayed.
|
||||||
/// The second value is the character length of the result.
|
/// The second value is the character length of the result.
|
||||||
fn format(&self) -> (String, usize) {
|
fn format(&self, star: bool) -> (String, usize) {
|
||||||
let (word, subject) = match self {
|
let (word, subject) = match self {
|
||||||
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
|
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
||||||
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
|
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
||||||
Self::Loading => ("loading", None),
|
Self::Loading => ("loading", None),
|
||||||
};
|
};
|
||||||
|
|
||||||
subject.map_or_else(
|
subject.map_or_else(
|
||||||
|| (word.to_owned(), word.len()),
|
|| (word.to_owned(), word.len()),
|
||||||
|(subject, len)| (format!("{} {}", word, subject.bold()), word.len() + 1 + len),
|
|(subject, len)| {
|
||||||
|
(
|
||||||
|
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
|
||||||
|
word.len() + 1 + len + if star { 1 } else { 0 },
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,7 +108,7 @@ pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> Str
|
|||||||
ActionBar::Playing(info)
|
ActionBar::Playing(info)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.format();
|
.format(player.bookmarked.load(Ordering::Relaxed));
|
||||||
|
|
||||||
if len > width {
|
if len > width {
|
||||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||||
|
@ -5,10 +5,13 @@ use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
|||||||
use futures::{FutureExt as _, StreamExt as _};
|
use futures::{FutureExt as _, StreamExt as _};
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use crate::player::{ui, Messages};
|
use crate::player::{
|
||||||
|
ui::{self, UIError},
|
||||||
|
Messages,
|
||||||
|
};
|
||||||
|
|
||||||
/// Starts the listener to recieve input from the terminal for various events.
|
/// Starts the listener to recieve input from the terminal for various events.
|
||||||
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<(), UIError> {
|
||||||
let mut reader = EventStream::new();
|
let mut reader = EventStream::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -43,6 +46,9 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
|||||||
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
|
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
|
||||||
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
|
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
|
||||||
|
|
||||||
|
// Bookmark
|
||||||
|
'b' => Messages::Bookmark,
|
||||||
|
|
||||||
_ => continue,
|
_ => continue,
|
||||||
},
|
},
|
||||||
// Media keys
|
// Media keys
|
||||||
|
165
src/tracks.rs
165
src/tracks.rs
@ -1,29 +1,116 @@
|
|||||||
//! Has all of the structs for managing the state
|
//! Has all of the structs for managing the state
|
||||||
//! of tracks, as well as downloading them &
|
//! of tracks, as well as downloading them & finding new ones.
|
||||||
//! finding new ones.
|
//!
|
||||||
|
//! There are several structs which represent the different stages
|
||||||
|
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
|
||||||
|
//! and what structs are relevant in each step, are as follows.
|
||||||
|
//!
|
||||||
|
//! First Stage, when a track is initially fetched.
|
||||||
|
//! 1. Raw entry selected from track list.
|
||||||
|
//! 2. Raw entry split into path & display name.
|
||||||
|
//! 3. Track data fetched, and [`QueuedTrack`] is created which includes a [`TrackName`] that may be raw.
|
||||||
|
//!
|
||||||
|
//! Second Stage, when a track is played.
|
||||||
|
//! 1. Track data is decoded.
|
||||||
|
//! 2. [`Info`] created from decoded data.
|
||||||
|
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
|
||||||
|
|
||||||
use std::{io::Cursor, time::Duration};
|
use std::{io::Cursor, time::Duration};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use eyre::OptionExt as _;
|
|
||||||
use inflector::Inflector as _;
|
use inflector::Inflector as _;
|
||||||
use rodio::{Decoder, Source as _};
|
use rodio::{Decoder, Source as _};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io;
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
|
||||||
|
/// The error type for the track system, which is used to handle errors that occur
|
||||||
|
/// while downloading, decoding, or playing tracks.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TrackError {
|
||||||
|
#[error("timeout")]
|
||||||
|
Timeout,
|
||||||
|
|
||||||
|
#[error("unable to decode")]
|
||||||
|
Decode(#[from] rodio::decoder::DecoderError),
|
||||||
|
|
||||||
|
#[error("invalid name")]
|
||||||
|
InvalidName,
|
||||||
|
|
||||||
|
#[error("invalid file path")]
|
||||||
|
InvalidPath,
|
||||||
|
|
||||||
|
#[error("unable to read file")]
|
||||||
|
File(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error("unable to fetch data")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackError {
|
||||||
|
pub fn is_timeout(&self) -> bool {
|
||||||
|
return matches!(self, TrackError::Timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Just a shorthand for a decoded [Bytes].
|
/// Just a shorthand for a decoded [Bytes].
|
||||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||||
|
|
||||||
|
/// Specifies a track's name, and specifically,
|
||||||
|
/// whether it has already been formatted or if it
|
||||||
|
/// is still in it's raw path form.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TrackName {
|
||||||
|
/// Pulled straight from the list,
|
||||||
|
/// with no splitting done at all.
|
||||||
|
Raw(String),
|
||||||
|
|
||||||
|
/// If a track has a custom specified name
|
||||||
|
/// in the list, then it should be defined with this variant.
|
||||||
|
Formatted(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks which are still waiting in the queue, and can't be played yet.
|
||||||
|
///
|
||||||
|
/// This means that only the data & track name are included.
|
||||||
|
pub struct QueuedTrack {
|
||||||
|
/// Name of the track, which may be raw.
|
||||||
|
pub name: TrackName,
|
||||||
|
|
||||||
|
/// Full downloadable path/url of the track.
|
||||||
|
pub full_path: String,
|
||||||
|
|
||||||
|
/// The raw data of the track, which is not decoded and
|
||||||
|
/// therefore much more memory efficient.
|
||||||
|
pub data: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueuedTrack {
|
||||||
|
/// This will actually decode and format the track,
|
||||||
|
/// returning a [`DecodedTrack`] which can be played
|
||||||
|
/// and also has a duration & formatted name.
|
||||||
|
pub fn decode(self) -> eyre::Result<DecodedTrack, TrackError> {
|
||||||
|
DecodedTrack::new(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The [`Info`] struct, which has the name and duration of a track.
|
/// The [`Info`] struct, which has the name and duration of a track.
|
||||||
///
|
///
|
||||||
/// This is not included in [Track] as the duration has to be acquired
|
/// This is not included in [Track] as the duration has to be acquired
|
||||||
/// from the decoded data and not from the raw data.
|
/// from the decoded data and not from the raw data.
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
pub struct Info {
|
pub struct Info {
|
||||||
|
/// The full downloadable path/url of the track.
|
||||||
|
pub 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.
|
/// 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
|
/// This is the *actual* terminal width of the track name, used to make
|
||||||
/// the UI consistent.
|
/// the UI consistent.
|
||||||
@ -49,11 +136,8 @@ impl Info {
|
|||||||
/// Formats a name with [Inflector].
|
/// Formats a name with [Inflector].
|
||||||
/// This will also strip the first few numbers that are
|
/// This will also strip the first few numbers that are
|
||||||
/// usually present on most lofi tracks.
|
/// usually present on most lofi tracks.
|
||||||
fn format_name(name: &str) -> eyre::Result<String> {
|
fn format_name(name: &str) -> eyre::Result<String, TrackError> {
|
||||||
let split = name
|
let split = name.split('/').last().ok_or(TrackError::InvalidName)?;
|
||||||
.split('/')
|
|
||||||
.last()
|
|
||||||
.ok_or_eyre("split is never supposed to return nothing")?;
|
|
||||||
|
|
||||||
let stripped = split.strip_suffix(".mp3").unwrap_or(split);
|
let stripped = split.strip_suffix(".mp3").unwrap_or(split);
|
||||||
let formatted = Self::decode_url(stripped)
|
let formatted = Self::decode_url(stripped)
|
||||||
@ -93,24 +177,30 @@ impl Info {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded track data.
|
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
|
||||||
pub fn new(name: TrackName, decoded: &DecodedData) -> eyre::Result<Self> {
|
pub fn new(
|
||||||
let name = match name {
|
name: TrackName,
|
||||||
TrackName::Raw(raw) => Self::format_name(&raw)?,
|
full_path: String,
|
||||||
TrackName::Formatted(formatted) => formatted,
|
decoded: &DecodedData,
|
||||||
|
) -> eyre::Result<Self, TrackError> {
|
||||||
|
let (display_name, custom_name) = match name {
|
||||||
|
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||||
|
TrackName::Formatted(custom) => (custom, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
duration: decoded.total_duration(),
|
duration: decoded.total_duration(),
|
||||||
width: name.graphemes(true).count(),
|
width: display_name.graphemes(true).count(),
|
||||||
name,
|
full_path,
|
||||||
|
custom_name,
|
||||||
|
display_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This struct is seperate from [Track] since it is generated lazily from
|
/// This struct is seperate from [Track] since it is generated lazily from
|
||||||
/// a track, and not when the track is first downloaded.
|
/// a track, and not when the track is first downloaded.
|
||||||
pub struct Decoded {
|
pub struct DecodedTrack {
|
||||||
/// Has both the formatted name and some information from the decoded data.
|
/// Has both the formatted name and some information from the decoded data.
|
||||||
pub info: Info,
|
pub info: Info,
|
||||||
|
|
||||||
@ -118,46 +208,13 @@ pub struct Decoded {
|
|||||||
pub data: DecodedData,
|
pub data: DecodedData,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Decoded {
|
impl DecodedTrack {
|
||||||
/// Creates a new track.
|
/// Creates a new track.
|
||||||
/// This is equivalent to [`Track::decode`].
|
/// This is equivalent to [`QueuedTrack::decode`].
|
||||||
pub fn new(track: Track) -> eyre::Result<Self> {
|
pub fn new(track: QueuedTrack) -> eyre::Result<Self, TrackError> {
|
||||||
let data = Decoder::new(Cursor::new(track.data))?;
|
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 })
|
Ok(Self { info, data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Specifies a track's name, and specifically,
|
|
||||||
/// whether it has already been formatted or if it
|
|
||||||
/// is still in it's raw form.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TrackName {
|
|
||||||
/// Pulled straight from the list,
|
|
||||||
/// with no splitting done at all.
|
|
||||||
Raw(String),
|
|
||||||
|
|
||||||
/// If a track has a custom specified name
|
|
||||||
/// in the list, then it should be defined with this variant.
|
|
||||||
Formatted(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The main track struct, which only includes data & the track name.
|
|
||||||
pub struct Track {
|
|
||||||
/// Name of the track.
|
|
||||||
pub name: TrackName,
|
|
||||||
|
|
||||||
/// The raw data of the track, which is not decoded and
|
|
||||||
/// therefore much more memory efficient.
|
|
||||||
pub data: Bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Track {
|
|
||||||
/// This will actually decode and format the track,
|
|
||||||
/// returning a [`DecodedTrack`] which can be played
|
|
||||||
/// and also has a duration & formatted name.
|
|
||||||
pub fn decode(self) -> eyre::Result<Decoded> {
|
|
||||||
Decoded::new(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -7,7 +7,9 @@ use rand::Rng as _;
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use super::Track;
|
use crate::{data_dir, tracks::TrackError};
|
||||||
|
|
||||||
|
use super::QueuedTrack;
|
||||||
|
|
||||||
/// Represents a list of tracks that can be played.
|
/// Represents a list of tracks that can be played.
|
||||||
///
|
///
|
||||||
@ -50,47 +52,60 @@ impl List {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a raw track, but doesn't decode it.
|
/// Downloads a raw track, but doesn't decode it.
|
||||||
async fn download(&self, track: &str, client: &Client) -> Result<Bytes, bool> {
|
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.
|
// 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()
|
track.to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}", self.base(), track)
|
format!("{}{}", self.base(), track)
|
||||||
};
|
};
|
||||||
|
|
||||||
let data: Bytes = if let Some(x) = url.strip_prefix("file://") {
|
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
|
||||||
let path = if x.starts_with("~") {
|
let path = if x.starts_with("~") {
|
||||||
let home_path = dirs::home_dir().ok_or(false)?;
|
let home_path = dirs::home_dir().ok_or(TrackError::InvalidPath)?;
|
||||||
let home = home_path.to_str().ok_or(false)?;
|
let home = home_path.to_str().ok_or(TrackError::InvalidPath)?;
|
||||||
|
|
||||||
x.replace("~", home)
|
x.replace("~", home)
|
||||||
} else {
|
} else {
|
||||||
x.to_owned()
|
x.to_owned()
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = tokio::fs::read(path).await.map_err(|_| false)?;
|
let result = tokio::fs::read(path).await?;
|
||||||
result.into()
|
result.into()
|
||||||
} else {
|
} else {
|
||||||
let response = client.get(url).send().await.map_err(|x| x.is_timeout())?;
|
let response = match client.get(full_path.clone()).send().await {
|
||||||
response.bytes().await.map_err(|_| false)?
|
Ok(x) => Ok(x),
|
||||||
|
Err(x) => {
|
||||||
|
if x.is_timeout() {
|
||||||
|
Err(TrackError::Timeout)
|
||||||
|
} else {
|
||||||
|
Err(TrackError::Request(x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
response.bytes().await?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(data)
|
Ok((data, full_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches and downloads a random track from the [List].
|
/// Fetches and downloads a random track from the [List].
|
||||||
///
|
///
|
||||||
/// The Result's error is a bool, which is true if a timeout error occured,
|
/// 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.
|
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
|
||||||
pub async fn random(&self, client: &Client) -> Result<Track, bool> {
|
pub async fn random(&self, client: &Client) -> Result<QueuedTrack, TrackError> {
|
||||||
let (path, custom_name) = self.random_path();
|
let (path, custom_name) = self.random_path();
|
||||||
let data = self.download(&path, client).await?;
|
let (data, full_path) = self.download(&path, client).await?;
|
||||||
|
|
||||||
let name = custom_name.map_or(super::TrackName::Raw(path), |formatted| {
|
let name = custom_name.map_or(super::TrackName::Raw(path.clone()), |formatted| {
|
||||||
super::TrackName::Formatted(formatted)
|
super::TrackName::Formatted(formatted)
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Track { name, data: data })
|
Ok(QueuedTrack {
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
full_path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses text into a [List].
|
/// Parses text into a [List].
|
||||||
@ -111,10 +126,7 @@ impl List {
|
|||||||
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 {
|
if let Some(arg) = tracks {
|
||||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
||||||
let name = dirs::data_dir()
|
let name = data_dir()?.join(format!("{arg}.txt"));
|
||||||
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")?
|
|
||||||
.join("lowfi")
|
|
||||||
.join(format!("{arg}.txt"));
|
|
||||||
|
|
||||||
let name = if name.exists() { name } else { arg.into() };
|
let name = if name.exists() { name } else { arg.into() };
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user