From d60dc362cac2ff329a2044f13e0c42baa4f75b1a Mon Sep 17 00:00:00 2001 From: Tal <83217276+talwat@users.noreply.github.com> Date: Sun, 10 Aug 2025 16:22:37 +0200 Subject: [PATCH] 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 --- Cargo.lock | 43 ++++++++++++++++++++++++--------- Cargo.toml | 5 ++-- src/player.rs | 8 ++++++- src/player/downloader.rs | 13 +++++----- src/player/queue.rs | 16 ++++++++----- src/player/ui.rs | 12 ++++++---- src/player/ui/components.rs | 27 +++++++++++++-------- src/tracks.rs | 32 +++++++++++-------------- src/tracks/error.rs | 27 +++++++++++++++------ src/tracks/list.rs | 47 +++++++++++++++++++++++++++---------- 10 files changed, 151 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28ea537..c4e4638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0b1f7d7..9e84a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/player.rs b/src/player.rs index 838f82a..973efbd 100644 --- a/src/player.rs +++ b/src/player.rs @@ -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, + /// 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, diff --git a/src/player/downloader.rs b/src/player/downloader.rs index d1c1f22..1c71e6b 100644 --- a/src/player/downloader.rs +++ b/src/player/downloader.rs @@ -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; + } } - _ => {} } } diff --git a/src/player/queue.rs b/src/player/queue.rs index 22243de..1db5c33 100644 --- a/src/player/queue.rs +++ b/src/player/queue.rs @@ -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; } diff --git a/src/player/ui.rs b/src/player/ui.rs index 13c1789..68b8623 100644 --- a/src/player/ui.rs +++ b/src/player/ui.rs @@ -165,10 +165,11 @@ async fn interface( player: Arc, 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, )); diff --git a/src/player/ui/components.rs b/src/player/ui/components.rs index c927479..63a2241 100644 --- a/src/player/ui/components.rs +++ b/src/player/ui/components.rs @@ -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>, 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 { diff --git a/src/tracks.rs b/src/tracks.rs index ba9365a..5d849ae 100644 --- a/src/tracks.rs +++ b/src/tracks.rs @@ -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())) } } diff --git a/src/tracks/error.rs b/src/tracks/error.rs index 80e990d..c2748a0 100644 --- a/src/tracks/error.rs +++ b/src/tracks/error.rs @@ -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 { impl Context for Result where (String, E): Into, + E: Into, { + #[must_use] fn track(self, name: impl Into) -> Result { - 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() + }) } } diff --git a/src/tracks/list.rs b/src/tracks/list.rs index 93aaa03..b7b0471 100644 --- a/src/tracks/list.rs +++ b/src/tracks/list.rs @@ -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 { + pub async fn random( + &self, + client: &Client, + progress: Option<&AtomicF32>, + ) -> Result { 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()),