Compare commits

...

18 Commits

Author SHA1 Message Date
Tal
69747ff8b4 docs: remove note about reliability under harsh conditions
i've done a few train rides with lowfi, and it's been great,
so this warning isn't super helpful.
2025-07-02 19:48:26 +02:00
Tal
0b15ce8e1b chore: give better names to track structs 2025-07-02 19:46:17 +02:00
Tal
6fadfe6304 chore: restructure and clean up 2025-07-02 19:36:53 +02:00
talwat
b6a81c9634 chore: refactor track error to use thiserror 2025-07-02 18:32:38 +02:00
talwat
1af976ad77 fix: begin work of improving error handling 2025-06-04 22:16:52 +02:00
talwat
e8b4b17f98 fix: bookmark writing 2025-06-04 14:09:41 +02:00
talwat
1a76699afc fix: improve bookmarks
my computer broke so this commit is probably of a below average quality,
since i'm just backing stuff up.
2025-05-08 17:46:40 +02:00
talwat
2ccf073646 feat: add fps flag 2025-05-05 13:16:01 +02:00
talwat
315fa105bf fix: don't start lowfi's UI unless in terminal 2025-05-01 09:46:17 +02:00
talwat
7cdd2e7694 feat: add star indicator for bookmarking 2025-04-23 14:14:49 +02:00
talwat
a89854e46f chore: remove test.txt 2025-04-23 14:00:54 +02:00
Tal
f1c6cbf026
docs: create ENVIRONMENT_VARS.md 2025-04-22 22:01:21 +02:00
talwat
d24c6b1a74 feat: implement basic bookmarking, still wip 2025-04-22 11:48:50 +02:00
talwat
a83a052ae9 docs: update disclaimer 2025-03-17 19:04:20 +01:00
talwat
a9cd30550c chore: bump version in preparation for 1.6.0 2025-03-17 18:34:11 +01:00
talwat
29dab7a77a docs: update flags list 2025-03-17 18:30:15 +01:00
talwat
fe70800502 docs: add fedora install instructions 2025-03-17 16:57:01 +01:00
talwat
d05f36a0bb chore: minor changes to internal docs 2025-03-17 16:54:16 +01:00
18 changed files with 572 additions and 306 deletions

31
Cargo.lock generated
View File

@ -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"

View File

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

View File

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

View File

@ -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
View 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,
}

View File

@ -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(())
} }

View File

@ -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
View 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
View 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())
}

View File

@ -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);
} }
} }

View File

@ -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
View 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(())
}
}

View File

@ -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,
)); ));

View File

@ -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();

View File

@ -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

View File

@ -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)
}
}

View File

@ -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() };