feat: add percent loading indicator

chore: switch from inflector to convert case
chore: tweak timeout settings again
fix: make debug mode more useful by showing full track path
fix: strip url from reqwest errors
This commit is contained in:
Tal 2025-08-10 16:22:37 +02:00
parent 3e0cbf9871
commit d60dc362ca
10 changed files with 151 additions and 79 deletions

43
Cargo.lock generated
View File

@ -2,16 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
dependencies = [
"lazy_static",
"regex",
]
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.24.2" version = "0.24.2"
@ -281,6 +271,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_float"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.4.0" version = "1.4.0"
@ -484,6 +480,15 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "convert_case"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -1452,11 +1457,12 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
name = "lowfi" name = "lowfi"
version = "1.7.0-dev" version = "1.7.0-dev"
dependencies = [ dependencies = [
"Inflector",
"arc-swap", "arc-swap",
"atomic_float",
"bytes", "bytes",
"clap", "clap",
"color-eyre", "color-eyre",
"convert_case",
"crossterm", "crossterm",
"dirs", "dirs",
"eyre", "eyre",
@ -2163,11 +2169,13 @@ dependencies = [
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util",
"tower", "tower",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"windows-registry", "windows-registry",
] ]
@ -3175,6 +3183,19 @@ version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.76" version = "0.3.76"

View File

@ -35,7 +35,7 @@ futures = "0.3.31"
arc-swap = "1.7.1" arc-swap = "1.7.1"
# Data # Data
reqwest = "0.12.9" reqwest = { version = "0.12.9", features = ["stream"] }
bytes = "1.9.0" bytes = "1.9.0"
# I/O # I/O
@ -45,7 +45,7 @@ mpris-server = { version = "0.8.1", optional = true }
dirs = "5.0.1" dirs = "5.0.1"
# Misc # Misc
Inflector = "0.11.4" convert_case = "0.8.0"
lazy_static = "1.5.0" lazy_static = "1.5.0"
url = "2.5.4" url = "2.5.4"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
@ -57,6 +57,7 @@ scraper = { version = "0.21.0", optional = true }
html-escape = { version = "0.2.13", optional = true } html-escape = { version = "0.2.13", optional = true }
indicatif = { version = "0.18.0", optional = true } indicatif = { version = "0.18.0", optional = true }
regex = "1.11.1" regex = "1.11.1"
atomic_float = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.167" libc = "0.2.167"

View File

@ -5,6 +5,7 @@
use std::{collections::VecDeque, sync::Arc, time::Duration}; use std::{collections::VecDeque, sync::Arc, time::Duration};
use arc_swap::ArcSwapOption; use arc_swap::ArcSwapOption;
use atomic_float::AtomicF32;
use downloader::Downloader; use downloader::Downloader;
use reqwest::Client; use reqwest::Client;
use rodio::{OutputStream, OutputStreamBuilder, Sink}; use rodio::{OutputStream, OutputStreamBuilder, Sink};
@ -62,6 +63,10 @@ pub struct Player {
/// This is [`None`] when lowfi is buffering/loading. /// This is [`None`] when lowfi is buffering/loading.
current: ArcSwapOption<tracks::Info>, current: ArcSwapOption<tracks::Info>,
/// The current progress for downloading tracks, if
/// `current` is None.
progress: AtomicF32,
/// The tracks, which is a [`VecDeque`] that holds /// The tracks, which is a [`VecDeque`] that holds
/// *undecoded* [Track]s. /// *undecoded* [Track]s.
/// ///
@ -139,13 +144,14 @@ impl Player {
"/", "/",
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
)) ))
.timeout(TIMEOUT * 2) .timeout(TIMEOUT * 5)
.build()?; .build()?;
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, buffer_size: args.buffer_size,
current: ArcSwapOption::new(None), current: ArcSwapOption::new(None),
progress: AtomicF32::new(-1.0),
bookmarks, bookmarks,
client, client,
sink, sink,

View File

@ -1,6 +1,6 @@
//! Contains the [`Downloader`] struct. //! Contains the [`Downloader`] struct.
use std::sync::Arc; use std::{error::Error, sync::Arc};
use tokio::{ use tokio::{
sync::mpsc::{self, Receiver, Sender}, sync::mpsc::{self, Receiver, Sender},
@ -44,17 +44,18 @@ impl Downloader {
/// Push a new, random track onto the internal buffer. /// Push a new, random track onto the internal buffer.
pub async fn push_buffer(&self, debug: bool) { pub async fn push_buffer(&self, debug: bool) {
let data = self.player.list.random(&self.player.client).await; let data = self.player.list.random(&self.player.client, None).await;
match data { match data {
Ok(track) => self.player.tracks.write().await.push_back(track), Ok(track) => self.player.tracks.write().await.push_back(track),
Err(error) if !error.is_timeout() => { Err(error) => {
if debug { if debug {
panic!("{error}") panic!("{error} - {:?}", error.source())
} }
sleep(TIMEOUT).await; if !error.is_timeout() {
sleep(TIMEOUT).await;
}
} }
_ => {}
} }
} }

View File

@ -1,4 +1,7 @@
use std::sync::Arc; use std::{
error::Error,
sync::{atomic::Ordering, Arc},
};
use tokio::{sync::mpsc::Sender, time::sleep}; use tokio::{sync::mpsc::Sender, time::sleep};
use crate::{ use crate::{
@ -23,7 +26,8 @@ impl Player {
// We're doing it here so that we don't get the "loading" display // 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. // for only a frame in the other case that the buffer is not empty.
self.current.store(None); self.current.store(None);
self.list.random(&self.client).await? self.progress.store(0.0, Ordering::Relaxed);
self.list.random(&self.client, Some(&self.progress)).await?
}; };
let decoded = track.decode()?; let decoded = track.decode()?;
@ -64,11 +68,11 @@ impl Player {
tx.send(Message::NewSong).await?; tx.send(Message::NewSong).await?;
} }
Err(error) => { Err(error) => {
if !error.is_timeout() { if debug {
if debug { panic!("{error} - {:?}", error.source())
panic!("{error}") }
}
if !error.is_timeout() {
sleep(TIMEOUT).await; sleep(TIMEOUT).await;
} }

View File

@ -165,10 +165,11 @@ async fn interface(
player: Arc<Player>, player: Arc<Player>,
minimalist: bool, minimalist: bool,
borderless: bool, borderless: bool,
debug: bool,
fps: u8, fps: u8,
width: usize, width: usize,
) -> eyre::Result<(), UIError> { ) -> eyre::Result<(), UIError> {
let mut window = Window::new(width, borderless); let mut window = Window::new(width, borderless || debug);
loop { loop {
// Load `current` once so that it doesn't have to be loaded over and over // Load `current` once so that it doesn't have to be loaded over and over
@ -197,10 +198,10 @@ async fn interface(
let controls = components::controls(width); let controls = components::controls(width);
let menu = if minimalist { let menu = match (minimalist, debug, player.current.load().as_ref()) {
vec![action, middle] (true, _, _) => vec![action, middle],
} else { (false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
vec![action, middle, controls] _ => vec![action, middle, controls],
}; };
window.draw(menu, false)?; window.draw(menu, false)?;
@ -294,6 +295,7 @@ pub async fn start(
Arc::clone(&player), Arc::clone(&player),
args.minimalist, args.minimalist,
args.borderless, args.borderless,
args.debug,
args.fps, args.fps,
21 + args.width.min(32) * 2, 21 + args.width.min(32) * 2,
)); ));

View File

@ -66,7 +66,7 @@ enum ActionBar {
Playing(Info), Playing(Info),
/// When the app is currently displaying "loading". /// When the app is currently displaying "loading".
Loading, Loading(f32),
} }
impl ActionBar { impl ActionBar {
@ -76,7 +76,11 @@ impl ActionBar {
let (word, subject) = match self { let (word, subject) = match self {
Self::Playing(x) => ("playing", Some((x.display_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::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
Self::Loading => ("loading", None), Self::Loading(progress) => {
let progress = format!("{: <2.0}%", (progress * 100.0).min(99.0));
("loading", Some((progress, 3)))
}
}; };
subject.map_or_else( subject.map_or_else(
@ -95,15 +99,18 @@ impl ActionBar {
/// This also creates all the needed padding. /// This also creates all the needed padding.
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String { pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
let (main, len) = current let (main, len) = current
.map_or(ActionBar::Loading, |info| { .map_or_else(
let info = info.deref().clone(); || ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|info| {
let info = info.deref().clone();
if player.sink.is_paused() { if player.sink.is_paused() {
ActionBar::Paused(info) ActionBar::Paused(info)
} else { } else {
ActionBar::Playing(info) ActionBar::Playing(info)
} }
}) },
)
.format(player.bookmarks.bookmarked()); .format(player.bookmarks.bookmarked());
if len > width { if len > width {

View File

@ -18,7 +18,7 @@
use std::{io::Cursor, path::Path, time::Duration}; use std::{io::Cursor, path::Path, time::Duration};
use bytes::Bytes; use bytes::Bytes;
use inflector::Inflector as _; use convert_case::{Case, Casing};
use regex::Regex; use regex::Regex;
use rodio::{Decoder, Source as _}; use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@ -122,7 +122,7 @@ impl Info {
.collect() .collect()
} }
/// Formats a name with [Inflector]. /// Formats a name with [convert_case].
/// ///
/// 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 and do some other /// usually present on most lofi tracks and do some other
@ -145,26 +145,22 @@ impl Info {
name = regex.replace(&name, "").to_string(); name = regex.replace(&name, "").to_string();
} }
// TODO: Get rid of track numberings beginning with a letter,
// like B2 or E4.
let name = name let name = name
.trim_end_matches("13lufs") .replace("13lufs", "")
.to_title_case() .to_case(Case::Title)
// Inflector doesn't like contractions... .replace(" .", "")
// Replaces a few very common ones. .replace(" Ft ", "ft.")
// TODO: Properly handle these. .replace("Ft.", "ft.")
.replace(" S ", "'s ") .replace("Feat.", "ft.")
.replace(" T ", "'t ") .replace(" W ", " w/ ");
.replace(" D ", "'d ")
.replace(" Ve ", "'ve ")
.replace(" Ll ", "'ll ")
.replace(" Re ", "'re ")
.replace(" M ", "'m ");
let name = name.trim();
// This is incremented for each digit in front of the song name. // This is incremented for each digit in front of the song name.
let mut skip = 0; let mut skip = 0;
for character in name.as_bytes() { for character in name.as_bytes() {
if character.is_ascii_digit() { if character.is_ascii_digit() || *character == b'.' || *character == b')' {
skip += 1; skip += 1;
} else { } else {
break; break;
@ -173,11 +169,11 @@ impl Info {
// If the entire name of the track is a number, then just return it. // If the entire name of the track is a number, then just return it.
if skip == name.len() { if skip == name.len() {
Ok(name.to_string()) Ok(name.trim().to_string())
} else { } else {
// We've already checked before that the bound is at an ASCII digit. // We've already checked before that the bound is at an ASCII digit.
#[allow(clippy::string_slice)] #[allow(clippy::string_slice)]
Ok(String::from(&name[skip..])) Ok(String::from(name[skip..].trim()))
} }
} }

View File

@ -1,8 +1,5 @@
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Kind { pub enum Kind {
#[error("timeout")]
Timeout,
#[error("unable to decode: {0}")] #[error("unable to decode: {0}")]
Decode(#[from] rodio::decoder::DecoderError), Decode(#[from] rodio::decoder::DecoderError),
@ -12,6 +9,9 @@ pub enum Kind {
#[error("invalid file path")] #[error("invalid file path")]
InvalidPath, InvalidPath,
#[error("unknown target track length")]
UnknownLength,
#[error("unable to read file: {0}")] #[error("unable to read file: {0}")]
File(#[from] std::io::Error), File(#[from] std::io::Error),
@ -20,7 +20,7 @@ pub enum Kind {
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("{kind}\ntrack: {track}")] #[error("{kind} (track: {track})")]
pub struct Error { pub struct Error {
pub track: String, pub track: String,
@ -29,8 +29,12 @@ pub struct Error {
} }
impl Error { impl Error {
pub const fn is_timeout(&self) -> bool { pub fn is_timeout(&self) -> bool {
matches!(self.kind, Kind::Timeout) if let Kind::Request(x) = &self.kind {
x.is_timeout()
} else {
false
}
} }
} }
@ -54,8 +58,17 @@ pub trait Context<T> {
impl<T, E> Context<T> for Result<T, E> impl<T, E> Context<T> for Result<T, E>
where where
(String, E): Into<Error>, (String, E): Into<Error>,
E: Into<Kind>,
{ {
#[must_use]
fn track(self, name: impl Into<String>) -> Result<T, Error> { fn track(self, name: impl Into<String>) -> Result<T, Error> {
self.map_err(|e| (name.into(), e).into()) self.map_err(|e| {
let error = match e.into() {
Kind::Request(e) => Kind::Request(e.without_url()),
e => e,
};
(name.into(), error).into()
})
} }
} }

View File

@ -1,8 +1,12 @@
//! The module containing all of the logic behind track lists, //! The module containing all of the logic behind track lists,
//! as well as obtaining track names & downloading the raw audio data //! as well as obtaining track names & downloading the raw audio data
use bytes::Bytes; use std::{cmp::min, sync::atomic::Ordering};
use atomic_float::AtomicF32;
use bytes::{BufMut, Bytes, BytesMut};
use eyre::OptionExt as _; use eyre::OptionExt as _;
use futures::StreamExt;
use rand::Rng as _; use rand::Rng as _;
use reqwest::Client; use reqwest::Client;
use tokio::fs; use tokio::fs;
@ -59,6 +63,7 @@ impl List {
&self, &self,
track: &str, track: &str,
client: &Client, client: &Client,
progress: Option<&AtomicF32>,
) -> Result<(Bytes, String), tracks::Error> { ) -> Result<(Bytes, String), tracks::Error> {
// 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 full_path = if track.contains("://") { let full_path = if track.contains("://") {
@ -83,17 +88,29 @@ impl List {
let result = tokio::fs::read(path.clone()).await.track(track)?; let result = tokio::fs::read(path.clone()).await.track(track)?;
result.into() result.into()
} else { } else {
let response = match client.get(full_path.clone()).send().await { let response = client.get(full_path.clone()).send().await.track(track)?;
Ok(x) => Ok(x),
Err(x) => { if let Some(progress) = progress {
if x.is_timeout() { let total = response
Err((track, tracks::error::Kind::Timeout)) .content_length()
} else { .ok_or((track, tracks::error::Kind::UnknownLength))?;
Err((track, tracks::error::Kind::Request(x))) let mut stream = response.bytes_stream();
} let mut bytes = BytesMut::new();
let mut downloaded: u64 = 0;
while let Some(item) = stream.next().await {
let chunk = item.track(track)?;
let new = min(downloaded + (chunk.len() as u64), total);
downloaded = new;
progress.store((new as f32) / (total as f32), Ordering::Relaxed);
bytes.put(chunk);
} }
}?;
response.bytes().await.track(track)? bytes.into()
} else {
response.bytes().await.track(track)?
}
}; };
Ok((data, full_path)) Ok((data, full_path))
@ -103,9 +120,13 @@ impl 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<QueuedTrack, tracks::Error> { pub async fn random(
&self,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<QueuedTrack, tracks::Error> {
let (path, custom_name) = self.random_path(); let (path, custom_name) = self.random_path();
let (data, full_path) = self.download(&path, client).await?; let (data, full_path) = self.download(&path, client, progress).await?;
let name = custom_name.map_or_else( let name = custom_name.map_or_else(
|| super::TrackName::Raw(path.clone()), || super::TrackName::Raw(path.clone()),