From 535ba788f9e8af4d902738b9b78eb198304d5ca7 Mon Sep 17 00:00:00 2001 From: talwat <83217276+talwat@users.noreply.github.com> Date: Fri, 5 Dec 2025 19:32:17 +0100 Subject: [PATCH] docs: add plenty of internal documentation --- Cargo.lock | 1 - Cargo.toml | 2 +- README.md | 14 ++++---- src/audio.rs | 12 ++++++- src/audio/waiter.rs | 13 +++++++- src/bookmark.rs | 22 +++++++------ src/download.rs | 47 +++++++++++++++++++++++++-- src/error.rs | 26 ++++++++++++++- src/main.rs | 14 ++++++-- src/player.rs | 70 +++++++++++++++++++++++++++++++--------- src/scrapers.rs | 2 ++ src/scrapers/archive.rs | 8 ++--- src/scrapers/chillhop.rs | 18 +++++------ src/scrapers/lofigirl.rs | 8 ++--- src/tests.rs | 2 ++ src/tracks/format.rs | 24 ++++++++++---- src/tracks/list.rs | 2 +- src/ui.rs | 25 ++++++++++++-- src/ui/components.rs | 2 +- src/ui/interface.rs | 7 +++- src/ui/window.rs | 7 ++++ src/volume.rs | 21 ++++++++---- 22 files changed, 268 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f331e68..94f9f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,7 +1405,6 @@ dependencies = [ "futures", "html-escape", "indicatif", - "lazy_static", "libc", "mpris-server", "regex", diff --git a/Cargo.toml b/Cargo.toml index 65cd545..09c94e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "lowfi" version = "2.0.0-dev" +rust-version = "1.83.0" edition = "2021" description = "An extremely simple lofi player." license = "MIT" @@ -48,7 +49,6 @@ convert_case = "0.8.0" unicode-segmentation = "1.12.0" url = "2.5.4" regex = "1.11.1" -lazy_static = "1.5.0" # Scraper serde = { version = "1.0.219", features = ["derive"], optional = true } diff --git a/README.md b/README.md index a013fe5..05db5c9 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ It'll do this as simply as it can: no albums, no ads, just lofi. This branch serves as a rewrite for lowfi. The main focus is to make the code more maintainable. This includes such things as: -* Replacing `Mutex` & `Arc` with channels, massively improving performance. -* More clearly handling tracks in different phases of loading, instead of having +- Replacing `Mutex` & `Arc` with channels, massively improving readability and flow. +- More clearly handling tracks in different phases of loading, instead of having a mess of different structs. -* Making the UI code cleaner and easier to follow. -* Rethinking input & control of the player, especially with MPRIS in mind. -* Making track loading simpler and more consistent. +- Making the UI code cleaner and easier to follow. +- Rethinking input & control of the player, especially with MPRIS in mind. +- Making track loading simpler and more consistent. This is an *internal rewrite*, and the goal is to retain every single feature. If there is a feature present in the original version of lowfi that is not present @@ -46,7 +46,7 @@ and as such it buffers 5 whole songs at a time instead of parts of the same song ### Dependencies -You'll need Rust 1.74.0+. +You'll need Rust 1.83.0+. On MacOS & Windows, no extra dependencies are needed. @@ -240,7 +240,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://`, diff --git a/src/audio.rs b/src/audio.rs index 7d6808f..114aea4 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -3,7 +3,7 @@ pub mod waiter; /// This gets the output stream while also shutting up alsa with [libc]. /// Uses raw libc calls, and therefore is functional only on Linux. #[cfg(target_os = "linux")] -pub fn silent_get_output_stream() -> eyre::Result { +fn silent_get_output_stream() -> crate::Result { use libc::freopen; use rodio::OutputStreamBuilder; use std::ffi::CString; @@ -40,3 +40,13 @@ pub fn silent_get_output_stream() -> eyre::Result crate::Result { + #[cfg(target_os = "linux")] + let mut stream = silent_get_output_stream()?; + #[cfg(not(target_os = "linux"))] + let mut stream = rodio::OutputStreamBuilder::open_default_stream()?; + stream.log_on_drop(false); + + Ok(stream) +} diff --git a/src/audio/waiter.rs b/src/audio/waiter.rs index 4e20265..ed13ada 100644 --- a/src/audio/waiter.rs +++ b/src/audio/waiter.rs @@ -7,8 +7,13 @@ use tokio::{ time, }; +/// Lightweight helper that waits for the current sink to drain and then +/// notifies the player to advance to the next track. pub struct Handle { + /// Background task monitoring the sink. task: JoinHandle<()>, + + /// Notification primitive used to wake the waiter. notify: Arc, } @@ -19,6 +24,8 @@ impl Drop for Handle { } impl Handle { + /// Create a new `Handle` which watches the provided `sink` and sends + /// `Message::Next` down `tx` when the sink becomes empty. pub fn new(sink: Arc, tx: mpsc::Sender) -> Self { let notify = Arc::new(Notify::new()); @@ -28,10 +35,14 @@ impl Handle { } } + /// Notify the waiter that playback state may have changed and it should + /// re-check the sink emptiness condition. pub fn notify(&self) { self.notify.notify_one(); } + /// Background loop that waits for the sink to drain and then attempts + /// to send a `Message::Next` to the provided channel. async fn waiter(sink: Arc, tx: mpsc::Sender, notify: Arc) { loop { notify.notified().await; @@ -42,7 +53,7 @@ impl Handle { if tx.try_send(crate::Message::Next).is_err() { break; - }; + } } } } diff --git a/src/bookmark.rs b/src/bookmark.rs index 719df83..e64452a 100644 --- a/src/bookmark.rs +++ b/src/bookmark.rs @@ -1,10 +1,14 @@ -//! Module for handling saving, loading, and adding bookmarks. +//! Bookmark persistence and helpers. +//! +//! Bookmarks are persisted to `bookmarks.txt` inside the application data +//! directory and follow the same track-list entry format (see `tracks::Info::to_entry`). use std::path::PathBuf; use tokio::{fs, io}; use crate::{data_dir, tracks}; +/// Result alias for bookmark operations. type Result = std::result::Result; /// Errors that might occur while managing bookmarks. @@ -24,7 +28,8 @@ pub struct Bookmarks { } impl Bookmarks { - /// Gets the path of the bookmarks file. + /// Returns the path to `bookmarks.txt`, creating the parent directory + /// if necessary. pub async fn path() -> Result { let data_dir = data_dir().map_err(|_| Error::Directory)?; fs::create_dir_all(data_dir.clone()).await?; @@ -32,7 +37,7 @@ impl Bookmarks { Ok(data_dir.join("bookmarks.txt")) } - /// Loads bookmarks from the `bookmarks.txt` file. + /// Loads bookmarks from disk. If no file exists an empty list is returned. pub async fn load() -> Result { let text = fs::read_to_string(Self::path().await?) .await @@ -54,16 +59,16 @@ impl Bookmarks { Ok(Self { entries }) } - // Saves the bookmarks to the `bookmarks.txt` file. + /// Saves bookmarks to disk in `bookmarks.txt`. pub async fn save(&self) -> Result<()> { let text = format!("noheader\n{}", self.entries.join("\n")); fs::write(Self::path().await?, text).await?; Ok(()) } - /// Bookmarks a given track with a full path and optional custom name. + /// Toggles bookmarking for `track` and returns whether it is now bookmarked. /// - /// Returns whether the track is now bookmarked, or not. + /// If the track exists it is removed; otherwise it is appended to the list. pub fn bookmark(&mut self, track: &tracks::Info) -> Result { let entry = track.to_entry(); let idx = self.entries.iter().position(|x| **x == entry); @@ -72,13 +77,12 @@ impl Bookmarks { self.entries.remove(idx); } else { self.entries.push(entry); - }; + } Ok(idx.is_none()) } - /// Sets the internal bookmarked register by checking against - /// the current track's info. + /// Returns true if `track` is currently bookmarked. pub fn bookmarked(&mut self, track: &tracks::Info) -> bool { self.entries.contains(&track.to_entry()) } diff --git a/src/download.rs b/src/download.rs index cbb1e0c..ee4308b 100644 --- a/src/download.rs +++ b/src/download.rs @@ -11,17 +11,45 @@ use tokio::{ use crate::tracks; +/// Flag indicating whether the downloader is actively fetching a track. +/// +/// This is used internally to prevent concurrent downloader starts and to +/// indicate to the UI that a download is in progress. static LOADING: AtomicBool = AtomicBool::new(false); + +/// Global download progress in the range 0..=100 updated atomically. +/// +/// The UI can read this `AtomicU8` to render a global progress indicator +/// when there isn't an immediately queued track available. pub(crate) static PROGRESS: AtomicU8 = AtomicU8::new(0); + +/// A convenient alias for the progress `AtomicU8` pointer type. pub type Progress = &'static AtomicU8; /// The downloader, which has all of the state necessary /// to download tracks and add them to the queue. pub struct Downloader { + /// The track queue itself, which in this case is actually + /// just an asynchronous sender. + /// + /// It is a [`Sender`] because the tracks will have to be + /// received by a completely different thread, so this avoids + /// the need to use an explicit [`tokio::sync::Mutex`]. queue: Sender, + + /// The [`Sender`] which is used to inform the + /// [`crate::Player`] with [`crate::Message::Loaded`]. tx: Sender, + + /// The list of tracks to download from. tracks: tracks::List, + + /// The [`reqwest`] client to use for downloads. client: Client, + + /// The timeout to use for both the client, + /// and also how long to wait between trying + /// again after a failed download. timeout: Duration, } @@ -43,10 +71,13 @@ impl Downloader { Handle { queue: qrx, - handle: tokio::spawn(downloader.run()), + task: tokio::spawn(downloader.run()), } } + /// Actually runs the downloader, consuming it and beginning + /// the cycle of downloading tracks and reporting to the + /// rest of the program. async fn run(self) -> crate::Result<()> { loop { let result = self.tracks.random(&self.client, &PROGRESS).await; @@ -73,13 +104,23 @@ impl Downloader { /// Downloader handle, responsible for managing /// the downloader task and internal buffer. pub struct Handle { + /// The queue receiver, which can be used to actually + /// fetch a track from the queue. queue: Receiver, - handle: JoinHandle>, + + /// The downloader task, which can be aborted. + task: JoinHandle>, } /// The output when a track is requested from the downloader. pub enum Output { + /// No track was immediately available from the downloader. When present, + /// the `Option` provides a reference to the global download + /// progress so callers can show a loading indicator. Loading(Option), + + /// A successfully downloaded (but not yet decoded) track ready to be + /// enqueued for decoding/playback. Queued(tracks::Queued), } @@ -98,6 +139,6 @@ impl Handle { impl Drop for Handle { fn drop(&mut self) { - self.handle.abort(); + self.task.abort(); } } diff --git a/src/error.rs b/src/error.rs index 92fd3e3..7f20842 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,52 +1,76 @@ -use tokio::sync::{broadcast, mpsc}; +//! Application-wide error type. +//! +//! This module exposes a single `Error` enum that aggregates the common +//! error kinds used across the application (IO, networking, UI, audio, +//! persistence). Higher-level functions should generally return +//! `crate::error::Result` to make error handling consistent. use crate::{bookmark, tracks, ui, volume}; +use tokio::sync::{broadcast, mpsc}; +/// Result alias using the crate-wide `Error` type. pub type Result = std::result::Result; + +/// Central application error. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Errors while loading or saving the persistent volume settings. #[error("unable to load/save the persistent volume: {0}")] PersistentVolume(#[from] volume::Error), + /// Errors while loading or saving bookmarks. #[error("unable to load/save bookmarks: {0}")] Bookmarks(#[from] bookmark::Error), + /// Network request failures from `reqwest`. #[error("unable to fetch data: {0}")] Request(#[from] reqwest::Error), + /// Failure converting to/from a C string (FFI helpers). #[error("C string null error: {0}")] FfiNull(#[from] std::ffi::NulError), + /// Errors coming from the audio backend / stream handling. #[error("audio playing error: {0}")] Rodio(#[from] rodio::StreamError), + /// Failure to send an internal `Message` over the mpsc channel. #[error("couldn't send internal message: {0}")] Send(#[from] mpsc::error::SendError), + /// Failure to enqueue a track into the queue channel. #[error("couldn't add track to the queue: {0}")] Queue(#[from] mpsc::error::SendError), + /// Failure to broadcast UI updates. #[error("couldn't update UI state: {0}")] Broadcast(#[from] broadcast::error::SendError), + /// Generic IO error. #[error("io error: {0}")] Io(#[from] std::io::Error), + /// Data directory was not found or could not be determined. #[error("directory not found")] Directory, + /// Downloader failed to provide the requested track. #[error("couldn't fetch track from downloader")] Download, + /// Integer parsing errors. #[error("couldn't parse integer: {0}")] Parse(#[from] std::num::ParseIntError), + /// Track subsystem error. #[error("track failure")] Track(#[from] tracks::Error), + /// UI subsystem error. #[error("ui failure")] UI(#[from] ui::Error), + /// Error returned when a spawned task join failed. #[error("join error")] JoinError(#[from] tokio::task::JoinError), } diff --git a/src/main.rs b/src/main.rs index 758e678..99f6d43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,13 +85,22 @@ enum Commands { }, } -/// Gets lowfi's data directory. +/// Returns the application data directory used for persistency. +/// +/// The function returns the platform-specific user data directory with +/// a `lowfi` subfolder. Callers may use this path to store config, +/// bookmarks, and other persistent files. pub fn data_dir() -> crate::Result { let dir = dirs::data_dir().unwrap().join("lowfi"); Ok(dir) } +/// Program entry point. +/// +/// Parses CLI arguments, initializes the audio stream and player, then +/// runs the main event loop. On exit it performs cleanup of the UI and +/// returns the inner result. #[tokio::main] async fn main() -> eyre::Result<()> { let args = Args::parse(); @@ -107,7 +116,8 @@ async fn main() -> eyre::Result<()> { } } - let player = Player::init(args).await?; + let stream = audio::stream()?; + let player = Player::init(args, stream.mixer()).await?; let environment = player.environment(); let result = player.run().await; diff --git a/src/player.rs b/src/player.rs index bb3aeee..4513ceb 100644 --- a/src/player.rs +++ b/src/player.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tokio::sync::{ broadcast, - mpsc::{self, Receiver, Sender}, + mpsc::{self, Receiver}, }; use crate::{ @@ -16,47 +16,80 @@ use crate::{ }; #[derive(Clone, Debug)] +/// Represents the currently known playback state. +/// +/// * [`Current::Loading`] indicates the player is waiting for data. +/// * [`Current::Track`] indicates the player has a decoded track available. pub enum Current { + /// Waiting for a track to arrive. The optional `Progress` is used to + /// indicate global download progress when present. Loading(Option), + + /// A decoded track that can be played; contains the track `Info`. Track(tracks::Info), } impl Default for Current { fn default() -> Self { + // By default the player starts in a loading state with no progress. Self::Loading(None) } } impl Current { + /// Returns `true` if this `Current` value represents a loading state. pub const fn loading(&self) -> bool { matches!(self, Self::Loading(_)) } } +/// The high-level application player. +/// +/// `Player` composes the downloader, UI, audio sink and bookkeeping state. +/// It owns background `Handle`s and drives the main message loop in `run`. pub struct Player { + /// Background downloader that fills the internal queue. downloader: download::Handle, + + /// Persistent bookmark storage used by the player. bookmarks: Bookmarks, + + /// Shared audio sink used for playback. sink: Arc, + + /// Receiver for incoming `Message` commands. rx: Receiver, + + /// Broadcast channel used to send UI updates. broadcast: broadcast::Sender, + + /// Current playback state (loading or track). current: Current, + + /// UI handle for rendering and input. ui: ui::Handle, + + /// Notifies when a play head has been appended. waiter: waiter::Handle, - _tx: Sender, - _stream: rodio::OutputStream, } impl Drop for Player { fn drop(&mut self) { + // Ensure playback is stopped when the player is dropped. self.sink.stop(); } } impl Player { + /// Returns the `Environment` currently used by the UI. pub const fn environment(&self) -> ui::Environment { self.ui.environment } + /// Sets the in-memory current state and notifies the UI about the change. + /// + /// If the new state is a `Track`, this will also update the bookmarked flag + /// based on persistent bookmarks. pub fn set_current(&mut self, current: Current) -> crate::Result<()> { self.current = current.clone(); self.update(ui::Update::Track(current))?; @@ -71,24 +104,25 @@ impl Player { Ok(()) } + /// Sends a `ui::Update` to the broadcast channel. pub fn update(&mut self, update: ui::Update) -> crate::Result<()> { self.broadcast.send(update)?; Ok(()) } - pub async fn init(args: crate::Args) -> crate::Result { - #[cfg(target_os = "linux")] - let mut stream = crate::audio::silent_get_output_stream()?; - #[cfg(not(target_os = "linux"))] - let mut stream = rodio::OutputStreamBuilder::open_default_stream()?; - stream.log_on_drop(false); - let sink = Arc::new(rodio::Sink::connect_new(stream.mixer())); - + /// Initialize a `Player` with the provided CLI `args` and audio `mixer`. + /// + /// This sets up the audio sink, UI, downloader, bookmarks and persistent + /// volume state. The function returns a fully constructed `Player` ready + /// to be driven via `run`. + pub async fn init(args: crate::Args, mixer: &rodio::mixer::Mixer) -> crate::Result { let (tx, rx) = mpsc::channel(8); tx.send(Message::Init).await?; - let (utx, urx) = broadcast::channel(8); + let (utx, urx) = broadcast::channel(8); let list = List::load(args.track_list.as_ref()).await?; + + let sink = Arc::new(rodio::Sink::connect_new(mixer)); let state = ui::State::initial(Arc::clone(&sink), args.width, list.name.clone()); let volume = PersistentVolume::load().await?; @@ -97,17 +131,16 @@ impl Player { Ok(Self { ui: ui::Handle::init(tx.clone(), urx, state, &args).await?, downloader: Downloader::init(args.buffer_size as usize, list, tx.clone()), - waiter: waiter::Handle::new(Arc::clone(&sink), tx.clone()), + waiter: waiter::Handle::new(Arc::clone(&sink), tx), bookmarks: Bookmarks::load().await?, current: Current::default(), broadcast: utx, rx, sink, - _tx: tx, - _stream: stream, }) } + /// Persist state that should survive a run (bookmarks and volume). pub async fn close(&self) -> crate::Result<()> { self.bookmarks.save().await?; PersistentVolume::save(self.sink.volume()).await?; @@ -115,6 +148,8 @@ impl Player { Ok(()) } + /// Play a queued track by decoding, appending to the sink and notifying + /// other subsystems that playback has changed. pub fn play(&mut self, queued: tracks::Queued) -> crate::Result<()> { let decoded = queued.decode()?; self.sink.append(decoded.data); @@ -124,6 +159,9 @@ impl Player { Ok(()) } + /// Drive the main message loop. This function consumes the `Player` and + /// will return when a `Message::Quit` is received. It handles commands + /// coming from the frontend and updates playback/UI state accordingly. pub async fn run(mut self) -> crate::Result<()> { while let Some(message) = self.rx.recv().await { match message { @@ -140,7 +178,7 @@ impl Player { download::Output::Queued(queued) => { self.play(queued)?; } - }; + } } Message::Play => { self.sink.play(); diff --git a/src/scrapers.rs b/src/scrapers.rs index 041a696..f051d13 100644 --- a/src/scrapers.rs +++ b/src/scrapers.rs @@ -1,3 +1,5 @@ +#![allow(clippy::all)] + use std::path::{Path, PathBuf}; use clap::ValueEnum; diff --git a/src/scrapers/archive.rs b/src/scrapers/archive.rs index 4348c0a..dd956d9 100644 --- a/src/scrapers/archive.rs +++ b/src/scrapers/archive.rs @@ -3,16 +3,16 @@ //! This command is completely optional, and as such isn't subject to the same //! quality standards as the rest of the codebase. +use std::sync::LazyLock; + use futures::{stream::FuturesOrdered, StreamExt}; -use lazy_static::lazy_static; use reqwest::Client; use scraper::{Html, Selector}; use crate::scrapers::{get, Source}; -lazy_static! { - static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap(); -} +static SELECTOR: LazyLock = + LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap()); async fn parse(client: &Client, path: &str) -> eyre::Result> { let document = get(client, path, super::Source::Lofigirl).await?; diff --git a/src/scrapers/chillhop.rs b/src/scrapers/chillhop.rs index 65c5980..e0a959d 100644 --- a/src/scrapers/chillhop.rs +++ b/src/scrapers/chillhop.rs @@ -2,9 +2,8 @@ use eyre::eyre; use futures::stream::FuturesUnordered; use futures::StreamExt; use indicatif::ProgressBar; -use lazy_static::lazy_static; -use std::fmt; use std::str::FromStr; +use std::{fmt, sync::LazyLock}; use reqwest::Client; use scraper::{Html, Selector}; @@ -16,14 +15,13 @@ use tokio::fs; use crate::scrapers::{get, Source}; -lazy_static! { - static ref RELEASES: Selector = Selector::parse(".table-body > a").unwrap(); - static ref RELEASE_LABEL: Selector = Selector::parse("label").unwrap(); - // static ref RELEASE_DATE: Selector = Selector::parse(".release-feat-props > .text-xs").unwrap(); - // static ref RELEASE_NAME: Selector = Selector::parse(".release-feat-props > h2").unwrap(); - static ref RELEASE_AUTHOR: Selector = Selector::parse(".release-feat-props .artist-link").unwrap(); - static ref RELEASE_TEXTAREA: Selector = Selector::parse("textarea").unwrap(); -} +static RELEASES: LazyLock = LazyLock::new(|| Selector::parse(".table-body > a").unwrap()); +static RELEASE_LABEL: LazyLock = LazyLock::new(|| Selector::parse("label").unwrap()); +// static ref RELEASE_DATE: LazyLock = LazyLock::new(|| Selector::parse(".release-feat-props > .text-xs").unwrap()); +// static ref RELEASE_NAME: LazyLock = LazyLock::new(|| Selector::parse(".release-feat-props > h2").unwrap()); +// static RELEASE_AUTHOR: LazyLock = LazyLock::new(|| Selector::parse(".release-feat-props .artist-link").unwrap()); +static RELEASE_TEXTAREA: LazyLock = + LazyLock::new(|| Selector::parse("textarea").unwrap()); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] diff --git a/src/scrapers/lofigirl.rs b/src/scrapers/lofigirl.rs index 41b40a8..a97706b 100644 --- a/src/scrapers/lofigirl.rs +++ b/src/scrapers/lofigirl.rs @@ -3,16 +3,16 @@ //! This command is completely optional, and as such isn't subject to the same //! quality standards as the rest of the codebase. +use std::sync::LazyLock; + use futures::{stream::FuturesOrdered, StreamExt}; -use lazy_static::lazy_static; use reqwest::Client; use scraper::{Html, Selector}; use crate::scrapers::get; -lazy_static! { - static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap(); -} +static SELECTOR: LazyLock = + LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap()); async fn parse(client: &Client, path: &str) -> eyre::Result> { let document = get(client, path, super::Source::Lofigirl).await?; diff --git a/src/tests.rs b/src/tests.rs index b228744..da313cd 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,3 +1,5 @@ +#![allow(clippy::all, clippy::missing_docs_in_private_items)] + mod bookmark; mod tracks; mod ui; diff --git a/src/tracks/format.rs b/src/tracks/format.rs index 9340a38..87fbda0 100644 --- a/src/tracks/format.rs +++ b/src/tracks/format.rs @@ -1,13 +1,17 @@ use convert_case::{Case, Casing as _}; -use lazy_static::lazy_static; use regex::Regex; -use std::path::Path; +use std::{path::Path, sync::LazyLock}; use url::form_urlencoded; use super::error::WithTrackContext as _; -lazy_static! { - static ref MASTER_PATTERNS: [Regex; 5] = [ +/// Regex patterns for matching and removing the "master" text in some track titles. +/// +/// These patterns attempt to strip common suffixes such as "(master)", +/// "master v2", or short forms like "mstr" that are frequently appended +/// to lofi track names by uploaders. +static MASTER_PATTERNS: LazyLock<[Regex; 5]> = LazyLock::new(|| { + [ // (master), (master v2) Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(), // mstr or - mstr or (mstr) — now also matches "mstr v3", "mstr2", etc. @@ -18,9 +22,15 @@ lazy_static! { Regex::new(r"\s+kupla\s+master(?:\s*v?\d+|\d+)?$").unwrap(), // (kupla master) followed by trailing parenthetical numbers, e.g. "... (kupla master) (1)" Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)(?:\s*\(\d+\))+$").unwrap(), - ]; - static ref ID_PATTERN: Regex = Regex::new(r"^[a-z]\d[ .]").unwrap(); -} + ] +}); + +/// Pattern for removing leading short ID prefixes. +/// +/// Many uploaded lofi tracks have a short identifier prefix like "a1 " or +/// "b2."; this regex strips those sequences so the title formatting +/// operates on the real track name. +static ID_PATTERN: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z]\d[ .]").unwrap()); /// Decodes a URL string into normal UTF-8. fn decode_url(text: &str) -> String { diff --git a/src/tracks/list.rs b/src/tracks/list.rs index 70912ae..700e7f7 100644 --- a/src/tracks/list.rs +++ b/src/tracks/list.rs @@ -173,7 +173,7 @@ impl List { // Get rid of special noheader case for tracklists without a header. let raw = raw .strip_prefix("noheader") - .map_or(raw.as_ref(), |stripped| stripped); + .map_or_else(|| raw.as_ref(), |stripped| stripped); let name = path .file_stem() diff --git a/src/ui.rs b/src/ui.rs index 6d26d6f..3fc5abe 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,6 +20,7 @@ pub mod window; #[cfg(feature = "mpris")] pub mod mpris; +/// Shorthand for a [`Result`] with a [`ui::Error`]. type Result = std::result::Result; /// The error type for the UI, which is used to handle errors @@ -54,12 +55,22 @@ pub enum Error { /// track of state. #[derive(Clone)] pub struct State { + /// The audio sink. pub sink: Arc, + + /// The current track, which is updated by way of an [`Update`]. pub current: Current, + + /// Whether the current track is bookmarked. pub bookmarked: bool, + + /// The timer, which is used when the user changes volume to briefly display it. pub(crate) timer: Option, + + /// The full inner width of the terminal window. pub(crate) width: usize, + /// The name of the playing tracklist, for MPRIS. #[allow(dead_code)] list: String, } @@ -97,17 +108,25 @@ pub enum Update { /// requires to function. #[derive(Debug)] struct Tasks { + /// The renderer, responsible for sending output to `stdout`. render: JoinHandle>, + + /// The input, which receives data from `stdin` via [`crossterm`]. input: JoinHandle>, } /// The UI handle for controlling the state of the UI, as well as /// updating MPRIS information and other small interfacing tasks. pub struct Handle { - tasks: Tasks, - pub environment: Environment, + /// The terminal environment, which can be used for cleanup. + pub(crate) environment: Environment, + + /// The MPRIS server, which is more or less a handle to the actual MPRIS thread. #[cfg(feature = "mpris")] pub mpris: mpris::Server, + + /// The UI's running tasks. + tasks: Tasks, } impl Drop for Handle { @@ -142,7 +161,7 @@ impl Handle { Update::Volume => state.timer = Some(Instant::now()), Update::Quit => break, } - }; + } interface::draw(&mut state, &mut window, params)?; interval.tick().await; diff --git a/src/ui/components.rs b/src/ui/components.rs index 3a30076..7ce747d 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -33,7 +33,7 @@ pub fn progress_bar(state: &ui::State, width: usize) -> String { let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32; filled = (elapsed * width as f32).round() as usize; } - }; + } format!( " [{}{}] {}/{} ", diff --git a/src/ui/interface.rs b/src/ui/interface.rs index 1e60776..6114e4f 100644 --- a/src/ui/interface.rs +++ b/src/ui/interface.rs @@ -25,6 +25,11 @@ impl From<&Args> for Params { } } +/// Creates a full "menu" from the [`ui::State`], which can be +/// easily put into a window for display. +/// +/// The menu really is just a [`Vec`] of the different components, +/// with padding already added. pub(crate) fn menu(state: &mut ui::State, params: Params) -> Vec { let action = components::action(state, state.width); @@ -34,7 +39,7 @@ pub(crate) fn menu(state: &mut ui::State, params: Params) -> Vec { let percentage = format!("{}%", (volume * 100.0).round().abs()); if timer.elapsed() > Duration::from_secs(1) { state.timer = None; - }; + } components::audio_bar(state.width - 17, volume, &percentage) } diff --git a/src/ui/window.rs b/src/ui/window.rs index c80f645..746249c 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -51,6 +51,13 @@ impl Window { } } + /// Renders the window itself, but doesn't actually draw it. + /// + /// `testing` just determines whether to add special features + /// like color resets and carriage returns. + /// + /// This returns both the final rendered window and also the full + /// height of the rendered window. pub(crate) fn render( &self, content: Vec, diff --git a/src/volume.rs b/src/volume.rs index 89119b5..feb103d 100644 --- a/src/volume.rs +++ b/src/volume.rs @@ -1,4 +1,7 @@ //! Persistent volume management. +//! +//! The module provides a tiny helper that reads and writes the user's +//! configured volume to `volume.txt` inside the platform config directory. use std::{num::ParseIntError, path::PathBuf}; use tokio::fs; @@ -18,8 +21,11 @@ pub enum Error { Parse(#[from] ParseIntError), } -/// This is the representation of the persistent volume, -/// which is loaded at startup and saved on shutdown. +/// Representation of the persistent volume stored on disk. +/// +/// The inner value is an integer percentage (0..=100). Use +/// [`PersistentVolume::float`] to convert to a normalized `f32` in the +/// range 0.0..=1.0 for playback volume calculations. #[derive(Clone, Copy)] pub struct PersistentVolume { /// The volume, as a percentage. @@ -27,7 +33,7 @@ pub struct PersistentVolume { } impl PersistentVolume { - /// Retrieves the config directory. + /// Retrieves the config directory, creating it if necessary. async fn config() -> Result { let config = dirs::config_dir() .ok_or(Error::Directory)? @@ -40,12 +46,15 @@ impl PersistentVolume { Ok(config) } - /// Returns the volume as a float from 0 to 1. + /// Returns the volume as a normalized float in the range 0.0..=1.0. pub fn float(self) -> f32 { f32::from(self.inner) / 100.0 } - /// Loads the [`PersistentVolume`] from [`dirs::config_dir()`]. + /// Loads the [`PersistentVolume`] from the platform config directory. + /// + /// If the file does not exist a default of `100` is written and + /// returned. pub async fn load() -> Result { let config = Self::config().await?; let volume = config.join(PathBuf::from("volume.txt")); @@ -64,7 +73,7 @@ impl PersistentVolume { Ok(Self { inner: volume }) } - /// Saves `volume` to `volume.txt`. + /// Saves `volume` (0.0..=1.0) to `volume.txt` as an integer percent. pub async fn save(volume: f32) -> Result<()> { let config = Self::config().await?; let path = config.join(PathBuf::from("volume.txt"));