mirror of
https://github.com/talwat/lowfi
synced 2025-08-13 13:04:16 +00:00
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:
parent
3e0cbf9871
commit
d60dc362ca
43
Cargo.lock
generated
43
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
));
|
||||
|
@ -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 {
|
||||
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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()),
|
||||
|
Loading…
x
Reference in New Issue
Block a user