mirror of
https://github.com/talwat/lowfi
synced 2025-12-09 16:34:12 +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",
|
"futures",
|
||||||
"html-escape",
|
"html-escape",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"lazy_static",
|
|
||||||
"libc",
|
"libc",
|
||||||
"mpris-server",
|
"mpris-server",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lowfi"
|
name = "lowfi"
|
||||||
version = "2.0.0-dev"
|
version = "2.0.0-dev"
|
||||||
|
rust-version = "1.83.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "An extremely simple lofi player."
|
description = "An extremely simple lofi player."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -48,7 +49,6 @@ convert_case = "0.8.0"
|
|||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
lazy_static = "1.5.0"
|
|
||||||
|
|
||||||
# Scraper
|
# Scraper
|
||||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
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
|
This branch serves as a rewrite for lowfi. The main focus is to make the code more
|
||||||
maintainable. This includes such things as:
|
maintainable. This includes such things as:
|
||||||
|
|
||||||
* Replacing `Mutex` & `Arc` with channels, massively improving performance.
|
- Replacing `Mutex` & `Arc` with channels, massively improving readability and flow.
|
||||||
* More clearly handling tracks in different phases of loading, instead of having
|
- More clearly handling tracks in different phases of loading, instead of having
|
||||||
a mess of different structs.
|
a mess of different structs.
|
||||||
* Making the UI code cleaner and easier to follow.
|
- Making the UI code cleaner and easier to follow.
|
||||||
* Rethinking input & control of the player, especially with MPRIS in mind.
|
- Rethinking input & control of the player, especially with MPRIS in mind.
|
||||||
* Making track loading simpler and more consistent.
|
- Making track loading simpler and more consistent.
|
||||||
|
|
||||||
This is an *internal rewrite*, and the goal is to retain every single feature.
|
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
|
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
|
### Dependencies
|
||||||
|
|
||||||
You'll need Rust 1.74.0+.
|
You'll need Rust 1.83.0+.
|
||||||
|
|
||||||
On MacOS & Windows, no extra dependencies are needed.
|
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.
|
the track.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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.
|
> 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://`,
|
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].
|
/// This gets the output stream while also shutting up alsa with [libc].
|
||||||
/// Uses raw libc calls, and therefore is functional only on Linux.
|
/// Uses raw libc calls, and therefore is functional only on Linux.
|
||||||
#[cfg(target_os = "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 libc::freopen;
|
||||||
use rodio::OutputStreamBuilder;
|
use rodio::OutputStreamBuilder;
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
@ -40,3 +40,13 @@ pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::Er
|
|||||||
|
|
||||||
Ok(stream)
|
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,
|
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 {
|
pub struct Handle {
|
||||||
|
/// Background task monitoring the sink.
|
||||||
task: JoinHandle<()>,
|
task: JoinHandle<()>,
|
||||||
|
|
||||||
|
/// Notification primitive used to wake the waiter.
|
||||||
notify: Arc<Notify>,
|
notify: Arc<Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,6 +24,8 @@ impl Drop for Handle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn new(sink: Arc<Sink>, tx: mpsc::Sender<crate::Message>) -> Self {
|
||||||
let notify = Arc::new(Notify::new());
|
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) {
|
pub fn notify(&self) {
|
||||||
self.notify.notify_one();
|
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>) {
|
async fn waiter(sink: Arc<Sink>, tx: mpsc::Sender<crate::Message>, notify: Arc<Notify>) {
|
||||||
loop {
|
loop {
|
||||||
notify.notified().await;
|
notify.notified().await;
|
||||||
@ -42,7 +53,7 @@ impl Handle {
|
|||||||
|
|
||||||
if tx.try_send(crate::Message::Next).is_err() {
|
if tx.try_send(crate::Message::Next).is_err() {
|
||||||
break;
|
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 std::path::PathBuf;
|
||||||
use tokio::{fs, io};
|
use tokio::{fs, io};
|
||||||
|
|
||||||
use crate::{data_dir, tracks};
|
use crate::{data_dir, tracks};
|
||||||
|
|
||||||
|
/// Result alias for bookmark operations.
|
||||||
type Result<T> = std::result::Result<T, Error>;
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// Errors that might occur while managing bookmarks.
|
/// Errors that might occur while managing bookmarks.
|
||||||
@ -24,7 +28,8 @@ pub struct Bookmarks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub async fn path() -> Result<PathBuf> {
|
||||||
let data_dir = data_dir().map_err(|_| Error::Directory)?;
|
let data_dir = data_dir().map_err(|_| Error::Directory)?;
|
||||||
fs::create_dir_all(data_dir.clone()).await?;
|
fs::create_dir_all(data_dir.clone()).await?;
|
||||||
@ -32,7 +37,7 @@ impl Bookmarks {
|
|||||||
Ok(data_dir.join("bookmarks.txt"))
|
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> {
|
pub async fn load() -> Result<Self> {
|
||||||
let text = fs::read_to_string(Self::path().await?)
|
let text = fs::read_to_string(Self::path().await?)
|
||||||
.await
|
.await
|
||||||
@ -54,16 +59,16 @@ impl Bookmarks {
|
|||||||
Ok(Self { entries })
|
Ok(Self { entries })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves the bookmarks to the `bookmarks.txt` file.
|
/// Saves bookmarks to disk in `bookmarks.txt`.
|
||||||
pub async fn save(&self) -> Result<()> {
|
pub async fn save(&self) -> Result<()> {
|
||||||
let text = format!("noheader\n{}", self.entries.join("\n"));
|
let text = format!("noheader\n{}", self.entries.join("\n"));
|
||||||
fs::write(Self::path().await?, text).await?;
|
fs::write(Self::path().await?, text).await?;
|
||||||
Ok(())
|
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> {
|
pub fn bookmark(&mut self, track: &tracks::Info) -> Result<bool> {
|
||||||
let entry = track.to_entry();
|
let entry = track.to_entry();
|
||||||
let idx = self.entries.iter().position(|x| **x == entry);
|
let idx = self.entries.iter().position(|x| **x == entry);
|
||||||
@ -72,13 +77,12 @@ impl Bookmarks {
|
|||||||
self.entries.remove(idx);
|
self.entries.remove(idx);
|
||||||
} else {
|
} else {
|
||||||
self.entries.push(entry);
|
self.entries.push(entry);
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(idx.is_none())
|
Ok(idx.is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the internal bookmarked register by checking against
|
/// Returns true if `track` is currently bookmarked.
|
||||||
/// the current track's info.
|
|
||||||
pub fn bookmarked(&mut self, track: &tracks::Info) -> bool {
|
pub fn bookmarked(&mut self, track: &tracks::Info) -> bool {
|
||||||
self.entries.contains(&track.to_entry())
|
self.entries.contains(&track.to_entry())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,17 +11,45 @@ use tokio::{
|
|||||||
|
|
||||||
use crate::tracks;
|
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);
|
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);
|
pub(crate) static PROGRESS: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// A convenient alias for the progress `AtomicU8` pointer type.
|
||||||
pub type Progress = &'static AtomicU8;
|
pub type Progress = &'static AtomicU8;
|
||||||
|
|
||||||
/// The downloader, which has all of the state necessary
|
/// The downloader, which has all of the state necessary
|
||||||
/// to download tracks and add them to the queue.
|
/// to download tracks and add them to the queue.
|
||||||
pub struct Downloader {
|
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>,
|
queue: Sender<tracks::Queued>,
|
||||||
|
|
||||||
|
/// The [`Sender`] which is used to inform the
|
||||||
|
/// [`crate::Player`] with [`crate::Message::Loaded`].
|
||||||
tx: Sender<crate::Message>,
|
tx: Sender<crate::Message>,
|
||||||
|
|
||||||
|
/// The list of tracks to download from.
|
||||||
tracks: tracks::List,
|
tracks: tracks::List,
|
||||||
|
|
||||||
|
/// The [`reqwest`] client to use for downloads.
|
||||||
client: Client,
|
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,
|
timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,10 +71,13 @@ impl Downloader {
|
|||||||
|
|
||||||
Handle {
|
Handle {
|
||||||
queue: qrx,
|
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<()> {
|
async fn run(self) -> crate::Result<()> {
|
||||||
loop {
|
loop {
|
||||||
let result = self.tracks.random(&self.client, &PROGRESS).await;
|
let result = self.tracks.random(&self.client, &PROGRESS).await;
|
||||||
@ -73,13 +104,23 @@ impl Downloader {
|
|||||||
/// Downloader handle, responsible for managing
|
/// Downloader handle, responsible for managing
|
||||||
/// the downloader task and internal buffer.
|
/// the downloader task and internal buffer.
|
||||||
pub struct Handle {
|
pub struct Handle {
|
||||||
|
/// The queue receiver, which can be used to actually
|
||||||
|
/// fetch a track from the queue.
|
||||||
queue: Receiver<tracks::Queued>,
|
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.
|
/// The output when a track is requested from the downloader.
|
||||||
pub enum Output {
|
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>),
|
Loading(Option<Progress>),
|
||||||
|
|
||||||
|
/// A successfully downloaded (but not yet decoded) track ready to be
|
||||||
|
/// enqueued for decoding/playback.
|
||||||
Queued(tracks::Queued),
|
Queued(tracks::Queued),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +139,6 @@ impl Handle {
|
|||||||
|
|
||||||
impl Drop for Handle {
|
impl Drop for Handle {
|
||||||
fn drop(&mut self) {
|
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 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>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Central application error.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
/// Errors while loading or saving the persistent volume settings.
|
||||||
#[error("unable to load/save the persistent volume: {0}")]
|
#[error("unable to load/save the persistent volume: {0}")]
|
||||||
PersistentVolume(#[from] volume::Error),
|
PersistentVolume(#[from] volume::Error),
|
||||||
|
|
||||||
|
/// Errors while loading or saving bookmarks.
|
||||||
#[error("unable to load/save bookmarks: {0}")]
|
#[error("unable to load/save bookmarks: {0}")]
|
||||||
Bookmarks(#[from] bookmark::Error),
|
Bookmarks(#[from] bookmark::Error),
|
||||||
|
|
||||||
|
/// Network request failures from `reqwest`.
|
||||||
#[error("unable to fetch data: {0}")]
|
#[error("unable to fetch data: {0}")]
|
||||||
Request(#[from] reqwest::Error),
|
Request(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
/// Failure converting to/from a C string (FFI helpers).
|
||||||
#[error("C string null error: {0}")]
|
#[error("C string null error: {0}")]
|
||||||
FfiNull(#[from] std::ffi::NulError),
|
FfiNull(#[from] std::ffi::NulError),
|
||||||
|
|
||||||
|
/// Errors coming from the audio backend / stream handling.
|
||||||
#[error("audio playing error: {0}")]
|
#[error("audio playing error: {0}")]
|
||||||
Rodio(#[from] rodio::StreamError),
|
Rodio(#[from] rodio::StreamError),
|
||||||
|
|
||||||
|
/// Failure to send an internal `Message` over the mpsc channel.
|
||||||
#[error("couldn't send internal message: {0}")]
|
#[error("couldn't send internal message: {0}")]
|
||||||
Send(#[from] mpsc::error::SendError<crate::Message>),
|
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}")]
|
#[error("couldn't add track to the queue: {0}")]
|
||||||
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
|
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
|
||||||
|
|
||||||
|
/// Failure to broadcast UI updates.
|
||||||
#[error("couldn't update UI state: {0}")]
|
#[error("couldn't update UI state: {0}")]
|
||||||
Broadcast(#[from] broadcast::error::SendError<ui::Update>),
|
Broadcast(#[from] broadcast::error::SendError<ui::Update>),
|
||||||
|
|
||||||
|
/// Generic IO error.
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// Data directory was not found or could not be determined.
|
||||||
#[error("directory not found")]
|
#[error("directory not found")]
|
||||||
Directory,
|
Directory,
|
||||||
|
|
||||||
|
/// Downloader failed to provide the requested track.
|
||||||
#[error("couldn't fetch track from downloader")]
|
#[error("couldn't fetch track from downloader")]
|
||||||
Download,
|
Download,
|
||||||
|
|
||||||
|
/// Integer parsing errors.
|
||||||
#[error("couldn't parse integer: {0}")]
|
#[error("couldn't parse integer: {0}")]
|
||||||
Parse(#[from] std::num::ParseIntError),
|
Parse(#[from] std::num::ParseIntError),
|
||||||
|
|
||||||
|
/// Track subsystem error.
|
||||||
#[error("track failure")]
|
#[error("track failure")]
|
||||||
Track(#[from] tracks::Error),
|
Track(#[from] tracks::Error),
|
||||||
|
|
||||||
|
/// UI subsystem error.
|
||||||
#[error("ui failure")]
|
#[error("ui failure")]
|
||||||
UI(#[from] ui::Error),
|
UI(#[from] ui::Error),
|
||||||
|
|
||||||
|
/// Error returned when a spawned task join failed.
|
||||||
#[error("join error")]
|
#[error("join error")]
|
||||||
JoinError(#[from] tokio::task::JoinError),
|
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> {
|
pub fn data_dir() -> crate::Result<PathBuf> {
|
||||||
let dir = dirs::data_dir().unwrap().join("lowfi");
|
let dir = dirs::data_dir().unwrap().join("lowfi");
|
||||||
|
|
||||||
Ok(dir)
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> eyre::Result<()> {
|
async fn main() -> eyre::Result<()> {
|
||||||
let args = Args::parse();
|
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 environment = player.environment();
|
||||||
let result = player.run().await;
|
let result = player.run().await;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::{
|
use tokio::sync::{
|
||||||
broadcast,
|
broadcast,
|
||||||
mpsc::{self, Receiver, Sender},
|
mpsc::{self, Receiver},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -16,47 +16,80 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[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 {
|
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>),
|
Loading(Option<download::Progress>),
|
||||||
|
|
||||||
|
/// A decoded track that can be played; contains the track `Info`.
|
||||||
Track(tracks::Info),
|
Track(tracks::Info),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Current {
|
impl Default for Current {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
// By default the player starts in a loading state with no progress.
|
||||||
Self::Loading(None)
|
Self::Loading(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Current {
|
impl Current {
|
||||||
|
/// Returns `true` if this `Current` value represents a loading state.
|
||||||
pub const fn loading(&self) -> bool {
|
pub const fn loading(&self) -> bool {
|
||||||
matches!(self, Self::Loading(_))
|
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 {
|
pub struct Player {
|
||||||
|
/// Background downloader that fills the internal queue.
|
||||||
downloader: download::Handle,
|
downloader: download::Handle,
|
||||||
|
|
||||||
|
/// Persistent bookmark storage used by the player.
|
||||||
bookmarks: Bookmarks,
|
bookmarks: Bookmarks,
|
||||||
|
|
||||||
|
/// Shared audio sink used for playback.
|
||||||
sink: Arc<rodio::Sink>,
|
sink: Arc<rodio::Sink>,
|
||||||
|
|
||||||
|
/// Receiver for incoming `Message` commands.
|
||||||
rx: Receiver<crate::Message>,
|
rx: Receiver<crate::Message>,
|
||||||
|
|
||||||
|
/// Broadcast channel used to send UI updates.
|
||||||
broadcast: broadcast::Sender<ui::Update>,
|
broadcast: broadcast::Sender<ui::Update>,
|
||||||
|
|
||||||
|
/// Current playback state (loading or track).
|
||||||
current: Current,
|
current: Current,
|
||||||
|
|
||||||
|
/// UI handle for rendering and input.
|
||||||
ui: ui::Handle,
|
ui: ui::Handle,
|
||||||
|
|
||||||
|
/// Notifies when a play head has been appended.
|
||||||
waiter: waiter::Handle,
|
waiter: waiter::Handle,
|
||||||
_tx: Sender<crate::Message>,
|
|
||||||
_stream: rodio::OutputStream,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Player {
|
impl Drop for Player {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// Ensure playback is stopped when the player is dropped.
|
||||||
self.sink.stop();
|
self.sink.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
|
/// Returns the `Environment` currently used by the UI.
|
||||||
pub const fn environment(&self) -> ui::Environment {
|
pub const fn environment(&self) -> ui::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<()> {
|
pub fn set_current(&mut self, current: Current) -> crate::Result<()> {
|
||||||
self.current = current.clone();
|
self.current = current.clone();
|
||||||
self.update(ui::Update::Track(current))?;
|
self.update(ui::Update::Track(current))?;
|
||||||
@ -71,24 +104,25 @@ impl Player {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends a `ui::Update` to the broadcast channel.
|
||||||
pub fn update(&mut self, update: ui::Update) -> crate::Result<()> {
|
pub fn update(&mut self, update: ui::Update) -> crate::Result<()> {
|
||||||
self.broadcast.send(update)?;
|
self.broadcast.send(update)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn init(args: crate::Args) -> crate::Result<Self> {
|
/// Initialize a `Player` with the provided CLI `args` and audio `mixer`.
|
||||||
#[cfg(target_os = "linux")]
|
///
|
||||||
let mut stream = crate::audio::silent_get_output_stream()?;
|
/// This sets up the audio sink, UI, downloader, bookmarks and persistent
|
||||||
#[cfg(not(target_os = "linux"))]
|
/// volume state. The function returns a fully constructed `Player` ready
|
||||||
let mut stream = rodio::OutputStreamBuilder::open_default_stream()?;
|
/// to be driven via `run`.
|
||||||
stream.log_on_drop(false);
|
pub async fn init(args: crate::Args, mixer: &rodio::mixer::Mixer) -> crate::Result<Self> {
|
||||||
let sink = Arc::new(rodio::Sink::connect_new(stream.mixer()));
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel(8);
|
let (tx, rx) = mpsc::channel(8);
|
||||||
tx.send(Message::Init).await?;
|
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 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 state = ui::State::initial(Arc::clone(&sink), args.width, list.name.clone());
|
||||||
|
|
||||||
let volume = PersistentVolume::load().await?;
|
let volume = PersistentVolume::load().await?;
|
||||||
@ -97,17 +131,16 @@ impl Player {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
ui: ui::Handle::init(tx.clone(), urx, state, &args).await?,
|
ui: ui::Handle::init(tx.clone(), urx, state, &args).await?,
|
||||||
downloader: Downloader::init(args.buffer_size as usize, list, tx.clone()),
|
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?,
|
bookmarks: Bookmarks::load().await?,
|
||||||
current: Current::default(),
|
current: Current::default(),
|
||||||
broadcast: utx,
|
broadcast: utx,
|
||||||
rx,
|
rx,
|
||||||
sink,
|
sink,
|
||||||
_tx: tx,
|
|
||||||
_stream: stream,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist state that should survive a run (bookmarks and volume).
|
||||||
pub async fn close(&self) -> crate::Result<()> {
|
pub async fn close(&self) -> crate::Result<()> {
|
||||||
self.bookmarks.save().await?;
|
self.bookmarks.save().await?;
|
||||||
PersistentVolume::save(self.sink.volume()).await?;
|
PersistentVolume::save(self.sink.volume()).await?;
|
||||||
@ -115,6 +148,8 @@ impl Player {
|
|||||||
Ok(())
|
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<()> {
|
pub fn play(&mut self, queued: tracks::Queued) -> crate::Result<()> {
|
||||||
let decoded = queued.decode()?;
|
let decoded = queued.decode()?;
|
||||||
self.sink.append(decoded.data);
|
self.sink.append(decoded.data);
|
||||||
@ -124,6 +159,9 @@ impl Player {
|
|||||||
Ok(())
|
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<()> {
|
pub async fn run(mut self) -> crate::Result<()> {
|
||||||
while let Some(message) = self.rx.recv().await {
|
while let Some(message) = self.rx.recv().await {
|
||||||
match message {
|
match message {
|
||||||
@ -140,7 +178,7 @@ impl Player {
|
|||||||
download::Output::Queued(queued) => {
|
download::Output::Queued(queued) => {
|
||||||
self.play(queued)?;
|
self.play(queued)?;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
Message::Play => {
|
Message::Play => {
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
|
|||||||
@ -3,16 +3,16 @@
|
|||||||
//! This command is completely optional, and as such isn't subject to the same
|
//! This command is completely optional, and as such isn't subject to the same
|
||||||
//! quality standards as the rest of the codebase.
|
//! quality standards as the rest of the codebase.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use futures::{stream::FuturesOrdered, StreamExt};
|
use futures::{stream::FuturesOrdered, StreamExt};
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
use crate::scrapers::{get, Source};
|
use crate::scrapers::{get, Source};
|
||||||
|
|
||||||
lazy_static! {
|
static SELECTOR: LazyLock<Selector> =
|
||||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||||
let document = get(client, path, super::Source::Lofigirl).await?;
|
let document = get(client, path, super::Source::Lofigirl).await?;
|
||||||
|
|||||||
@ -2,9 +2,8 @@ use eyre::eyre;
|
|||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use indicatif::ProgressBar;
|
use indicatif::ProgressBar;
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::{fmt, sync::LazyLock};
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
@ -16,14 +15,13 @@ use tokio::fs;
|
|||||||
|
|
||||||
use crate::scrapers::{get, Source};
|
use crate::scrapers::{get, Source};
|
||||||
|
|
||||||
lazy_static! {
|
static RELEASES: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".table-body > a").unwrap());
|
||||||
static ref RELEASES: Selector = Selector::parse(".table-body > a").unwrap();
|
static RELEASE_LABEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse("label").unwrap());
|
||||||
static ref RELEASE_LABEL: Selector = Selector::parse("label").unwrap();
|
// static ref RELEASE_DATE: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > .text-xs").unwrap());
|
||||||
// static ref RELEASE_DATE: Selector = Selector::parse(".release-feat-props > .text-xs").unwrap();
|
// static ref RELEASE_NAME: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > h2").unwrap());
|
||||||
// static ref RELEASE_NAME: Selector = Selector::parse(".release-feat-props > h2").unwrap();
|
// static RELEASE_AUTHOR: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props .artist-link").unwrap());
|
||||||
static ref RELEASE_AUTHOR: Selector = Selector::parse(".release-feat-props .artist-link").unwrap();
|
static RELEASE_TEXTAREA: LazyLock<Selector> =
|
||||||
static ref RELEASE_TEXTAREA: Selector = Selector::parse("textarea").unwrap();
|
LazyLock::new(|| Selector::parse("textarea").unwrap());
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -3,16 +3,16 @@
|
|||||||
//! This command is completely optional, and as such isn't subject to the same
|
//! This command is completely optional, and as such isn't subject to the same
|
||||||
//! quality standards as the rest of the codebase.
|
//! quality standards as the rest of the codebase.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use futures::{stream::FuturesOrdered, StreamExt};
|
use futures::{stream::FuturesOrdered, StreamExt};
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
use crate::scrapers::get;
|
use crate::scrapers::get;
|
||||||
|
|
||||||
lazy_static! {
|
static SELECTOR: LazyLock<Selector> =
|
||||||
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
|
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
|
||||||
let document = get(client, path, super::Source::Lofigirl).await?;
|
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 bookmark;
|
||||||
mod tracks;
|
mod tracks;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
use convert_case::{Case, Casing as _};
|
use convert_case::{Case, Casing as _};
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::path::Path;
|
use std::{path::Path, sync::LazyLock};
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
use super::error::WithTrackContext as _;
|
use super::error::WithTrackContext as _;
|
||||||
|
|
||||||
lazy_static! {
|
/// Regex patterns for matching and removing the "master" text in some track titles.
|
||||||
static ref MASTER_PATTERNS: [Regex; 5] = [
|
///
|
||||||
|
/// 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)
|
// (master), (master v2)
|
||||||
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(),
|
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(),
|
||||||
// mstr or - mstr or (mstr) — now also matches "mstr v3", "mstr2", etc.
|
// 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(),
|
Regex::new(r"\s+kupla\s+master(?:\s*v?\d+|\d+)?$").unwrap(),
|
||||||
// (kupla master) followed by trailing parenthetical numbers, e.g. "... (kupla master) (1)"
|
// (kupla master) followed by trailing parenthetical numbers, e.g. "... (kupla master) (1)"
|
||||||
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)(?:\s*\(\d+\))+$").unwrap(),
|
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.
|
/// Decodes a URL string into normal UTF-8.
|
||||||
fn decode_url(text: &str) -> String {
|
fn decode_url(text: &str) -> String {
|
||||||
|
|||||||
@ -173,7 +173,7 @@ impl List {
|
|||||||
// Get rid of special noheader case for tracklists without a header.
|
// Get rid of special noheader case for tracklists without a header.
|
||||||
let raw = raw
|
let raw = raw
|
||||||
.strip_prefix("noheader")
|
.strip_prefix("noheader")
|
||||||
.map_or(raw.as_ref(), |stripped| stripped);
|
.map_or_else(|| raw.as_ref(), |stripped| stripped);
|
||||||
|
|
||||||
let name = path
|
let name = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
|
|||||||
25
src/ui.rs
25
src/ui.rs
@ -20,6 +20,7 @@ pub mod window;
|
|||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
pub mod mpris;
|
pub mod mpris;
|
||||||
|
|
||||||
|
/// Shorthand for a [`Result`] with a [`ui::Error`].
|
||||||
type Result<T> = std::result::Result<T, Error>;
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// The error type for the UI, which is used to handle errors
|
/// The error type for the UI, which is used to handle errors
|
||||||
@ -54,12 +55,22 @@ pub enum Error {
|
|||||||
/// track of state.
|
/// track of state.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
|
/// The audio sink.
|
||||||
pub sink: Arc<rodio::Sink>,
|
pub sink: Arc<rodio::Sink>,
|
||||||
|
|
||||||
|
/// The current track, which is updated by way of an [`Update`].
|
||||||
pub current: Current,
|
pub current: Current,
|
||||||
|
|
||||||
|
/// Whether the current track is bookmarked.
|
||||||
pub bookmarked: bool,
|
pub bookmarked: bool,
|
||||||
|
|
||||||
|
/// The timer, which is used when the user changes volume to briefly display it.
|
||||||
pub(crate) timer: Option<Instant>,
|
pub(crate) timer: Option<Instant>,
|
||||||
|
|
||||||
|
/// The full inner width of the terminal window.
|
||||||
pub(crate) width: usize,
|
pub(crate) width: usize,
|
||||||
|
|
||||||
|
/// The name of the playing tracklist, for MPRIS.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
list: String,
|
list: String,
|
||||||
}
|
}
|
||||||
@ -97,17 +108,25 @@ pub enum Update {
|
|||||||
/// requires to function.
|
/// requires to function.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Tasks {
|
struct Tasks {
|
||||||
|
/// The renderer, responsible for sending output to `stdout`.
|
||||||
render: JoinHandle<Result<()>>,
|
render: JoinHandle<Result<()>>,
|
||||||
|
|
||||||
|
/// The input, which receives data from `stdin` via [`crossterm`].
|
||||||
input: JoinHandle<Result<()>>,
|
input: JoinHandle<Result<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The UI handle for controlling the state of the UI, as well as
|
/// The UI handle for controlling the state of the UI, as well as
|
||||||
/// updating MPRIS information and other small interfacing tasks.
|
/// updating MPRIS information and other small interfacing tasks.
|
||||||
pub struct Handle {
|
pub struct Handle {
|
||||||
tasks: Tasks,
|
/// The terminal environment, which can be used for cleanup.
|
||||||
pub environment: Environment,
|
pub(crate) environment: Environment,
|
||||||
|
|
||||||
|
/// The MPRIS server, which is more or less a handle to the actual MPRIS thread.
|
||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
pub mpris: mpris::Server,
|
pub mpris: mpris::Server,
|
||||||
|
|
||||||
|
/// The UI's running tasks.
|
||||||
|
tasks: Tasks,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Handle {
|
impl Drop for Handle {
|
||||||
@ -142,7 +161,7 @@ impl Handle {
|
|||||||
Update::Volume => state.timer = Some(Instant::now()),
|
Update::Volume => state.timer = Some(Instant::now()),
|
||||||
Update::Quit => break,
|
Update::Quit => break,
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
interface::draw(&mut state, &mut window, params)?;
|
interface::draw(&mut state, &mut window, params)?;
|
||||||
interval.tick().await;
|
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;
|
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
||||||
filled = (elapsed * width as f32).round() as usize;
|
filled = (elapsed * width as f32).round() as usize;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
format!(
|
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> {
|
pub(crate) fn menu(state: &mut ui::State, params: Params) -> Vec<String> {
|
||||||
let action = components::action(state, state.width);
|
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());
|
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
||||||
if timer.elapsed() > Duration::from_secs(1) {
|
if timer.elapsed() > Duration::from_secs(1) {
|
||||||
state.timer = None;
|
state.timer = None;
|
||||||
};
|
}
|
||||||
|
|
||||||
components::audio_bar(state.width - 17, volume, &percentage)
|
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(
|
pub(crate) fn render(
|
||||||
&self,
|
&self,
|
||||||
content: Vec<String>,
|
content: Vec<String>,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
//! Persistent volume management.
|
//! 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 std::{num::ParseIntError, path::PathBuf};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
@ -18,8 +21,11 @@ pub enum Error {
|
|||||||
Parse(#[from] ParseIntError),
|
Parse(#[from] ParseIntError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is the representation of the persistent volume,
|
/// Representation of the persistent volume stored on disk.
|
||||||
/// which is loaded at startup and saved on shutdown.
|
///
|
||||||
|
/// 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)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct PersistentVolume {
|
pub struct PersistentVolume {
|
||||||
/// The volume, as a percentage.
|
/// The volume, as a percentage.
|
||||||
@ -27,7 +33,7 @@ pub struct PersistentVolume {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PersistentVolume {
|
impl PersistentVolume {
|
||||||
/// Retrieves the config directory.
|
/// Retrieves the config directory, creating it if necessary.
|
||||||
async fn config() -> Result<PathBuf> {
|
async fn config() -> Result<PathBuf> {
|
||||||
let config = dirs::config_dir()
|
let config = dirs::config_dir()
|
||||||
.ok_or(Error::Directory)?
|
.ok_or(Error::Directory)?
|
||||||
@ -40,12 +46,15 @@ impl PersistentVolume {
|
|||||||
Ok(config)
|
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 {
|
pub fn float(self) -> f32 {
|
||||||
f32::from(self.inner) / 100.0
|
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> {
|
pub async fn load() -> Result<Self> {
|
||||||
let config = Self::config().await?;
|
let config = Self::config().await?;
|
||||||
let volume = config.join(PathBuf::from("volume.txt"));
|
let volume = config.join(PathBuf::from("volume.txt"));
|
||||||
@ -64,7 +73,7 @@ impl PersistentVolume {
|
|||||||
Ok(Self { inner: volume })
|
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<()> {
|
pub async fn save(volume: f32) -> Result<()> {
|
||||||
let config = Self::config().await?;
|
let config = Self::config().await?;
|
||||||
let path = config.join(PathBuf::from("volume.txt"));
|
let path = config.join(PathBuf::from("volume.txt"));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user