docs: add plenty of internal documentation

This commit is contained in:
talwat 2025-12-05 19:32:17 +01:00
parent a87a8cc59e
commit 535ba788f9
22 changed files with 268 additions and 79 deletions

1
Cargo.lock generated
View File

@ -1405,7 +1405,6 @@ dependencies = [
"futures",
"html-escape",
"indicatif",
"lazy_static",
"libc",
"mpris-server",
"regex",

View File

@ -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 }

View File

@ -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://`,

View File

@ -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<rodio::OutputStream, crate::Error> {
fn silent_get_output_stream() -> crate::Result<rodio::OutputStream> {
use libc::freopen;
use rodio::OutputStreamBuilder;
use std::ffi::CString;
@ -40,3 +40,13 @@ pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::Er
Ok(stream)
}
pub fn stream() -> crate::Result<rodio::OutputStream> {
#[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)
}

View File

@ -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<Notify>,
}
@ -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<Sink>, tx: mpsc::Sender<crate::Message>) -> 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<Sink>, tx: mpsc::Sender<crate::Message>, notify: Arc<Notify>) {
loop {
notify.notified().await;
@ -42,7 +53,7 @@ impl Handle {
if tx.try_send(crate::Message::Next).is_err() {
break;
};
}
}
}
}

View File

@ -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<T> = std::result::Result<T, Error>;
/// 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<PathBuf> {
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<Self> {
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<bool> {
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())
}

View File

@ -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<tracks::Queued>,
/// The [`Sender`] which is used to inform the
/// [`crate::Player`] with [`crate::Message::Loaded`].
tx: Sender<crate::Message>,
/// 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<tracks::Queued>,
handle: JoinHandle<crate::Result<()>>,
/// The downloader task, which can be aborted.
task: JoinHandle<crate::Result<()>>,
}
/// 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<Progress>` provides a reference to the global download
/// progress so callers can show a loading indicator.
Loading(Option<Progress>),
/// 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();
}
}

View File

@ -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<T>` 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<T> = std::result::Result<T, Error>;
/// 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<crate::Message>),
/// Failure to enqueue a track into the queue channel.
#[error("couldn't add track to the queue: {0}")]
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
/// Failure to broadcast UI updates.
#[error("couldn't update UI state: {0}")]
Broadcast(#[from] broadcast::error::SendError<ui::Update>),
/// 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),
}

View File

@ -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<PathBuf> {
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;

View File

@ -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<download::Progress>),
/// 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<rodio::Sink>,
/// Receiver for incoming `Message` commands.
rx: Receiver<crate::Message>,
/// Broadcast channel used to send UI updates.
broadcast: broadcast::Sender<ui::Update>,
/// 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<crate::Message>,
_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<Self> {
#[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<Self> {
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();

View File

@ -1,3 +1,5 @@
#![allow(clippy::all)]
use std::path::{Path, PathBuf};
use clap::ValueEnum;

View File

@ -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<Selector> =
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;

View File

@ -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<Selector> = LazyLock::new(|| Selector::parse(".table-body > a").unwrap());
static RELEASE_LABEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse("label").unwrap());
// static ref RELEASE_DATE: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > .text-xs").unwrap());
// static ref RELEASE_NAME: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > h2").unwrap());
// static RELEASE_AUTHOR: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props .artist-link").unwrap());
static RELEASE_TEXTAREA: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("textarea").unwrap());
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]

View File

@ -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<Selector> =
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;

View File

@ -1,3 +1,5 @@
#![allow(clippy::all, clippy::missing_docs_in_private_items)]
mod bookmark;
mod tracks;
mod ui;

View File

@ -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<Regex> = LazyLock::new(|| Regex::new(r"^[a-z]\d[ .]").unwrap());
/// Decodes a URL string into normal UTF-8.
fn decode_url(text: &str) -> String {

View File

@ -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()

View File

@ -20,6 +20,7 @@ pub mod window;
#[cfg(feature = "mpris")]
pub mod mpris;
/// Shorthand for a [`Result`] with a [`ui::Error`].
type Result<T> = std::result::Result<T, Error>;
/// 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<rodio::Sink>,
/// 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<Instant>,
/// 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<Result<()>>,
/// The input, which receives data from `stdin` via [`crossterm`].
input: JoinHandle<Result<()>>,
}
/// 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;

View File

@ -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!(
" [{}{}] {}/{} ",

View File

@ -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<String> {
let action = components::action(state, state.width);
@ -34,7 +39,7 @@ pub(crate) fn menu(state: &mut ui::State, params: Params) -> Vec<String> {
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)
}

View File

@ -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<String>,

View File

@ -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<PathBuf> {
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<Self> {
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"));