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.
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]]
name = "addr2line"
version = "0.24.2"
@ -281,6 +271,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_float"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a"
[[package]]
name = "autocfg"
version = "1.4.0"
@ -484,6 +480,15 @@ dependencies = [
"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]]
name = "core-foundation"
version = "0.9.4"
@ -1452,11 +1457,12 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
name = "lowfi"
version = "1.7.0-dev"
dependencies = [
"Inflector",
"arc-swap",
"atomic_float",
"bytes",
"clap",
"color-eyre",
"convert_case",
"crossterm",
"dirs",
"eyre",
@ -2163,11 +2169,13 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"windows-registry",
]
@ -3175,6 +3183,19 @@ version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "web-sys"
version = "0.3.76"

View File

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

View File

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

View File

@ -1,6 +1,6 @@
//! Contains the [`Downloader`] struct.
use std::sync::Arc;
use std::{error::Error, sync::Arc};
use tokio::{
sync::mpsc::{self, Receiver, Sender},
@ -44,17 +44,18 @@ impl Downloader {
/// Push a new, random track onto the internal buffer.
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 {
Ok(track) => self.player.tracks.write().await.push_back(track),
Err(error) if !error.is_timeout() => {
Err(error) => {
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 crate::{
@ -23,7 +26,8 @@ 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?
self.progress.store(0.0, Ordering::Relaxed);
self.list.random(&self.client, Some(&self.progress)).await?
};
let decoded = track.decode()?;
@ -64,11 +68,11 @@ impl Player {
tx.send(Message::NewSong).await?;
}
Err(error) => {
if !error.is_timeout() {
if debug {
panic!("{error}")
}
if debug {
panic!("{error} - {:?}", error.source())
}
if !error.is_timeout() {
sleep(TIMEOUT).await;
}

View File

@ -165,10 +165,11 @@ async fn interface(
player: Arc<Player>,
minimalist: bool,
borderless: bool,
debug: bool,
fps: u8,
width: usize,
) -> eyre::Result<(), UIError> {
let mut window = Window::new(width, borderless);
let mut window = Window::new(width, borderless || debug);
loop {
// 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 menu = if minimalist {
vec![action, middle]
} else {
vec![action, middle, controls]
let menu = match (minimalist, debug, player.current.load().as_ref()) {
(true, _, _) => vec![action, middle],
(false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
_ => vec![action, middle, controls],
};
window.draw(menu, false)?;
@ -294,6 +295,7 @@ pub async fn start(
Arc::clone(&player),
args.minimalist,
args.borderless,
args.debug,
args.fps,
21 + args.width.min(32) * 2,
));

View File

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

View File

@ -18,7 +18,7 @@
use std::{io::Cursor, path::Path, time::Duration};
use bytes::Bytes;
use inflector::Inflector as _;
use convert_case::{Case, Casing};
use regex::Regex;
use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation;
@ -122,7 +122,7 @@ impl Info {
.collect()
}
/// Formats a name with [Inflector].
/// Formats a name with [convert_case].
///
/// This will also strip the first few numbers that are
/// usually present on most lofi tracks and do some other
@ -145,26 +145,22 @@ impl Info {
name = regex.replace(&name, "").to_string();
}
// TODO: Get rid of track numberings beginning with a letter,
// like B2 or E4.
let name = name
.trim_end_matches("13lufs")
.to_title_case()
// Inflector doesn't like contractions...
// Replaces a few very common ones.
// TODO: Properly handle these.
.replace(" S ", "'s ")
.replace(" T ", "'t ")
.replace(" D ", "'d ")
.replace(" Ve ", "'ve ")
.replace(" Ll ", "'ll ")
.replace(" Re ", "'re ")
.replace(" M ", "'m ");
let name = name.trim();
.replace("13lufs", "")
.to_case(Case::Title)
.replace(" .", "")
.replace(" Ft ", "ft.")
.replace("Ft.", "ft.")
.replace("Feat.", "ft.")
.replace(" W ", " w/ ");
// This is incremented for each digit in front of the song name.
let mut skip = 0;
for character in name.as_bytes() {
if character.is_ascii_digit() {
if character.is_ascii_digit() || *character == b'.' || *character == b')' {
skip += 1;
} else {
break;
@ -173,11 +169,11 @@ impl Info {
// If the entire name of the track is a number, then just return it.
if skip == name.len() {
Ok(name.to_string())
Ok(name.trim().to_string())
} else {
// We've already checked before that the bound is at an ASCII digit.
#[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)]
pub enum Kind {
#[error("timeout")]
Timeout,
#[error("unable to decode: {0}")]
Decode(#[from] rodio::decoder::DecoderError),
@ -12,6 +9,9 @@ pub enum Kind {
#[error("invalid file path")]
InvalidPath,
#[error("unknown target track length")]
UnknownLength,
#[error("unable to read file: {0}")]
File(#[from] std::io::Error),
@ -20,7 +20,7 @@ pub enum Kind {
}
#[derive(Debug, thiserror::Error)]
#[error("{kind}\ntrack: {track}")]
#[error("{kind} (track: {track})")]
pub struct Error {
pub track: String,
@ -29,8 +29,12 @@ pub struct Error {
}
impl Error {
pub const fn is_timeout(&self) -> bool {
matches!(self.kind, Kind::Timeout)
pub fn is_timeout(&self) -> bool {
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>
where
(String, E): Into<Error>,
E: Into<Kind>,
{
#[must_use]
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,
//! 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 futures::StreamExt;
use rand::Rng as _;
use reqwest::Client;
use tokio::fs;
@ -59,6 +63,7 @@ impl List {
&self,
track: &str,
client: &Client,
progress: Option<&AtomicF32>,
) -> Result<(Bytes, String), tracks::Error> {
// If the track has a protocol, then we should ignore the base for it.
let full_path = if track.contains("://") {
@ -83,17 +88,29 @@ impl List {
let result = tokio::fs::read(path.clone()).await.track(track)?;
result.into()
} else {
let response = match client.get(full_path.clone()).send().await {
Ok(x) => Ok(x),
Err(x) => {
if x.is_timeout() {
Err((track, tracks::error::Kind::Timeout))
} else {
Err((track, tracks::error::Kind::Request(x)))
}
let response = client.get(full_path.clone()).send().await.track(track)?;
if let Some(progress) = progress {
let total = response
.content_length()
.ok_or((track, tracks::error::Kind::UnknownLength))?;
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))
@ -103,9 +120,13 @@ impl List {
///
/// 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<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 (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(
|| super::TrackName::Raw(path.clone()),