Compare commits

...

18 Commits

Author SHA1 Message Date
talwat
315fa105bf fix: don't start lowfi's UI unless in terminal 2025-05-01 09:46:17 +02:00
talwat
7cdd2e7694 feat: add star indicator for bookmarking 2025-04-23 14:14:49 +02:00
talwat
a89854e46f chore: remove test.txt 2025-04-23 14:00:54 +02:00
Tal
f1c6cbf026
docs: create ENVIRONMENT_VARS.md 2025-04-22 22:01:21 +02:00
talwat
d24c6b1a74 feat: implement basic bookmarking, still wip 2025-04-22 11:48:50 +02:00
talwat
a83a052ae9 docs: update disclaimer 2025-03-17 19:04:20 +01:00
talwat
a9cd30550c chore: bump version in preparation for 1.6.0 2025-03-17 18:34:11 +01:00
talwat
29dab7a77a docs: update flags list 2025-03-17 18:30:15 +01:00
talwat
fe70800502 docs: add fedora install instructions 2025-03-17 16:57:01 +01:00
talwat
d05f36a0bb chore: minor changes to internal docs 2025-03-17 16:54:16 +01:00
talwat
5db5146b8e feat: add option for fixed mpris name 2025-03-17 16:39:19 +01:00
talwat
34577efe8f feat: add buffer size option 2025-03-17 16:32:06 +01:00
talwat
968c1ee670 chore: formatting fix 2025-03-12 14:28:29 +01:00
talwat
bbdcfdd6f2 chore: bump version 2025-03-12 14:28:05 +01:00
talwat
8e843c12a2 docs: fix readme formatting 2025-03-12 14:23:26 +01:00
talwat
adcb20f2d0 fix: reform errors for track fetching 2025-03-12 14:21:37 +01:00
talwat
27fc505830 feat(internal): add extra flexibility to windows 2025-03-12 14:21:37 +01:00
Dario Griffo
66ccc44099
docs: add debian unofficial repository (#65)
* Add debian unofficial

* Update README.md
2025-03-12 07:10:14 +01:00
16 changed files with 343 additions and 164 deletions

2
Cargo.lock generated
View File

@ -1453,7 +1453,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "lowfi"
version = "1.6.2-dev"
version = "1.6.0"
dependencies = [
"Inflector",
"arc-swap",

View File

@ -1,6 +1,6 @@
[package]
name = "lowfi"
version = "1.6.2-dev"
version = "1.6.0"
edition = "2021"
description = "An extremely simple lofi player."
license = "MIT"

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
**All** of the audio files played in lowfi are from [Lofi Girl's](https://lofigirl.com/) website,
**All** of the audio files embedded into in lowfi by default are from [Lofi Girl's](https://lofigirl.com/) website,
under their [licensing guidelines](https://form.lofigirl.com/CommercialLicense).
If god forbid you're planning to use this in a commercial setting, please
If, god forbid, you're planning to use lowfi in a commercial setting, please
follow their rules.
## Why?
@ -65,8 +65,6 @@ precompiled binaries from the [latest release](https://github.com/talwat/lowfi/r
### AUR
If you're on Arch, you can also use the AUR:
```sh
yay -S lowfi
```
@ -77,6 +75,27 @@ yay -S lowfi
zypper install lowfi
```
### Debian
> [!NOTE]
> This uses an unofficial Debian repository maintained by [Dario Griffo](https://github.com/dariogriffo).
```sh
curl -sS https://debian.griffo.io/3B9335DF576D3D58059C6AA50B56A1A69762E9FF.asc | gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
echo "deb https://debian.griffo.io//apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
sudo apt install -y lowfi
```
### Fedora (COPR)
> [!NOTE]
> This uses an unofficial COPR repository by [FurqanHun](https://github.com/FurqanHun).
```sh
sudo dnf copr enable furqanhun/lowfi
sudo dnf install lowfi
```
### Manual
This is good for debugging, especially in issues.
@ -113,7 +132,7 @@ Yeah, that's it.
> [!NOTE]
> 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`)
> due to it being only for Linux, as well as the fact that the main point of
@ -124,15 +143,16 @@ Yeah, that's it.
If you have something you'd like to tweak about lowfi, you use additional flags which
slightly tweak the UI or behaviour of the menu. The flags can be viewed with `lowfi help`.
| Flag | Function |
| ------------------------------- | ---------------------------------------------- |
| `-a`, `--alternate` | Use an alternate terminal screen |
| `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI |
| `-p`, `--paused` | Start lowfi paused |
| `-d`, `--debug` | Include ALSA & other logs |
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
| `-t`, `--tracklist <TRACKLIST>` | Use a [custom track list](#custom-track-lists) |
| Flag | Function |
| ----------------------------------- | ---------------------------------------------- |
| `-a`, `--alternate` | Use an alternate terminal screen |
| `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI |
| `-p`, `--paused` | Start lowfi paused |
| `-d`, `--debug` | Include ALSA & other logs |
| `-w`, `--width <WIDTH>` | Width of the player, from 0 to 32 [default: 3] |
| `-t`, `--track-list <TRACK_LIST>` | Use a [custom track list](#custom-track-lists) |
| `-s`, `--buffer-size <BUFFER_SIZE>` | Internal song buffer size [default: 5] |
### Scraping
@ -204,3 +224,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.
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)]
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use eyre::OptionExt;
mod messages;
mod play;
mod player;
mod tracks;
@ -12,7 +16,7 @@ mod tracks;
mod scrape;
/// An extremely simple lofi player.
#[derive(Parser)]
#[derive(Parser, Clone)]
#[command(about, version)]
#[allow(
clippy::struct_excessive_bools,
@ -45,7 +49,11 @@ struct Args {
/// Use a custom track list
#[clap(long, short, alias = "list", short_alias = 'l')]
tracklist: Option<String>,
track_list: Option<String>,
/// Internal song buffer size.
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
buffer_size: usize,
/// The command that was ran.
/// This is [None] if no command was specified.
@ -54,7 +62,7 @@ struct Args {
}
/// Defines all of the extra commands lowfi can run.
#[derive(Subcommand)]
#[derive(Subcommand, Clone)]
enum Commands {
/// Scrapes the lofi girl website file server for files.
Scrape {
@ -68,6 +76,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]
async fn main() -> eyre::Result<()> {
#[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,6 @@
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
use std::io::{stdout, IsTerminal};
use std::path::PathBuf;
use std::sync::Arc;
@ -7,8 +8,9 @@ use eyre::eyre;
use tokio::fs;
use tokio::{sync::mpsc, task};
use crate::messages::Messages;
use crate::player::ui;
use crate::player::Player;
use crate::player::{ui, Messages};
use crate::Args;
/// This is the representation of the persistent volume,
@ -102,19 +104,27 @@ pub async fn play(args: Args) -> eyre::Result<()> {
// Initialize the UI, as well as the internal communication channel.
let (tx, rx) = mpsc::channel(8);
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
let ui = if stdout().is_terminal() {
Some(task::spawn(ui::start(
Arc::clone(&player),
tx.clone(),
args.clone(),
)))
} else {
None
};
// Sends the player an "init" signal telling it to start playing a song straight away.
tx.send(Messages::Init).await?;
// Actually starts the player.
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
Player::play(Arc::clone(&player), tx.clone(), rx, args.buffer_size).await?;
// Save the volume.txt file for the next session.
PersistentVolume::save(player.sink.volume()).await?;
drop(stream.0);
player.sink.stop();
ui.abort();
ui.and_then(|x| Some(x.abort()));
Ok(())
}

View File

@ -2,7 +2,14 @@
//! This also has the code for the underlying
//! 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 downloader::Downloader;
@ -22,8 +29,9 @@ use tokio::{
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
use crate::{
messages::Messages,
play::{PersistentVolume, SendableOutputStream},
tracks::{self, list::List},
tracks::{self, bookmark, list::List},
Args,
};
@ -33,46 +41,8 @@ pub mod ui;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Handles communication between the frontend & audio player.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum Messages {
/// Notifies the audio server that it should update the track.
Next,
/// Special in that this isn't sent in a "client to server" sort of way,
/// but rather is sent by a child of the server when a song has not only
/// been requested but also downloaded aswell.
NewSong,
/// This signal is only sent if a track timed out. In that case,
/// lowfi will try again and again to retrieve the track.
TryAgain,
/// Similar to Next, but specific to the first track.
Init,
/// Unpause the [Sink].
#[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.
const TIMEOUT: Duration = Duration::from_secs(5);
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
const TIMEOUT: Duration = Duration::from_secs(3);
/// Main struct responsible for queuing up & playing tracks.
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
@ -85,6 +55,9 @@ pub struct Player {
/// [rodio]'s [`Sink`] which can control playback.
pub sink: Sink,
/// Whether the current track has been bookmarked.
bookmarked: AtomicBool,
/// The [`TrackInfo`] of the current track.
/// This is [`None`] when lowfi is buffering/loading.
current: ArcSwapOption<tracks::Info>,
@ -114,9 +87,6 @@ pub struct 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;
@ -178,7 +148,7 @@ impl Player {
let volume = PersistentVolume::load().await?;
// Load the track list.
let list = List::load(args.tracklist.as_ref()).await?;
let list = List::load(args.track_list.as_ref()).await?;
// We should only shut up alsa forcefully on Linux if we really have to.
#[cfg(target_os = "linux")]
@ -207,13 +177,14 @@ impl Player {
.build()?;
let player = Self {
tracks: RwLock::new(VecDeque::with_capacity(5)),
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
current: ArcSwapOption::new(None),
client,
sink,
volume,
list,
_handle: handle,
bookmarked: AtomicBool::new(false),
};
Ok((player, SendableOutputStream(stream)))
@ -222,7 +193,7 @@ impl Player {
/// This will play the next track, as well as refilling the buffer in the background.
///
/// This will also set `current` to the newly loaded song.
pub async fn next(&self) -> eyre::Result<tracks::Decoded> {
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 {
@ -235,11 +206,10 @@ impl Player {
// We're doing it here so that we don't get the "loading" display
// for only a frame in the other case that the buffer is not empty.
self.current.store(None);
self.list.random(&self.client).await.0?
self.list.random(&self.client).await?
};
let decoded = track.decode()?;
let decoded = track.decode().map_err(|_| false)?;
// Set the current track.
self.set_current(decoded.info.clone());
@ -277,8 +247,8 @@ impl Player {
// Notify the audio server that the next song has actually been downloaded.
tx.send(Messages::NewSong).await?;
}
Err(error) => {
if !error.downcast::<reqwest::Error>()?.is_timeout() {
Err(timeout) => {
if !timeout {
sleep(TIMEOUT).await;
}
@ -295,10 +265,12 @@ impl Player {
/// skip tracks or pause.
///
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
/// The [Downloader]s internal buffer size is determined by `buf_size`.
pub async fn play(
player: Arc<Self>,
tx: Sender<Messages>,
mut rx: Receiver<Messages>,
buf_size: usize,
) -> eyre::Result<()> {
// Initialize the mpris player.
//
@ -314,7 +286,7 @@ impl Player {
})?;
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
let downloader = Downloader::new(Arc::clone(&player));
let downloader = Downloader::new(Arc::clone(&player), buf_size);
let (itx, downloader) = downloader.start();
// Start buffering tracks immediately.
@ -421,6 +393,25 @@ impl Player {
continue;
}
Messages::Bookmark => {
if player.bookmarked.load(Ordering::Relaxed) {
continue;
}
player.bookmarked.swap(true, Ordering::Relaxed);
let current = player.current.load();
let current = current.as_ref().unwrap();
bookmark::bookmark(
current.full_path.clone(),
if current.custom_name {
Some(current.display_name.clone())
} else {
None
},
)
.await?;
}
Messages::Quit => break,
}
}

View File

@ -8,7 +8,7 @@ use tokio::{
time::sleep,
};
use super::{Player, BUFFER_SIZE, TIMEOUT};
use super::{Player, TIMEOUT};
/// This struct is responsible for downloading tracks in the background.
///
@ -24,6 +24,9 @@ pub struct Downloader {
/// A copy of the internal sender, which can be useful for keeping
/// track of it.
tx: Sender<()>,
/// The size of the internal download buffer.
buf_size: usize,
}
impl Downloader {
@ -37,9 +40,14 @@ impl Downloader {
///
/// This also sends a [`Sender`] which can be used to notify
/// when the downloader needs to begin downloading more tracks.
pub fn new(player: Arc<Player>) -> Self {
pub fn new(player: Arc<Player>, buf_size: usize) -> Self {
let (tx, rx) = mpsc::channel(8);
Self { player, rx, tx }
Self {
player,
rx,
tx,
buf_size,
}
}
/// Actually starts & consumes the [Downloader].
@ -50,11 +58,11 @@ impl Downloader {
// Loop through each update notification.
while self.rx.recv().await == Some(()) {
// For each update notification, we'll push tracks until the buffer is completely full.
while self.player.tracks.read().await.len() < BUFFER_SIZE {
let (data, timeout) = self.player.list.random(&self.player.client).await;
while self.player.tracks.read().await.len() < self.buf_size {
let data = self.player.list.random(&self.player.client).await;
match data {
Ok(track) => self.player.tracks.write().await.push_back(track),
Err(_) => {
Err(timeout) => {
if !timeout {
sleep(TIMEOUT).await;
}

View File

@ -1,6 +1,6 @@
//! Contains the code for the MPRIS server & other helper functions.
use std::{process, sync::Arc};
use std::{env, process, sync::Arc};
use mpris_server::{
zbus::{self, fdo, Result},
@ -169,7 +169,7 @@ impl PlayerInterface for Player {
.as_ref()
.map_or_else(Metadata::new, |track| {
let mut metadata = Metadata::builder()
.title(track.name.clone())
.title(track.display_name.clone())
.album(self.player.list.name.clone())
.build();
@ -267,7 +267,11 @@ impl Server {
/// Creates a new MPRIS server.
pub async fn new(player: Arc<super::Player>, sender: Sender<Messages>) -> eyre::Result<Self> {
let suffix = format!("lowfi.{}.instance{}", player.list.name, process::id());
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
String::from("lowfi")
} else {
format!("lowfi.{}.instance{}", player.list.name, process::id())
};
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;

View File

@ -29,6 +29,7 @@ use crossterm::{
use lazy_static::lazy_static;
use tokio::{sync::mpsc::Sender, task, time::sleep};
use unicode_segmentation::UnicodeSegmentation;
use super::{Messages, Player};
@ -77,6 +78,9 @@ pub struct Window {
/// If the option to not include borders is set, these will just be empty [String]s.
borders: [String; 2],
/// The width of the window.
width: usize,
/// The output, currently just an [`Stdout`].
out: Stdout,
}
@ -98,19 +102,25 @@ impl Window {
Self {
borders,
borderless,
width,
out: stdout(),
}
}
/// Actually draws the window, with each element in `content` being on a new line.
pub fn draw(&mut self, content: Vec<String>) -> eyre::Result<()> {
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<()> {
let len: u16 = content.len().try_into()?;
// Note that this will have a trailing newline, which we use later.
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
// Horizontal Padding & Border
let padding = if self.borderless { " " } else { "" };
write!(output, "{padding} {} {padding}\r\n", x.reset()).unwrap();
let space = if space {
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
} else {
String::new()
};
write!(output, "{padding} {}{space} {padding}\r\n", x.reset()).unwrap();
output
});
@ -184,7 +194,7 @@ async fn interface(
vec![action, middle, controls]
};
window.draw(menu)?;
window.draw(menu, false)?;
sleep(Duration::from_secs_f32(FRAME_DELTA)).await;
}

View File

@ -1,7 +1,11 @@
//! Various different individual components that
//! 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 unicode_segmentation::UnicodeSegmentation as _;
@ -72,16 +76,21 @@ enum ActionBar {
impl ActionBar {
/// Formats the action bar to be displayed.
/// The second value is the character length of the result.
fn format(&self) -> (String, usize) {
fn format(&self, star: bool) -> (String, usize) {
let (word, subject) = match self {
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
Self::Loading => ("loading", None),
};
subject.map_or_else(
|| (word.to_owned(), word.len()),
|(subject, len)| (format!("{} {}", word, subject.bold()), word.len() + 1 + len),
|(subject, len)| {
(
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
word.len() + 1 + len + if star { 1 } else { 0 },
)
},
)
}
}
@ -99,7 +108,7 @@ pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> Str
ActionBar::Playing(info)
}
})
.format();
.format(player.bookmarked.load(Ordering::Relaxed));
if len > width {
let chopped: String = main.graphemes(true).take(width + 1).collect();

View File

@ -43,6 +43,9 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
// Bookmark
'b' => Messages::Bookmark,
_ => continue,
},
// Media keys

View File

@ -1,6 +1,19 @@
//! Has all of the structs for managing the state
//! of tracks, as well as downloading them &
//! finding new ones.
//! of tracks, as well as downloading them & finding new ones.
//!
//! There are several structs which represent the different stages
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
//! and what structs are relevant in each step, are as follows.
//!
//! First Stage, when a track is initially fetched.
//! 1. Raw entry selected from track list.
//! 2. Raw entry split into path & display name.
//! 3. Track data fetched, and [`Track`] is created which includes a [`TrackName`] that may be raw.
//!
//! Second Stage, when a track is played.
//! 1. Track data is decoded.
//! 2. [`Info`] created from decoded data.
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
use std::{io::Cursor, time::Duration};
@ -11,19 +24,62 @@ use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation;
use url::form_urlencoded;
pub mod bookmark;
pub mod list;
/// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>;
/// Specifies a track's name, and specifically,
/// whether it has already been formatted or if it
/// is still in it's raw path form.
#[derive(Debug, Clone)]
pub enum TrackName {
/// Pulled straight from the list,
/// with no splitting done at all.
Raw(String),
/// If a track has a custom specified name
/// in the list, then it should be defined with this variant.
Formatted(String),
}
/// The main track struct, which only includes data & the track name.
pub struct Track {
/// Name of the track, which may be raw.
pub name: TrackName,
/// Full downloadable path/url of the track.
pub full_path: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
impl Track {
/// This will actually decode and format the track,
/// returning a [`DecodedTrack`] which can be played
/// and also has a duration & formatted name.
pub fn decode(self) -> eyre::Result<Decoded> {
Decoded::new(self)
}
}
/// The [`Info`] struct, which has the name and duration of a track.
///
/// This is not included in [Track] as the duration has to be acquired
/// from the decoded data and not from the raw data.
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Info {
/// The full downloadable path/url of the track.
pub full_path: String,
/// Whether the track entry included a custom name, or not.
pub custom_name: bool,
/// This is a formatted name, so it doesn't include the full path.
pub name: String,
pub display_name: String,
/// This is the *actual* terminal width of the track name, used to make
/// the UI consistent.
@ -93,17 +149,19 @@ impl Info {
}
}
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded track data.
pub fn new(name: TrackName, decoded: &DecodedData) -> eyre::Result<Self> {
let name = match name {
TrackName::Raw(raw) => Self::format_name(&raw)?,
TrackName::Formatted(formatted) => formatted,
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
pub fn new(name: TrackName, full_path: String, decoded: &DecodedData) -> eyre::Result<Self> {
let (display_name, custom_name) = match name {
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
TrackName::Formatted(custom) => (custom, true),
};
Ok(Self {
duration: decoded.total_duration(),
width: name.graphemes(true).count(),
name,
width: display_name.graphemes(true).count(),
full_path,
custom_name,
display_name,
})
}
}
@ -123,41 +181,8 @@ impl Decoded {
/// This is equivalent to [`Track::decode`].
pub fn new(track: Track) -> eyre::Result<Self> {
let data = Decoder::new(Cursor::new(track.data))?;
let info = Info::new(track.name, &data)?;
let info = Info::new(track.name, track.full_path, &data)?;
Ok(Self { info, data })
}
}
/// 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)
}
}

27
src/tracks/bookmark.rs Normal file
View File

@ -0,0 +1,27 @@
use tokio::fs::{create_dir_all, OpenOptions};
use tokio::io::AsyncWriteExt;
use crate::data_dir;
pub async fn bookmark(path: String, custom: Option<String>) -> eyre::Result<()> {
let mut entry = format!("\n{path}");
if let Some(custom) = custom {
entry.push('!');
entry.push_str(&custom);
}
let data_dir = data_dir()?;
create_dir_all(data_dir.clone()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.append(true)
.open(data_dir.join("bookmarks.txt"))
.await?;
file.write_all(entry.as_bytes()).await?;
Ok(())
}

View File

@ -7,6 +7,8 @@ use rand::Rng as _;
use reqwest::Client;
use tokio::fs;
use crate::data_dir;
use super::Track;
/// Represents a list of tracks that can be played.
@ -50,44 +52,55 @@ impl List {
}
/// Downloads a raw track, but doesn't decode it.
async fn download(&self, track: &str, client: &Client) -> (eyre::Result<Bytes>, bool) {
async fn download(&self, track: &str, client: &Client) -> Result<(Bytes, String), bool> {
// If the track has a protocol, then we should ignore the base for it.
let url = if track.contains("://") {
let full_path = if track.contains("://") {
track.to_owned()
} else {
format!("{}{}", self.base(), track)
};
let (timeout, data) = if let Some(x) = url.strip_prefix("file://") {
let result = tokio::fs::read(x).await.unwrap();
(false, Ok(result.into()))
} else {
let response = client.get(url).send().await;
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
let path = if x.starts_with("~") {
let home_path = dirs::home_dir().ok_or(false)?;
let home = home_path.to_str().ok_or(false)?;
match response {
Ok(x) => (false, x.bytes().await),
Err(x) => (x.is_timeout(), Err(x)),
}
x.replace("~", home)
} else {
x.to_owned()
};
let result = tokio::fs::read(path).await.map_err(|_| false)?;
result.into()
} else {
let response = client
.get(full_path.clone())
.send()
.await
.map_err(|x| x.is_timeout())?;
response.bytes().await.map_err(|_| false)?
};
(data.map_err(|x| eyre::eyre!(x)), timeout)
Ok((data, full_path))
}
/// Fetches and downloads a random track from the [List].
pub async fn random(&self, client: &Client) -> (eyre::Result<Track>, bool) {
///
/// The Result's error is a bool, which is true if a timeout error occured,
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
pub async fn random(&self, client: &Client) -> Result<Track, bool> {
let (path, custom_name) = self.random_path();
let (data, timeout) = 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)
});
let track = match data {
Ok(x) => Ok(Track { name, data: x }),
Err(x) => Err(x),
};
(track, timeout)
Ok(Track {
name,
data,
full_path,
})
}
/// Parses text into a [List].
@ -108,10 +121,7 @@ impl List {
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
if let Some(arg) = tracks {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let name = dirs::data_dir()
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")?
.join("lowfi")
.join(format!("{arg}.txt"));
let name = data_dir()?.join(format!("{arg}.txt"));
let name = if name.exists() { name } else { arg.into() };