Compare commits

..

No commits in common. "main" and "2.0.6" have entirely different histories.
main ... 2.0.6

10 changed files with 450 additions and 402 deletions

578
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "lowfi"
version = "2.0.7"
version = "2.0.6"
rust-version = "1.83.0"
edition = "2021"
description = "An extremely simple lofi player."
@ -32,25 +32,25 @@ scrape = [
[dependencies]
# Basics
clap = { version = "4.5.61", features = ["derive", "cargo"] }
clap = { version = "4.5.60", features = ["derive", "cargo"] }
eyre = "0.6.12"
fastrand = "2.4.1"
fastrand = "2.3.0"
thiserror = "2.0.18"
# Async
tokio = { version = "1.52.3", features = ["macros", "rt", "fs", "io-util", "sync", "time"], default-features = false }
tokio = { version = "1.50.0", features = ["macros", "rt", "fs", "io-util", "sync", "time"], default-features = false }
futures-util = { version = "0.3.32", default-features = false }
arc-swap = { version = "1.9.1", optional = true }
arc-swap = { version = "1.8.2", optional = true }
# Data
reqwest = { version = "0.13.3", features = ["stream", "http2", "default-tls"], default-features = false }
chrono = { version = "0.4.45", features = ["clock"], default-features = false }
bytes = "1.12.0"
reqwest = { version = "0.13.2", features = ["stream", "http2", "default-tls"], default-features = false }
chrono = { version = "0.4.44", features = ["clock"], default-features = false }
bytes = "1.11.1"
# I/O
crossterm = { version = "0.29.0", features = ["event-stream", "windows"], default-features = false }
rodio = { version = "0.22.2", features = ["symphonia-mp3", "playback"], default-features = false }
mpris-server = { version = "0.10.0", optional = true }
mpris-server = { version = "0.9.0", optional = true }
dirs = "6.0.0"
# Text processing
@ -59,10 +59,26 @@ url = { version = "2.5.8", default-features = false }
# Scraper
serde = { version = "1.0.228", features = ["derive"], optional = true }
serde_json = { version = "1.0.150", optional = true }
scraper = { version = "0.27.0", optional = true }
serde_json = { version = "1.0.149", optional = true }
scraper = { version = "0.26.0", optional = true }
html-escape = { version = "0.2.13", optional = true }
indicatif = { version = "0.18.4", optional = true }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.186"
libc = "0.2.182"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
unwrap_in_result = "warn"
missing_docs_in_private_items = "warn"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
cast_possible_truncation = "allow"
struct_excessive_bools = "allow"

View File

@ -20,7 +20,7 @@ by default are from [chillhop](https://chillhop.com/). Read
## Why?
I really hate modern music platforms, and I wanted a small, simple
I really hate modern music platforms, and I wanted a small, simple
app that would just play random ambient music without video and other fluff.
Beyond that, it was also designed to be fairly resilient to inconsistent networks,
@ -30,28 +30,14 @@ and as such it buffers 5 whole songs at a time instead of parts of the same song
> [!NOTE]
>
> Distro repositories may be slightly behind cargo, but lowfi is not security critical
> so they are still perfectly fine to use.
> If you're interested in maintaining a package for `lowfi`
> on package managers such as homebrew and the like, open an issue.
<a href="https://repology.org/project/lowfi/versions">
<img src="https://repology.org/badge/vertical-allrepos/lowfi.svg" alt="Packaging status" align="right">
</a>
### Dependencies
### Methods
You'll need Rust 1.83.0+.
- [Cargo](#cargo)
- [Release Binaries](#release-binaries)
- [AUR](#aur)
- [openSUSE](#opensuse)
- [Debian and Ubuntu](#debian-and-ubuntu)
- [Fedora (COPR)](#fedora-copr)
- [Manual](#manual)
### Cargo
Installing with cargo is universal, but carries a few dependencies with it.
Firstly, you'll need Rust 1.83.0+. On MacOS & Windows, no extra dependencies are needed.
On MacOS & Windows, no extra dependencies are needed.
On Linux, you'll also need openssl & alsa, as well as their headers.
@ -60,7 +46,9 @@ On Linux, you'll also need openssl & alsa, as well as their headers.
Make sure to also install `pulseaudio-alsa` if you're using PulseAudio.
Then, simply run:
### Cargo
The recommended installation method is to use cargo:
```sh
cargo install lowfi
@ -69,7 +57,7 @@ cargo install lowfi
cargo install lowfi --features mpris
```
And make sure `$HOME/.cargo/bin` is added to `$PATH`.
and making sure `$HOME/.cargo/bin` is added to `$PATH`.
Also see [Extra Features](#extra-features) for extended functionality.
### Release Binaries
@ -171,11 +159,10 @@ slightly tweak the UI or behavior of the menu. The flags can be viewed with `low
| `-a`, `--alternate` | Use an alternate terminal screen |
| `-m`, `--minimalist` | Hide the bottom control bar |
| `-b`, `--borderless` | Exclude borders in UI |
| `-c`, `--clock` | Include a clock |
| `-p`, `--paused` | Start lowfi paused |
| `-f`, `--fps` | FPS of the UI [default: 12] |
| `--timeout` | Timeout in seconds for music downloads [default: 3] |
| `-d`, `--debug` | Include ALSA & other logs meant for debugging |
| `-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] |
@ -217,7 +204,7 @@ If you are dealing with the 1% using another audio format which is in
> Feel free to contribute your own list with a PR.
lowfi also supports custom track lists, although the default one from chillhop
is embedded into the binary by default.
is embedded into the binary.
To use a custom list, use the `--track-list` flag. This can either be a path to some file,
or it could also be the name of a file (without the `.txt` extension) in the data
@ -244,7 +231,7 @@ Each track will be first appended to the header, and then use the combination to
the track.
> [!NOTE]
> lowfi _will not_ put a `/` between the base & track for added flexibility,
> lowfi *will not* put a `/` between the base & track for added flexibility,
> so for most cases you should have a trailing `/` in your header.
The exception to this is if the track name begins with a protocol like `https://`,

View File

@ -5,7 +5,7 @@ use std::{
time::Duration,
};
use crate::{tracks, ui::interface::Logger};
use crate::tracks;
use reqwest::Client;
use tokio::sync::mpsc;
@ -68,60 +68,20 @@ pub struct Downloader {
/// The list of tracks to download from.
tracks: tracks::List,
/// Whether to log more debug information.
debug: bool,
/// The [`reqwest`] client to use for downloads.
client: Client,
/// The RNG generator to use.
rng: fastrand::Rng,
/// Logger, to report any download errors to the UI.
logger: Logger,
}
impl Downloader {
/// Handles an error encountered by the downloader.
///
/// If the error isn't a timeout, it will also stall for some arbitrary error timeout.
async fn handle_error(&mut self, error: tracks::Error) -> crate::Result<()> {
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
PROGRESS.store(0, atomic::Ordering::Relaxed);
let track_suffix = if let Some(x) = error.track.as_ref() {
format!(" track: {x}")
} else {
String::new()
};
match &error.kind {
tracks::error::Kind::Request(x) => {
if let Some(status) = x.status() {
let message =
format!("track list error code: {}{track_suffix}", status.as_u16());
self.logger.info(message).await?;
}
}
error if self.debug => {
let message = format!("debug: error: {}{track_suffix}", error);
self.logger.info(message).await?;
}
_ => {}
}
if !error.timeout() {
tokio::time::sleep(ERROR_TIMEOUT).await;
}
Ok(())
}
/// Actually runs the downloader, consuming it and beginning
/// the cycle of downloading tracks and reporting to the
/// rest of the program.
async fn run(mut self) -> crate::Result<()> {
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
loop {
let result = self
.tracks
@ -130,19 +90,18 @@ impl Downloader {
match result {
Ok(track) => {
if self.debug {
self.logger
.info(format!("debug: adding {} to queue", track.display))
.await?;
}
self.queue.send(track).await?;
if LOADING.load(atomic::Ordering::Relaxed) {
self.tx.send(crate::Message::Loaded).await?;
LOADING.store(false, atomic::Ordering::Relaxed);
}
}
Err(error) => self.handle_error(error).await?,
Err(error) => {
PROGRESS.store(0, atomic::Ordering::Relaxed);
if !error.timeout() {
tokio::time::sleep(ERROR_TIMEOUT).await;
}
}
}
}
}
@ -187,8 +146,8 @@ impl crate::Tasks {
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
pub fn downloader(
&mut self,
args: &crate::Args,
logger: Logger,
size: usize,
timeout: u64,
tracks: tracks::List,
) -> crate::Result<Handle> {
let client = Client::builder()
@ -197,13 +156,11 @@ impl crate::Tasks {
"/",
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(args.timeout))
.timeout(Duration::from_secs(timeout))
.build()?;
let (qtx, qrx) = mpsc::channel(args.buffer_size as usize - 1);
let (qtx, qrx) = mpsc::channel(size - 1);
let downloader = Downloader {
debug: args.debug,
logger,
queue: qtx,
tx: self.tx(),
tracks,

View File

@ -111,10 +111,9 @@ impl Player {
let volume = PersistentVolume::load().await?;
sink.set_volume(volume.float());
let ui = tasks.ui(state, &args).await?;
let player = Self {
downloader: tasks.downloader(&args, ui.logger(), list)?,
ui,
ui: tasks.ui(state, &args).await?,
downloader: tasks.downloader(args.buffer_size as usize, args.timeout, list)?,
waiter: tasks.waiter(Arc::clone(&sink)),
bookmarks: Bookmarks::load().await?,
current: Current::default(),

View File

@ -1,7 +1,7 @@
//! The module containing all of the logic behind track lists,
//! as well as obtaining track names & downloading the raw audio data
use std::{cmp::min, path::Path};
use std::cmp::min;
use bytes::{BufMut as _, Bytes, BytesMut};
use futures_util::StreamExt as _;
@ -28,8 +28,8 @@ pub struct List {
#[allow(dead_code)]
pub name: String,
/// Just the raw file, but separated by `/n` (newlines).
/// `lines[0]` is the base/header, with the rest being tracks.
/// Just the raw file, but seperated by `/n` (newlines).
/// `lines[0]` is the base/heaeder, with the rest being tracks.
pub lines: Vec<String>,
/// The file path which the list was read from.
@ -95,13 +95,7 @@ impl List {
let result = tokio::fs::read(path.clone()).await.track(x)?;
result.into()
} else {
let response = client
.get(path.clone())
.send()
.await?
.error_for_status()
.track(track)?;
let response = client.get(path.clone()).send().await.track(track)?;
let Some(progress) = progress else {
let bytes = response.bytes().await.track(track)?;
return Ok((bytes, path));
@ -145,27 +139,8 @@ impl List {
Queued::new(path, data, display)
}
/// Reads a file and parses it into a list.
pub async fn from_file(path: impl AsRef<Path>, name: Option<&str>) -> tracks::Result<Self> {
let path = path.as_ref();
let text = fs::read_to_string(path).await?;
let name = match name {
Some(name) => name,
None => path
.file_stem()
.and_then(|x| x.to_str())
.ok_or(tracks::error::Kind::InvalidName)?,
};
Ok(Self::new(name, &text, path.to_str()))
}
/// Parses text into a [List].
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
// Get rid of special noheader case for tracklists without a header.
let text = text.strip_prefix("noheader").unwrap_or(text);
let lines: Vec<String> = text
.trim_end()
.lines()
@ -181,15 +156,6 @@ impl List {
/// Reads a [List] from the filesystem using the CLI argument provided.
pub async fn load(tracks: &str) -> tracks::Result<Self> {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let path = data_dir()
.map_err(|_| error::Kind::InvalidPath)?
.join(format!("{tracks}.txt"));
if path.exists() {
return Self::from_file(path, Some(tracks)).await;
}
if tracks == "chillhop" {
#[cfg(feature = "default-tracklist")]
return Ok(Self::new(
@ -202,6 +168,23 @@ impl List {
return Err(tracks::error::Kind::NoTrackList.into());
}
Self::from_file(tracks, None).await
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let path = data_dir()
.map_err(|_| error::Kind::InvalidPath)?
.join(format!("{tracks}.txt"));
let path = if path.exists() { path } else { tracks.into() };
let raw = fs::read_to_string(path.clone()).await?;
// Get rid of special noheader case for tracklists without a header.
let raw = raw.strip_prefix("noheader").unwrap_or_else(|| raw.as_ref());
let name = path
.file_stem()
.and_then(|x| x.to_str())
.ok_or(tracks::error::Kind::InvalidName)
.track(tracks)?;
Ok(Self::new(name, raw, path.to_str()))
}
}

View File

@ -2,7 +2,7 @@
use std::sync::Arc;
use crate::{player::Current, ui::interface::Logger};
use crate::player::Current;
use tokio::{sync::broadcast, time::Instant};
pub mod environment;
@ -34,9 +34,6 @@ pub enum Error {
#[error("sharing state between backend and frontend failed: {0}")]
StateSend(#[from] tokio::sync::broadcast::error::SendError<Update>),
#[error("error sending a log: {0}")]
LogSend(#[from] tokio::sync::mpsc::error::SendError<String>),
#[error("you can't disable the UI without MPRIS!")]
RejectedDisable,
@ -117,9 +114,6 @@ pub struct Handle {
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
#[cfg(feature = "mpris")]
pub mpris: mpris::Server,
/// Logger which can be used to log important events.
logger: Logger,
}
impl Handle {
@ -128,11 +122,6 @@ impl Handle {
self.updater.send(update)?;
Ok(())
}
/// Creates a new handle to log events.
pub fn logger(&self) -> Logger {
self.logger.clone()
}
}
/// The main UI process, which will both render the UI to the terminal
@ -145,9 +134,11 @@ impl Handle {
/// and `params` specifies aesthetic options that are specified by the user.
pub async fn run(
mut updater: broadcast::Receiver<Update>,
mut interface: Interface,
mut state: State,
params: interface::Params,
) -> Result<()> {
let mut interface = Interface::new(params)?;
loop {
if let Ok(message) = updater.try_recv() {
match message {

View File

@ -13,17 +13,13 @@ impl crate::Tasks {
let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?;
let params = interface::Params::try_from(args)?;
let interface = interface::Interface::new(params)?;
let logger = interface.logger.clone();
if params.enabled {
self.spawn(ui::run(urx, interface, state));
self.spawn(ui::run(urx, state, params));
self.spawn(input::listen(self.tx()));
}
Ok(ui::Handle {
updater: utx,
logger,
#[cfg(feature = "mpris")]
mpris,
})

View File

@ -13,21 +13,8 @@ pub mod window;
pub use clock::Clock;
pub use titlebar::TitleBar;
use tokio::sync::mpsc;
pub use window::Window;
/// Wrapper around [`mpsc::Sender`] which provides a nice API for logging.
#[derive(Clone)]
pub struct Logger(mpsc::Sender<String>);
impl Logger {
/// Send an informational log.
pub async fn info(&self, message: String) -> ui::Result<()> {
self.0.send(message).await?;
Ok(())
}
}
/// UI-specific parameters and options.
#[derive(Copy, Clone, Debug)]
pub struct Params {
@ -106,13 +93,6 @@ pub struct Interface {
/// The interface parameters that control smaller
/// aesthetic features and options.
params: Params,
/// Receiver for any truly import live time logs about tracks.
pub logs: mpsc::Receiver<String>,
/// An instance of the logger which can be cloned to give tasks
/// access to logging.
pub(super) logger: Logger,
}
impl Default for Interface {
@ -126,13 +106,10 @@ impl Interface {
/// Creates a new interface.
pub fn new(params: Params) -> ui::Result<Self> {
let mut window = Window::new(params.width, params.borderless, false, true);
let (sender, logs) = mpsc::channel(8);
Ok(Self {
clock: params.clock.then(|| Clock::new(&mut window)),
interval: tokio::time::interval(params.delta),
logger: Logger(sender),
logs,
window,
params,
})
@ -171,8 +148,7 @@ impl Interface {
}
let menu = self.menu(state);
self.window
.draw(stdout().lock(), self.logs.try_recv().ok(), menu)?;
self.window.draw(stdout().lock(), menu)?;
self.interval.tick().await;
Ok(())

View File

@ -99,22 +99,14 @@ impl Window {
}
/// Actually draws the window, with each element in `content` being on a new line.
///
/// If `log` is [`Some`], then it will also print it after clearing, but before the lowfi window.
pub fn draw(
&mut self,
mut writer: impl std::io::Write,
log: Option<String>,
content: Vec<String>,
) -> ui::Result<()> {
let (rendered, height) = self.render(content)?;
crossterm::queue!(writer, Clear(ClearType::FromCursorDown), MoveToColumn(0))?;
if let Some(log) = log {
crossterm::queue!(writer, Print(log), Print("\n"), MoveToColumn(0))?;
}
crossterm::queue!(
crossterm::execute!(
writer,
Clear(ClearType::FromCursorDown),
MoveToColumn(0),
@ -123,7 +115,6 @@ impl Window {
MoveUp(height - 1),
)?;
writer.flush()?;
Ok(())
}
}