mirror of
https://github.com/talwat/lowfi
synced 2025-12-07 23:47:46 +00:00
docs: add plenty of internal documentation
This commit is contained in:
parent
a87a8cc59e
commit
535ba788f9
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1405,7 +1405,6 @@ dependencies = [
|
||||
"futures",
|
||||
"html-escape",
|
||||
"indicatif",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"mpris-server",
|
||||
"regex",
|
||||
|
||||
@ -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 }
|
||||
|
||||
14
README.md
14
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://`,
|
||||
|
||||
12
src/audio.rs
12
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<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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
26
src/error.rs
26
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<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),
|
||||
}
|
||||
|
||||
14
src/main.rs
14
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<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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use clap::ValueEnum;
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
#![allow(clippy::all, clippy::missing_docs_in_private_items)]
|
||||
|
||||
mod bookmark;
|
||||
mod tracks;
|
||||
mod ui;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
25
src/ui.rs
25
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<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;
|
||||
|
||||
@ -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!(
|
||||
" [{}{}] {}/{} ",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user