mirror of
https://github.com/talwat/lowfi
synced 2025-12-07 23:47:46 +00:00
feat: merge pull request #108 from talwat/rewrite
This commit is contained in:
commit
a26623f9c0
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@ -1,6 +1,7 @@
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
|
||||
27
.github/workflows/tests.yml
vendored
Normal file
27
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: Rust Unit Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt install libasound2-dev
|
||||
|
||||
- name: Setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all --verbose
|
||||
1566
Cargo.lock
generated
1566
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
34
Cargo.toml
@ -1,6 +1,7 @@
|
||||
[package]
|
||||
name = "lowfi"
|
||||
version = "1.7.2"
|
||||
version = "2.0.0-dev"
|
||||
rust-version = "1.83.0"
|
||||
edition = "2021"
|
||||
description = "An extremely simple lofi player."
|
||||
license = "MIT"
|
||||
@ -27,38 +28,47 @@ clap = { version = "4.5.21", features = ["derive", "cargo"] }
|
||||
eyre = "0.6.12"
|
||||
fastrand = "2.3.0"
|
||||
thiserror = "2.0.12"
|
||||
color-eyre = { version = "0.6.5", default-features = false }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "fs"], default-features = false }
|
||||
futures = "0.3.31"
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt", "fs"], default-features = false }
|
||||
arc-swap = "1.7.1"
|
||||
futures = "0.3.31"
|
||||
|
||||
# Data
|
||||
reqwest = { version = "0.12.9", features = ["stream"] }
|
||||
bytes = "1.9.0"
|
||||
|
||||
# I/O
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"], default-features = false }
|
||||
rodio = { version = "0.21.1", features = ["symphonia-mp3", "playback"], default-features = false }
|
||||
mpris-server = { version = "0.8.1", optional = true }
|
||||
mpris-server = { version = "0.9.0", optional = true }
|
||||
dirs = "6.0.0"
|
||||
|
||||
# Misc
|
||||
convert_case = "0.8.0"
|
||||
lazy_static = "1.5.0"
|
||||
url = "2.5.4"
|
||||
unicode-segmentation = "1.12.0"
|
||||
url = "2.5.4"
|
||||
|
||||
# Scraper
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0.142", optional = true }
|
||||
scraper = { version = "0.21.0", optional = true }
|
||||
scraper = { version = "0.24.0", optional = true }
|
||||
html-escape = { version = "0.2.13", optional = true }
|
||||
indicatif = { version = "0.18.0", optional = true }
|
||||
regex = "1.11.1"
|
||||
atomic_float = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2.167"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
|
||||
unwrap_in_result = "warn"
|
||||
missing_docs_in_private_items = "warn"
|
||||
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
must_use_candidate = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
cast_possible_truncation = "allow"
|
||||
|
||||
22
README.md
22
README.md
@ -5,6 +5,24 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
|
||||
|
||||

|
||||
|
||||
## The Rewrite
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
in the rewrite, then it is a bug and must be implemented.
|
||||
|
||||
Currently, it is in an extremely early and non-functional state.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
As of the 1.7.0 version of lowfi, **all** of the audio files embedded
|
||||
@ -28,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.
|
||||
|
||||
@ -222,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://`,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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::player::Error> {
|
||||
fn silent_get_output_stream() -> crate::Result<rodio::OutputStream> {
|
||||
use libc::freopen;
|
||||
use rodio::OutputStreamBuilder;
|
||||
use std::ffi::CString;
|
||||
@ -23,7 +25,7 @@ pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::pl
|
||||
// SAFETY: Simple enough to be impossible to fail. Hopefully.
|
||||
unsafe {
|
||||
freopen(null.as_ptr(), mode.as_ptr(), stderr);
|
||||
}
|
||||
};
|
||||
|
||||
// Make the OutputStream while stderr is still redirected to /dev/null.
|
||||
let stream = OutputStreamBuilder::open_default_stream()?;
|
||||
@ -34,7 +36,17 @@ pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::pl
|
||||
// SAFETY: See the first call to `freopen`.
|
||||
unsafe {
|
||||
freopen(tty.as_ptr(), mode.as_ptr(), stderr);
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
59
src/audio/waiter.rs
Normal file
59
src/audio/waiter.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use rodio::Sink;
|
||||
use tokio::{
|
||||
sync::{mpsc, Notify},
|
||||
task::{self, JoinHandle},
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
Self {
|
||||
task: task::spawn(Self::waiter(sink, tx, Arc::clone(¬ify))),
|
||||
notify,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
while !sink.empty() {
|
||||
time::sleep(Duration::from_millis(8)).await;
|
||||
}
|
||||
|
||||
if tx.try_send(crate::Message::Next).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/bookmark.rs
Normal file
89
src/bookmark.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! 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.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("data directory not found")]
|
||||
Directory,
|
||||
|
||||
#[error("io failure")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Manages the bookmarks in the current player.
|
||||
pub struct Bookmarks {
|
||||
/// The different entries in the bookmarks file.
|
||||
pub(crate) entries: Vec<String>,
|
||||
}
|
||||
|
||||
impl Bookmarks {
|
||||
/// 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?;
|
||||
|
||||
Ok(data_dir.join("bookmarks.txt"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
.unwrap_or_default();
|
||||
|
||||
let entries: Vec<String> = text
|
||||
.trim_start_matches("noheader")
|
||||
.trim()
|
||||
.lines()
|
||||
.filter_map(|x| {
|
||||
if x.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(x.to_owned())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Toggles bookmarking for `track` and returns whether it is now bookmarked.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
self.entries.remove(idx);
|
||||
} else {
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
Ok(idx.is_none())
|
||||
}
|
||||
|
||||
/// Returns true if `track` is currently bookmarked.
|
||||
pub fn bookmarked(&mut self, track: &tracks::Info) -> bool {
|
||||
self.entries.contains(&track.to_entry())
|
||||
}
|
||||
}
|
||||
152
src/download.rs
Normal file
152
src/download.rs
Normal file
@ -0,0 +1,152 @@
|
||||
use std::{
|
||||
sync::atomic::{self, AtomicBool, AtomicU8},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::{
|
||||
sync::mpsc::{self, Receiver, Sender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
/// Initializes the downloader with a track list.
|
||||
///
|
||||
/// `tx` specifies the [`Sender`] to be notified with [`crate::Message::Loaded`].
|
||||
pub fn init(
|
||||
size: usize,
|
||||
timeout: u64,
|
||||
tracks: tracks::List,
|
||||
tx: Sender<crate::Message>,
|
||||
) -> crate::Result<Handle> {
|
||||
let client = Client::builder()
|
||||
.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()?;
|
||||
|
||||
let (qtx, qrx) = mpsc::channel(size - 1);
|
||||
let downloader = Self {
|
||||
queue: qtx,
|
||||
tx,
|
||||
tracks,
|
||||
client,
|
||||
};
|
||||
|
||||
Ok(Handle {
|
||||
queue: qrx,
|
||||
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<()> {
|
||||
const ERROR_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
loop {
|
||||
let result = self.tracks.random(&self.client, &PROGRESS).await;
|
||||
match result {
|
||||
Ok(track) => {
|
||||
self.queue.send(track).await?;
|
||||
|
||||
if LOADING.load(atomic::Ordering::Relaxed) {
|
||||
self.tx.send(crate::Message::Loaded).await?;
|
||||
LOADING.store(false, atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
PROGRESS.store(0, atomic::Ordering::Relaxed);
|
||||
if !error.timeout() {
|
||||
tokio::time::sleep(ERROR_TIMEOUT).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
impl Handle {
|
||||
/// Gets either a queued track, or a progress report,
|
||||
/// depending on the state of the internal download buffer.
|
||||
#[rustfmt::skip]
|
||||
pub fn track(&mut self) -> Output {
|
||||
self.queue.try_recv().map_or_else(|_| {
|
||||
LOADING.store(true, atomic::Ordering::Relaxed);
|
||||
Output::Loading(Some(&PROGRESS))
|
||||
}, Output::Queued,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
76
src/error.rs
Normal file
76
src/error.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! 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),
|
||||
}
|
||||
76
src/main.rs
76
src/main.rs
@ -1,16 +1,22 @@
|
||||
//! An extremely simple lofi player.
|
||||
|
||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
pub mod error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod messages;
|
||||
mod play;
|
||||
mod player;
|
||||
mod tracks;
|
||||
use clap::{Parser, Subcommand};
|
||||
mod tests;
|
||||
pub use error::{Error, Result};
|
||||
pub mod message;
|
||||
pub mod ui;
|
||||
pub use message::Message;
|
||||
|
||||
use crate::player::Player;
|
||||
pub mod audio;
|
||||
pub mod bookmark;
|
||||
pub mod download;
|
||||
pub mod player;
|
||||
pub mod tracks;
|
||||
pub mod volume;
|
||||
|
||||
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
|
||||
#[cfg(feature = "scrape")]
|
||||
mod scrapers;
|
||||
|
||||
@ -21,7 +27,7 @@ use crate::scrapers::Source;
|
||||
#[derive(Parser, Clone)]
|
||||
#[command(about, version)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct Args {
|
||||
pub struct Args {
|
||||
/// Use an alternate terminal screen.
|
||||
#[clap(long, short)]
|
||||
alternate: bool,
|
||||
@ -43,7 +49,7 @@ struct Args {
|
||||
fps: u8,
|
||||
|
||||
/// Timeout in seconds for music downloads.
|
||||
#[clap(long, default_value_t = 3)]
|
||||
#[clap(long, default_value_t = 16)]
|
||||
timeout: u64,
|
||||
|
||||
/// Include ALSA & other logs.
|
||||
@ -54,13 +60,13 @@ struct Args {
|
||||
#[clap(long, short, default_value_t = 3)]
|
||||
width: usize,
|
||||
|
||||
/// Use a custom track list
|
||||
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l')]
|
||||
track_list: Option<String>,
|
||||
/// Track list to play music from
|
||||
#[clap(long, short, alias = "list", alias = "tracks", short_alias = 'l', default_value_t = String::from("chillhop"))]
|
||||
track_list: String,
|
||||
|
||||
/// Internal song buffer size.
|
||||
#[clap(long, short = 's', alias = "buffer", default_value_t = 5)]
|
||||
buffer_size: usize,
|
||||
#[clap(long, short = 's', alias = "buffer", default_value_t = 5, value_parser = clap::value_parser!(u32).range(2..))]
|
||||
buffer_size: u32,
|
||||
|
||||
/// The command that was ran.
|
||||
/// This is [None] if no command was specified.
|
||||
@ -79,33 +85,41 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
/// Gets lowfi's data directory.
|
||||
pub fn data_dir() -> eyre::Result<PathBuf, player::Error> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or(player::Error::DataDir)?
|
||||
.join("lowfi");
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
/// 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(flavor = "current_thread")]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
let args = Args::parse();
|
||||
|
||||
let cli = Args::parse();
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
#[cfg(feature = "scrape")]
|
||||
if let Some(command) = &args.command {
|
||||
match command {
|
||||
#[cfg(feature = "scrape")]
|
||||
Commands::Scrape { source } => match source {
|
||||
Source::Archive => scrapers::archive::scrape().await?,
|
||||
Source::Lofigirl => scrapers::lofigirl::scrape().await?,
|
||||
Source::Chillhop => scrapers::chillhop::scrape().await?,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
play::play(cli).await?;
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let stream = audio::stream()?;
|
||||
let mut player = Player::init(args, stream.mixer()).await?;
|
||||
let result = player.run().await;
|
||||
|
||||
player.environment().cleanup(result.is_ok())?;
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
37
src/message.rs
Normal file
37
src/message.rs
Normal file
@ -0,0 +1,37 @@
|
||||
/// Handles communication between different parts of the program.
|
||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||
#[derive(PartialEq, Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// Deliberate user request to go to the next song, also sent when the
|
||||
/// song is over by the waiter.
|
||||
Next,
|
||||
|
||||
/// When a track is loaded after the caller previously being told to wait.
|
||||
/// If a track is taken from the queue, then there is no waiting, so this
|
||||
/// is never actually sent.
|
||||
Loaded,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Unpause the [Sink].
|
||||
Play,
|
||||
|
||||
/// Pauses the [Sink].
|
||||
Pause,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
PlayPause,
|
||||
|
||||
/// Change the volume of playback.
|
||||
ChangeVolume(f32),
|
||||
|
||||
/// Set the volume of playback, rather than changing it.
|
||||
SetVolume(f32),
|
||||
|
||||
/// Bookmark the current track.
|
||||
Bookmark,
|
||||
|
||||
/// Quits gracefully.
|
||||
Quit,
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
/// Handles communication between the frontend & audio player.
|
||||
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||
pub enum Message {
|
||||
/// Notifies the audio server that it should update the track.
|
||||
Next,
|
||||
|
||||
/// Special in that this isn't sent in a "client to server" sort of way,
|
||||
/// but rather is sent by a child of the server when a song has not only
|
||||
/// been requested but also downloaded aswell.
|
||||
NewSong,
|
||||
|
||||
/// This signal is only sent if a track timed out. In that case,
|
||||
/// lowfi will try again and again to retrieve the track.
|
||||
TryAgain,
|
||||
|
||||
/// Similar to Next, but specific to the first track.
|
||||
Init,
|
||||
|
||||
/// Unpause the [Sink].
|
||||
#[allow(dead_code, reason = "this code may not be dead depending on features")]
|
||||
Play,
|
||||
|
||||
/// Pauses the [Sink].
|
||||
Pause,
|
||||
|
||||
/// Pauses the [Sink]. This will also unpause it if it is paused.
|
||||
PlayPause,
|
||||
|
||||
/// Change the volume of playback.
|
||||
ChangeVolume(f32),
|
||||
|
||||
/// Bookmark the current track.
|
||||
Bookmark,
|
||||
|
||||
/// Quits gracefully.
|
||||
Quit,
|
||||
}
|
||||
78
src/play.rs
78
src/play.rs
@ -1,78 +0,0 @@
|
||||
//! Responsible for the basic initialization & shutdown of the audio server & frontend.
|
||||
|
||||
use crossterm::cursor::Show;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::terminal::{self, Clear, ClearType};
|
||||
use std::io::{stdout, IsTerminal};
|
||||
use std::process::exit;
|
||||
use std::sync::Arc;
|
||||
use std::{env, panic};
|
||||
use tokio::{sync::mpsc, task};
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::player::persistent_volume::PersistentVolume;
|
||||
use crate::player::Player;
|
||||
use crate::player::{self, ui};
|
||||
use crate::Args;
|
||||
|
||||
/// Initializes the audio server, and then safely stops
|
||||
/// it when the frontend quits.
|
||||
pub async fn play(args: Args) -> eyre::Result<(), player::Error> {
|
||||
// TODO: This isn't a great way of doing things,
|
||||
// but it's better than vanilla behaviour at least.
|
||||
let eyre_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(move |x| {
|
||||
let mut lock = stdout().lock();
|
||||
crossterm::execute!(
|
||||
lock,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
Show,
|
||||
PopKeyboardEnhancementFlags
|
||||
)
|
||||
.unwrap();
|
||||
terminal::disable_raw_mode().unwrap();
|
||||
|
||||
eyre_hook(x);
|
||||
exit(1)
|
||||
}));
|
||||
|
||||
// Actually initializes the player.
|
||||
// Stream kept here in the master thread to keep it alive.
|
||||
let (player, stream) = Player::new(&args).await?;
|
||||
let player = Arc::new(player);
|
||||
|
||||
// Initialize the UI, as well as the internal communication channel.
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let ui = if stdout().is_terminal() && !(env::var("LOWFI_DISABLE_UI") == Ok("1".to_owned())) {
|
||||
Some(task::spawn(ui::start(
|
||||
Arc::clone(&player),
|
||||
tx.clone(),
|
||||
args.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Sends the player an "init" signal telling it to start playing a song straight away.
|
||||
tx.send(Message::Init).await?;
|
||||
|
||||
// Actually starts the player.
|
||||
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
|
||||
|
||||
// Save the volume.txt file for the next session.
|
||||
PersistentVolume::save(player.sink.volume())
|
||||
.await
|
||||
.map_err(player::Error::PersistentVolumeSave)?;
|
||||
|
||||
// Save the bookmarks for the next session.
|
||||
player.bookmarks.save().await?;
|
||||
|
||||
drop(stream);
|
||||
player.sink.stop();
|
||||
if let Some(x) = ui {
|
||||
x.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
494
src/player.rs
494
src/player.rs
@ -1,314 +1,242 @@
|
||||
//! Responsible for playing & queueing audio.
|
||||
//! This also has the code for the underlying
|
||||
//! audio server which adds new tracks.
|
||||
use std::sync::Arc;
|
||||
|
||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use atomic_float::AtomicF32;
|
||||
use downloader::Downloader;
|
||||
use reqwest::Client;
|
||||
use rodio::{OutputStream, OutputStreamBuilder, Sink};
|
||||
use tokio::{
|
||||
select,
|
||||
sync::{
|
||||
mpsc::{Receiver, Sender},
|
||||
RwLock,
|
||||
},
|
||||
task,
|
||||
use tokio::sync::{
|
||||
broadcast,
|
||||
mpsc::{self, Receiver},
|
||||
};
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
use mpris_server::{PlaybackStatus, PlayerInterface, Property};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume},
|
||||
tracks::{self, list::List},
|
||||
Args,
|
||||
audio::waiter,
|
||||
bookmark::Bookmarks,
|
||||
download::{self, Downloader},
|
||||
tracks::{self, List},
|
||||
ui,
|
||||
volume::PersistentVolume,
|
||||
Message,
|
||||
};
|
||||
|
||||
pub mod audio;
|
||||
pub mod bookmark;
|
||||
pub mod downloader;
|
||||
pub mod error;
|
||||
pub mod persistent_volume;
|
||||
pub mod queue;
|
||||
pub mod ui;
|
||||
#[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>),
|
||||
|
||||
pub use error::Error;
|
||||
/// A decoded track that can be played; contains the track `Info`.
|
||||
Track(tracks::Info),
|
||||
}
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
pub mod mpris;
|
||||
impl Default for Current {
|
||||
fn default() -> Self {
|
||||
// By default the player starts in a loading state with no progress.
|
||||
Self::Loading(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Main struct responsible for queuing up & playing tracks.
|
||||
// TODO: Consider refactoring [Player] from being stored in an [Arc], into containing many smaller [Arc]s.
|
||||
// TODO: In other words, this would change the type from `Arc<Player>` to just `Player`.
|
||||
// TODO:
|
||||
// TODO: This is conflicting, since then it'd clone ~10 smaller [Arc]s
|
||||
// TODO: every single time, which could be even worse than having an
|
||||
// TODO: [Arc] of an [Arc] in some cases (Like with [Sink] & [Client]).
|
||||
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 {
|
||||
/// [rodio]'s [`Sink`] which can control playback.
|
||||
pub sink: Sink,
|
||||
/// Background downloader that fills the internal queue.
|
||||
downloader: download::Handle,
|
||||
|
||||
/// The internal buffer size.
|
||||
pub buffer_size: usize,
|
||||
/// Persistent bookmark storage used by the player.
|
||||
bookmarks: Bookmarks,
|
||||
|
||||
/// The [`TrackInfo`] of the current track.
|
||||
/// This is [`None`] when lowfi is buffering/loading.
|
||||
current: ArcSwapOption<tracks::Info>,
|
||||
/// Shared audio sink used for playback.
|
||||
sink: Arc<rodio::Sink>,
|
||||
|
||||
/// The current progress for downloading tracks, if
|
||||
/// `current` is None.
|
||||
progress: AtomicF32,
|
||||
/// Receiver for incoming `Message` commands.
|
||||
rx: Receiver<crate::Message>,
|
||||
|
||||
/// The tracks, which is a [`VecDeque`] that holds
|
||||
/// *undecoded* [Track]s.
|
||||
///
|
||||
/// This is populated specifically by the [Downloader].
|
||||
tracks: RwLock<VecDeque<tracks::QueuedTrack>>,
|
||||
/// Broadcast channel used to send UI updates.
|
||||
broadcast: broadcast::Sender<ui::Update>,
|
||||
|
||||
/// The bookmarks, which are saved on quit.
|
||||
pub bookmarks: Bookmarks,
|
||||
/// Current playback state (loading or track).
|
||||
current: Current,
|
||||
|
||||
/// The timeout for track downloads, as a [Duration].
|
||||
timeout: Duration,
|
||||
/// UI handle for rendering and input.
|
||||
ui: ui::Handle,
|
||||
|
||||
/// The actual list of tracks to be played.
|
||||
list: List,
|
||||
/// Notifies when a play head has been appended.
|
||||
waiter: waiter::Handle,
|
||||
}
|
||||
|
||||
/// The initial volume level.
|
||||
volume: PersistentVolume,
|
||||
|
||||
/// The web client, which can contain a `UserAgent` & some
|
||||
/// settings that help lowfi work more effectively.
|
||||
client: Client,
|
||||
impl Drop for Player {
|
||||
fn drop(&mut self) {
|
||||
// Ensure playback is stopped when the player is dropped.
|
||||
self.sink.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Just a shorthand for setting `current`.
|
||||
fn set_current(&self, info: tracks::Info) {
|
||||
self.current.store(Some(Arc::new(info)));
|
||||
/// Returns the `Environment` currently used by the UI.
|
||||
pub const fn environment(&self) -> ui::Environment {
|
||||
self.ui.environment
|
||||
}
|
||||
|
||||
/// A shorthand for checking if `self.current` is [Some].
|
||||
pub fn current_exists(&self) -> bool {
|
||||
self.current.load().is_some()
|
||||
}
|
||||
|
||||
/// Sets the volume of the sink, and also clamps the value to avoid negative/over 100% values.
|
||||
pub fn set_volume(&self, volume: f32) {
|
||||
self.sink.set_volume(volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Initializes the entire player, including audio devices & sink.
|
||||
/// Sets the in-memory current state and notifies the UI about the change.
|
||||
///
|
||||
/// This also will load the track list & persistent volume.
|
||||
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
|
||||
// Load the bookmarks.
|
||||
let bookmarks = Bookmarks::load().await?;
|
||||
/// 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))?;
|
||||
|
||||
// Load the volume file.
|
||||
let volume = PersistentVolume::load()
|
||||
.await
|
||||
.map_err(player::Error::PersistentVolumeLoad)?;
|
||||
|
||||
// Load the track list.
|
||||
let list = List::load(args.track_list.as_ref())
|
||||
.await
|
||||
.map_err(player::Error::TrackListLoad)?;
|
||||
|
||||
// We should only shut up alsa forcefully on Linux if we really have to.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut stream = if !args.alternate && !args.debug {
|
||||
audio::silent_get_output_stream()?
|
||||
} else {
|
||||
OutputStreamBuilder::open_default_stream()?
|
||||
let Current::Track(track) = &self.current else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut stream = OutputStreamBuilder::open_default_stream()?;
|
||||
|
||||
stream.log_on_drop(false); // Frankly, this is a stupid feature. Stop shoving your crap into my beloved stderr!!!
|
||||
let sink = Sink::connect_new(stream.mixer());
|
||||
|
||||
if args.paused {
|
||||
sink.pause();
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(Duration::from_secs(args.timeout * 5))
|
||||
.build()?;
|
||||
|
||||
let player = Self {
|
||||
tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)),
|
||||
buffer_size: args.buffer_size,
|
||||
current: ArcSwapOption::new(None),
|
||||
progress: AtomicF32::new(0.0),
|
||||
timeout: Duration::from_secs(args.timeout),
|
||||
bookmarks,
|
||||
client,
|
||||
sink,
|
||||
volume,
|
||||
list,
|
||||
};
|
||||
|
||||
Ok((player, stream))
|
||||
}
|
||||
|
||||
/// This is the main "audio server".
|
||||
///
|
||||
/// `rx` & `tx` are used to communicate with it, for example when to
|
||||
/// skip tracks or pause.
|
||||
///
|
||||
/// This will also initialize a [Downloader] as well as an MPRIS server if enabled.
|
||||
/// The [Downloader]s internal buffer size is determined by `buf_size`.
|
||||
pub async fn play(
|
||||
player: Arc<Self>,
|
||||
tx: Sender<Message>,
|
||||
mut rx: Receiver<Message>,
|
||||
debug: bool,
|
||||
) -> eyre::Result<(), player::Error> {
|
||||
// Initialize the mpris player.
|
||||
//
|
||||
// We're initializing here, despite MPRIS being a "user interface",
|
||||
// since we need to be able to *actively* write new information to MPRIS
|
||||
// specifically when it occurs, unlike the UI which passively reads the
|
||||
// information each frame. Blame MPRIS, not me.
|
||||
#[cfg(feature = "mpris")]
|
||||
let mpris = mpris::Server::new(Arc::clone(&player), tx.clone())
|
||||
.await
|
||||
.inspect_err(|x| {
|
||||
dbg!(x);
|
||||
})?;
|
||||
|
||||
// `itx` is used to notify the `Downloader` when it needs to download new tracks.
|
||||
let downloader = Downloader::new(Arc::clone(&player));
|
||||
let (itx, downloader) = downloader.start(debug);
|
||||
|
||||
// Start buffering tracks immediately.
|
||||
Downloader::notify(&itx).await?;
|
||||
|
||||
// Set the initial sink volume to the one specified.
|
||||
player.set_volume(player.volume.float());
|
||||
|
||||
// Whether the last signal was a `NewSong`. This is helpful, since we
|
||||
// only want to autoplay if there hasn't been any manual intervention.
|
||||
//
|
||||
// In other words, this will be `true` after a new track has been fully
|
||||
// loaded and it'll be `false` if a track is still currently loading.
|
||||
let mut new = false;
|
||||
|
||||
loop {
|
||||
let clone = Arc::clone(&player);
|
||||
|
||||
let msg = select! {
|
||||
biased;
|
||||
|
||||
Some(x) = rx.recv() => x,
|
||||
// This future will finish only at the end of the current track.
|
||||
// The condition is a kind-of hack which gets around the quirks
|
||||
// of `sleep_until_end`.
|
||||
//
|
||||
// That's because `sleep_until_end` will return instantly if the sink
|
||||
// is uninitialized. That's why we put a check to make sure that the last
|
||||
// signal we got was `NewSong`, since we shouldn't start waiting for the
|
||||
// song to be over until it has actually started.
|
||||
//
|
||||
// It's also important to note that the condition is only checked at the
|
||||
// beginning of the loop, not throughout.
|
||||
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
|
||||
if new => Message::Next,
|
||||
};
|
||||
|
||||
match msg {
|
||||
Message::Next | Message::Init | Message::TryAgain => {
|
||||
// We manually skipped, so we shouldn't actually wait for the song
|
||||
// to be over until we recieve the `NewSong` signal.
|
||||
new = false;
|
||||
|
||||
// This basically just prevents `Next` while a song is still currently loading.
|
||||
if msg == Message::Next && !player.current_exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle the rest of the signal in the background,
|
||||
// as to not block the main audio server thread.
|
||||
task::spawn(Self::next(
|
||||
Arc::clone(&player),
|
||||
itx.clone(),
|
||||
tx.clone(),
|
||||
debug,
|
||||
));
|
||||
}
|
||||
Message::Play => {
|
||||
player.sink.play();
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris.playback(PlaybackStatus::Playing).await?;
|
||||
}
|
||||
Message::Pause => {
|
||||
player.sink.pause();
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris.playback(PlaybackStatus::Paused).await?;
|
||||
}
|
||||
Message::PlayPause => {
|
||||
if player.sink.is_paused() {
|
||||
player.sink.play();
|
||||
} else {
|
||||
player.sink.pause();
|
||||
}
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.playback(mpris.player().playback_status().await?)
|
||||
.await?;
|
||||
}
|
||||
Message::ChangeVolume(change) => {
|
||||
player.set_volume(player.sink.volume() + change);
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.changed(vec![Property::Volume(player.sink.volume().into())])
|
||||
.await?;
|
||||
}
|
||||
// This basically just continues, but more importantly, it'll re-evaluate
|
||||
// the select macro at the beginning of the loop.
|
||||
// See the top section to find out why this matters.
|
||||
Message::NewSong => {
|
||||
// We've recieved `NewSong`, so on the next loop iteration we'll
|
||||
// begin waiting for the song to be over in order to autoplay.
|
||||
new = true;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris
|
||||
.changed(vec![
|
||||
Property::Metadata(mpris.player().metadata().await?),
|
||||
Property::PlaybackStatus(mpris.player().playback_status().await?),
|
||||
])
|
||||
.await?;
|
||||
|
||||
continue;
|
||||
}
|
||||
Message::Bookmark => {
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref().unwrap();
|
||||
|
||||
player.bookmarks.bookmark(current).await?;
|
||||
}
|
||||
Message::Quit => break,
|
||||
}
|
||||
}
|
||||
|
||||
downloader.abort();
|
||||
let bookmarked = self.bookmarks.bookmarked(track);
|
||||
self.update(ui::Update::Bookmarked(bookmarked))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a `ui::Update` to the broadcast channel.
|
||||
pub fn update(&mut self, update: ui::Update) -> crate::Result<()> {
|
||||
self.broadcast.send(update)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if args.paused {
|
||||
tx.send(Message::Pause).await?;
|
||||
}
|
||||
|
||||
tx.send(Message::Init).await?;
|
||||
|
||||
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?;
|
||||
sink.set_volume(volume.float());
|
||||
|
||||
Ok(Self {
|
||||
ui: ui::Handle::init(tx.clone(), urx, state, &args).await?,
|
||||
downloader: Downloader::init(
|
||||
args.buffer_size as usize,
|
||||
args.timeout,
|
||||
list,
|
||||
tx.clone(),
|
||||
)?,
|
||||
waiter: waiter::Handle::new(Arc::clone(&sink), tx),
|
||||
bookmarks: Bookmarks::load().await?,
|
||||
current: Current::default(),
|
||||
broadcast: utx,
|
||||
rx,
|
||||
sink,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
|
||||
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);
|
||||
self.set_current(Current::Track(decoded.info))?;
|
||||
self.waiter.notify();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drives the main message loop of the player.
|
||||
///
|
||||
/// This 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 {
|
||||
Message::Next | Message::Init | Message::Loaded => {
|
||||
if message == Message::Next && self.current.loading() {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.sink.stop();
|
||||
match self.downloader.track() {
|
||||
download::Output::Loading(progress) => {
|
||||
self.set_current(Current::Loading(progress))?;
|
||||
}
|
||||
download::Output::Queued(queued) => self.play(queued)?,
|
||||
}
|
||||
}
|
||||
Message::Play => {
|
||||
self.sink.play();
|
||||
}
|
||||
Message::Pause => {
|
||||
self.sink.pause();
|
||||
}
|
||||
Message::PlayPause => {
|
||||
if self.sink.is_paused() {
|
||||
self.sink.play();
|
||||
} else {
|
||||
self.sink.pause();
|
||||
}
|
||||
}
|
||||
Message::ChangeVolume(change) => {
|
||||
self.sink
|
||||
.set_volume((self.sink.volume() + change).clamp(0.0, 1.0));
|
||||
self.update(ui::Update::Volume)?;
|
||||
}
|
||||
Message::SetVolume(set) => {
|
||||
self.sink.set_volume(set.clamp(0.0, 1.0));
|
||||
self.update(ui::Update::Volume)?;
|
||||
}
|
||||
Message::Bookmark => {
|
||||
let Current::Track(current) = &self.current else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let bookmarked = self.bookmarks.bookmark(current)?;
|
||||
self.update(ui::Update::Bookmarked(bookmarked))?;
|
||||
}
|
||||
Message::Quit => break,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
match message {
|
||||
Message::ChangeVolume(_) | Message::SetVolume(_) => {
|
||||
self.ui.mpris.update_volume().await?
|
||||
}
|
||||
Message::Play | Message::Pause | Message::PlayPause => {
|
||||
self.ui.mpris.update_playback().await?
|
||||
}
|
||||
Message::Init | Message::Loaded | Message::Next => {
|
||||
self.ui.mpris.update_metadata().await?
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
self.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
//! Module for handling saving, loading, and adding
|
||||
//! bookmarks.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::{fs, io};
|
||||
|
||||
use crate::{data_dir, tracks};
|
||||
|
||||
/// Errors that might occur while managing bookmarks.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BookmarkError {
|
||||
#[error("data directory not found")]
|
||||
DataDir,
|
||||
|
||||
#[error("io failure")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Manages the bookmarks in the current player.
|
||||
pub struct Bookmarks {
|
||||
/// The different entries in the bookmarks file.
|
||||
entries: RwLock<Vec<String>>,
|
||||
|
||||
/// The internal bookmarked register, which keeps track
|
||||
/// of whether a track is bookmarked or not.
|
||||
///
|
||||
/// This is much more efficient than checking every single frame.
|
||||
bookmarked: AtomicBool,
|
||||
}
|
||||
|
||||
impl Bookmarks {
|
||||
/// Gets the path of the bookmarks file.
|
||||
pub async fn path() -> eyre::Result<PathBuf, BookmarkError> {
|
||||
let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?;
|
||||
fs::create_dir_all(data_dir.clone()).await?;
|
||||
|
||||
Ok(data_dir.join("bookmarks.txt"))
|
||||
}
|
||||
|
||||
/// Loads bookmarks from the `bookmarks.txt` file.
|
||||
pub async fn load() -> eyre::Result<Self, BookmarkError> {
|
||||
let text = fs::read_to_string(Self::path().await?)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let lines: Vec<String> = text
|
||||
.trim_start_matches("noheader")
|
||||
.trim()
|
||||
.lines()
|
||||
.filter_map(|x| {
|
||||
if x.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(x.to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
entries: RwLock::new(lines),
|
||||
bookmarked: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
// Saves the bookmarks to the `bookmarks.txt` file.
|
||||
pub async fn save(&self) -> eyre::Result<(), BookmarkError> {
|
||||
let text = format!("noheader\n{}", self.entries.read().await.join("\n"));
|
||||
fs::write(Self::path().await?, text).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bookmarks a given track with a full path and optional custom name.
|
||||
///
|
||||
/// Returns whether the track is now bookmarked, or not.
|
||||
pub async fn bookmark(&self, track: &tracks::Info) -> eyre::Result<(), BookmarkError> {
|
||||
let entry = track.to_entry();
|
||||
let idx = self.entries.read().await.iter().position(|x| **x == entry);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
self.entries.write().await.remove(idx);
|
||||
} else {
|
||||
self.entries.write().await.push(entry);
|
||||
};
|
||||
|
||||
self.bookmarked
|
||||
.swap(idx.is_none(), std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether a track is bookmarked or not by using the internal
|
||||
/// bookmarked register.
|
||||
pub fn bookmarked(&self) -> bool {
|
||||
self.bookmarked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Sets the internal bookmarked register by checking against
|
||||
/// the current track's info.
|
||||
pub async fn set_bookmarked(&self, track: &tracks::Info) {
|
||||
let val = self.entries.read().await.contains(&track.to_entry());
|
||||
self.bookmarked
|
||||
.swap(val, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
//! Contains the [`Downloader`] struct.
|
||||
|
||||
use std::{error::Error, sync::Arc};
|
||||
|
||||
use tokio::{
|
||||
sync::mpsc::{self, Receiver, Sender},
|
||||
task::{self, JoinHandle},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use super::Player;
|
||||
|
||||
/// This struct is responsible for downloading tracks in the background.
|
||||
///
|
||||
/// This is not used for the first track or a track when the buffer is currently empty.
|
||||
pub struct Downloader {
|
||||
/// The player for the downloader to download to & with.
|
||||
player: Arc<Player>,
|
||||
|
||||
/// The internal reciever, which is used by the downloader to know
|
||||
/// when to begin downloading more tracks.
|
||||
rx: Receiver<()>,
|
||||
|
||||
/// A copy of the internal sender, which can be useful for keeping
|
||||
/// track of it.
|
||||
tx: Sender<()>,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
/// Uses a sender recieved from [Sender] to notify the
|
||||
/// download thread that it should resume downloading.
|
||||
pub async fn notify(sender: &Sender<()>) -> Result<(), mpsc::error::SendError<()>> {
|
||||
sender.send(()).await
|
||||
}
|
||||
|
||||
/// Initializes the [Downloader].
|
||||
///
|
||||
/// This also sends a [`Sender`] which can be used to notify
|
||||
/// when the downloader needs to begin downloading more tracks.
|
||||
pub fn new(player: Arc<Player>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
Self { player, rx, tx }
|
||||
}
|
||||
|
||||
/// Push a new, random track onto the internal buffer.
|
||||
pub async fn push_buffer(&self, debug: bool) {
|
||||
let data = self.player.list.random(&self.player.client, None).await;
|
||||
match data {
|
||||
Ok(track) => self.player.tracks.write().await.push_back(track),
|
||||
Err(error) => {
|
||||
if debug {
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
if !error.is_timeout() {
|
||||
sleep(self.player.timeout).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually starts & consumes the [Downloader].
|
||||
pub fn start(mut self, debug: bool) -> (Sender<()>, JoinHandle<()>) {
|
||||
let tx = self.tx.clone();
|
||||
|
||||
let handle = task::spawn(async move {
|
||||
// Loop through each update notification.
|
||||
while self.rx.recv().await == Some(()) {
|
||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
||||
while self.player.tracks.read().await.len() < self.player.buffer_size {
|
||||
self.push_buffer(debug).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
use std::ffi::NulError;
|
||||
|
||||
use crate::{messages::Message, player::bookmark::BookmarkError};
|
||||
use tokio::sync::mpsc::error::SendError;
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
use mpris_server::zbus::{self, fdo};
|
||||
|
||||
/// Any errors which might occur when running or initializing the lowfi player.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("unable to load the persistent volume")]
|
||||
PersistentVolumeLoad(eyre::Error),
|
||||
|
||||
#[error("unable to save the persistent volume")]
|
||||
PersistentVolumeSave(eyre::Error),
|
||||
|
||||
#[error("sending internal message failed")]
|
||||
Communication(#[from] SendError<Message>),
|
||||
|
||||
#[error("unable to load track list")]
|
||||
TrackListLoad(eyre::Error),
|
||||
|
||||
#[error("interfacing with audio failed")]
|
||||
Stream(#[from] rodio::StreamError),
|
||||
|
||||
#[error("NUL error, if you see this, something has gone VERY wrong")]
|
||||
Nul(#[from] NulError),
|
||||
|
||||
#[error("unable to send or prepare network request")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris bus error")]
|
||||
ZBus(#[from] zbus::Error),
|
||||
|
||||
// TODO: This has a terrible error message, mainly because I barely understand
|
||||
// what this error even represents. What does fdo mean?!?!? Why, MPRIS!?!?
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris fdo (zbus interface) error")]
|
||||
Fdo(#[from] fdo::Error),
|
||||
|
||||
#[error("unable to notify downloader")]
|
||||
DownloaderNotify(#[from] SendError<()>),
|
||||
|
||||
#[error("unable to find data directory")]
|
||||
DataDir,
|
||||
|
||||
#[error("bookmarking load/unload failed")]
|
||||
Bookmark(#[from] BookmarkError),
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
use eyre::eyre;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// This is the representation of the persistent volume,
|
||||
/// which is loaded at startup and saved on shutdown.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PersistentVolume {
|
||||
/// The volume, as a percentage.
|
||||
inner: u16,
|
||||
}
|
||||
|
||||
impl PersistentVolume {
|
||||
/// Retrieves the config directory.
|
||||
async fn config() -> eyre::Result<PathBuf> {
|
||||
let config = dirs::config_dir()
|
||||
.ok_or_else(|| eyre!("Couldn't find config directory"))?
|
||||
.join(PathBuf::from("lowfi"));
|
||||
|
||||
if !config.exists() {
|
||||
fs::create_dir_all(&config).await?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Returns the volume as a float from 0 to 1.
|
||||
pub fn float(self) -> f32 {
|
||||
f32::from(self.inner) / 100.0
|
||||
}
|
||||
|
||||
/// Loads the [`PersistentVolume`] from [`dirs::config_dir()`].
|
||||
pub async fn load() -> eyre::Result<Self> {
|
||||
let config = Self::config().await?;
|
||||
let volume = config.join(PathBuf::from("volume.txt"));
|
||||
|
||||
// Basically just read from the volume file if it exists, otherwise return 100.
|
||||
let volume = if volume.exists() {
|
||||
let contents = fs::read_to_string(volume).await?;
|
||||
let trimmed = contents.trim();
|
||||
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
|
||||
stripped
|
||||
.parse()
|
||||
.map_err(|_error| eyre!("volume.txt file is invalid"))?
|
||||
} else {
|
||||
fs::write(&volume, "100").await?;
|
||||
100u16
|
||||
};
|
||||
|
||||
Ok(Self { inner: volume })
|
||||
}
|
||||
|
||||
/// Saves `volume` to `volume.txt`.
|
||||
pub async fn save(volume: f32) -> eyre::Result<()> {
|
||||
let config = Self::config().await?;
|
||||
let path = config.join(PathBuf::from("volume.txt"));
|
||||
|
||||
// Already rounded & absolute, therefore this should be safe.
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation
|
||||
)]
|
||||
let percentage = (volume * 100.0).abs().round() as u16;
|
||||
|
||||
fs::write(path, percentage.to_string()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
use tokio::{sync::mpsc::Sender, time::sleep};
|
||||
|
||||
use crate::{
|
||||
messages::Message,
|
||||
player::{downloader::Downloader, Player},
|
||||
tracks,
|
||||
};
|
||||
|
||||
impl Player {
|
||||
/// Fetches the next track from the queue, or a random track if the queue is empty.
|
||||
/// This will also set the current track to the fetched track's info.
|
||||
async fn fetch(&self) -> Result<tracks::DecodedTrack, tracks::Error> {
|
||||
// TODO: Consider replacing this with `unwrap_or_else` when async closures are stablized.
|
||||
let track = self.tracks.write().await.pop_front();
|
||||
let track = if let Some(track) = track {
|
||||
track
|
||||
} else {
|
||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
||||
// This is relevant particularly at the first song.
|
||||
|
||||
// Serves as an indicator that the queue is "loading".
|
||||
// We're doing it here so that we don't get the "loading" display
|
||||
// for only a frame in the other case that the buffer is not empty.
|
||||
self.current.store(None);
|
||||
self.progress.store(0.0, Ordering::Relaxed);
|
||||
self.list.random(&self.client, Some(&self.progress)).await?
|
||||
};
|
||||
|
||||
let decoded = track.decode()?;
|
||||
|
||||
// Set the current track.
|
||||
self.set_current(decoded.info.clone());
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Gets, decodes, and plays the next track in the queue while also handling the downloader.
|
||||
///
|
||||
/// This functions purpose is to be called in the background, so that when the audio server recieves a
|
||||
/// `Next` signal it will still be able to respond to other signals while it's loading.
|
||||
///
|
||||
/// This also sends the either a `NewSong` or `TryAgain` signal to `tx`.
|
||||
pub async fn next(
|
||||
player: Arc<Self>,
|
||||
itx: Sender<()>,
|
||||
tx: Sender<Message>,
|
||||
debug: bool,
|
||||
) -> eyre::Result<()> {
|
||||
// Stop the sink.
|
||||
player.sink.stop();
|
||||
|
||||
let track = player.fetch().await;
|
||||
|
||||
match track {
|
||||
Ok(track) => {
|
||||
// Start playing the new track.
|
||||
player.sink.append(track.data);
|
||||
|
||||
// Set whether it's bookmarked.
|
||||
player.bookmarks.set_bookmarked(&track.info).await;
|
||||
|
||||
// Notify the background downloader that there's an empty spot
|
||||
// in the buffer.
|
||||
Downloader::notify(&itx).await?;
|
||||
|
||||
// Notify the audio server that the next song has actually been downloaded.
|
||||
tx.send(Message::NewSong).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
if debug {
|
||||
panic!("{error} - {:?}", error.source())
|
||||
}
|
||||
|
||||
if !error.is_timeout() {
|
||||
sleep(player.timeout).await;
|
||||
}
|
||||
|
||||
tx.send(Message::TryAgain).await?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
307
src/player/ui.rs
307
src/player/ui.rs
@ -1,307 +0,0 @@
|
||||
//! The module which manages all user interface, including inputs.
|
||||
|
||||
#![allow(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "the ui is full of these because of various layout & positioning aspects, and for a simple music player making all casts safe is not worth the effort"
|
||||
)]
|
||||
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
io::{stdout, Stdout},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::Args;
|
||||
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show},
|
||||
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
||||
style::{Print, Stylize as _},
|
||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use thiserror::Error;
|
||||
use tokio::{sync::mpsc::Sender, task, time::sleep};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::Player;
|
||||
use crate::messages::Message;
|
||||
|
||||
mod components;
|
||||
mod input;
|
||||
|
||||
/// The error type for the UI, which is used to handle errors that occur
|
||||
/// while drawing the UI or handling input.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UIError {
|
||||
#[error("unable to convert number")]
|
||||
Conversion(#[from] std::num::TryFromIntError),
|
||||
|
||||
#[error("unable to write output")]
|
||||
Write(#[from] std::io::Error),
|
||||
|
||||
#[error("sending message to backend from ui failed")]
|
||||
Communication(#[from] tokio::sync::mpsc::error::SendError<Message>),
|
||||
}
|
||||
|
||||
/// How long the audio bar will be visible for when audio is adjusted.
|
||||
/// This is in frames.
|
||||
const AUDIO_BAR_DURATION: usize = 10;
|
||||
|
||||
lazy_static! {
|
||||
/// The volume timer, which controls how long the volume display should
|
||||
/// show up and when it should disappear.
|
||||
///
|
||||
/// When this is 0, it means that the audio bar shouldn't be displayed.
|
||||
/// To make it start counting, you need to set it to 1.
|
||||
static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0);
|
||||
}
|
||||
|
||||
/// Sets the volume timer to one, effectively flashing the audio display in lowfi's UI.
|
||||
///
|
||||
/// The amount of frames the audio display is visible for is determined by [`AUDIO_BAR_DURATION`].
|
||||
pub fn flash_audio() {
|
||||
VOLUME_TIMER.store(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Represents an abstraction for drawing the actual lowfi window itself.
|
||||
///
|
||||
/// The main purpose of this struct is just to add the fancy border,
|
||||
/// as well as clear the screen before drawing.
|
||||
pub struct Window {
|
||||
/// Whether or not to include borders in the output.
|
||||
borderless: bool,
|
||||
|
||||
/// The top & bottom borders, which are here since they can be
|
||||
/// prerendered, as they don't change from window to window.
|
||||
///
|
||||
/// If the option to not include borders is set, these will just be empty [String]s.
|
||||
borders: [String; 2],
|
||||
|
||||
/// The width of the window.
|
||||
width: usize,
|
||||
|
||||
/// The output, currently just an [`Stdout`].
|
||||
out: Stdout,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Initializes a new [Window].
|
||||
///
|
||||
/// * `width` - Width of the windows.
|
||||
/// * `borderless` - Whether to include borders in the window, or not.
|
||||
pub fn new(width: usize, borderless: bool) -> Self {
|
||||
let borders = if borderless {
|
||||
[String::new(), String::new()]
|
||||
} else {
|
||||
let middle = "─".repeat(width + 2);
|
||||
|
||||
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
||||
};
|
||||
|
||||
Self {
|
||||
borders,
|
||||
borderless,
|
||||
width,
|
||||
out: stdout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually draws the window, with each element in `content` being on a new line.
|
||||
pub fn draw(&mut self, content: Vec<String>, space: bool) -> eyre::Result<(), UIError> {
|
||||
let len: u16 = content.len().try_into()?;
|
||||
|
||||
// Note that this will have a trailing newline, which we use later.
|
||||
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
||||
// Horizontal Padding & Border
|
||||
let padding = if self.borderless { " " } else { "│" };
|
||||
let space = if space {
|
||||
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
write!(output, "{padding} {}{space} {padding}\r\n", x.reset()).unwrap();
|
||||
|
||||
output
|
||||
});
|
||||
|
||||
// We're doing this because Windows is stupid and can't stand
|
||||
// writing to the last line repeatedly.
|
||||
#[cfg(windows)]
|
||||
let (height, suffix) = (len + 2, "\r\n");
|
||||
#[cfg(not(windows))]
|
||||
let (height, suffix) = (len + 1, "");
|
||||
|
||||
// There's no need for another newline after the main menu content, because it already has one.
|
||||
let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]);
|
||||
|
||||
crossterm::execute!(
|
||||
self.out,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
MoveToColumn(0),
|
||||
Print(rendered),
|
||||
MoveToColumn(0),
|
||||
MoveUp(height),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The code for the terminal interface itself.
|
||||
///
|
||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||
async fn interface(
|
||||
player: Arc<Player>,
|
||||
minimalist: bool,
|
||||
borderless: bool,
|
||||
debug: bool,
|
||||
fps: u8,
|
||||
width: usize,
|
||||
) -> eyre::Result<(), UIError> {
|
||||
let mut window = Window::new(width, borderless || debug);
|
||||
|
||||
loop {
|
||||
// Load `current` once so that it doesn't have to be loaded over and over
|
||||
// again by different UI components.
|
||||
let current = player.current.load();
|
||||
let current = current.as_ref();
|
||||
|
||||
let action = components::action(&player, current, width);
|
||||
|
||||
let volume = player.sink.volume();
|
||||
let percentage = format!("{}%", (volume * 100.0).round().abs());
|
||||
|
||||
let timer = VOLUME_TIMER.load(Ordering::Relaxed);
|
||||
let middle = match timer {
|
||||
0 => components::progress_bar(&player, current, width - 16),
|
||||
_ => components::audio_bar(volume, &percentage, width - 17),
|
||||
};
|
||||
|
||||
if timer > 0 && timer <= AUDIO_BAR_DURATION {
|
||||
// We'll keep increasing the timer until it eventually hits `AUDIO_BAR_DURATION`.
|
||||
VOLUME_TIMER.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
// If enough time has passed, we'll reset it back to 0.
|
||||
VOLUME_TIMER.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let controls = components::controls(width);
|
||||
|
||||
let menu = match (minimalist, debug, player.current.load().as_ref()) {
|
||||
(true, _, _) => vec![action, middle],
|
||||
(false, true, Some(x)) => vec![x.full_path.clone(), action, middle, controls],
|
||||
_ => vec![action, middle, controls],
|
||||
};
|
||||
|
||||
window.draw(menu, false)?;
|
||||
|
||||
let delta = 1.0 / f32::from(fps);
|
||||
sleep(Duration::from_secs_f32(delta)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the terminal environment, and is used to properly
|
||||
/// initialize and clean up the terminal.
|
||||
pub struct Environment {
|
||||
/// Whether keyboard enhancements are enabled.
|
||||
enhancement: bool,
|
||||
|
||||
/// Whether the terminal is in an alternate screen or not.
|
||||
alternate: bool,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// This prepares the terminal, returning an [Environment] helpful
|
||||
/// for cleaning up afterwards.
|
||||
pub fn ready(alternate: bool) -> eyre::Result<Self, UIError> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
crossterm::execute!(lock, Hide)?;
|
||||
|
||||
if alternate {
|
||||
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
|
||||
}
|
||||
|
||||
terminal::enable_raw_mode()?;
|
||||
let enhancement = terminal::supports_keyboard_enhancement()?;
|
||||
|
||||
if enhancement {
|
||||
crossterm::execute!(
|
||||
lock,
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enhancement,
|
||||
alternate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Uses the information collected from initialization to safely close down
|
||||
/// the terminal & restore it to it's previous state.
|
||||
pub fn cleanup(&self) -> eyre::Result<(), UIError> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
if self.alternate {
|
||||
crossterm::execute!(lock, LeaveAlternateScreen)?;
|
||||
}
|
||||
|
||||
crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?;
|
||||
|
||||
if self.enhancement {
|
||||
crossterm::execute!(lock, PopKeyboardEnhancementFlags)?;
|
||||
}
|
||||
|
||||
terminal::disable_raw_mode()?;
|
||||
|
||||
eprintln!("bye! :)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Environment {
|
||||
/// Just a wrapper for [`Environment::cleanup`] which ignores any errors thrown.
|
||||
fn drop(&mut self) {
|
||||
// Well, we're dropping it, so it doesn't really matter if there's an error.
|
||||
let _ = self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the UI, this will also start taking input from the user.
|
||||
///
|
||||
/// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide
|
||||
/// previous terminal history.
|
||||
pub async fn start(
|
||||
player: Arc<Player>,
|
||||
sender: Sender<Message>,
|
||||
args: Args,
|
||||
) -> eyre::Result<(), UIError> {
|
||||
let environment = Environment::ready(args.alternate)?;
|
||||
|
||||
let interface = task::spawn(interface(
|
||||
Arc::clone(&player),
|
||||
args.minimalist,
|
||||
args.borderless,
|
||||
args.debug,
|
||||
args.fps,
|
||||
21 + args.width.min(32) * 2,
|
||||
));
|
||||
|
||||
input::listen(sender.clone()).await?;
|
||||
interface.abort();
|
||||
|
||||
environment.cleanup()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -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?;
|
||||
|
||||
5
src/tests.rs
Normal file
5
src/tests.rs
Normal file
@ -0,0 +1,5 @@
|
||||
#![allow(clippy::all, clippy::missing_docs_in_private_items)]
|
||||
|
||||
mod bookmark;
|
||||
mod tracks;
|
||||
mod ui;
|
||||
58
src/tests/bookmark.rs
Normal file
58
src/tests/bookmark.rs
Normal file
@ -0,0 +1,58 @@
|
||||
#[cfg(test)]
|
||||
mod bookmark {
|
||||
use crate::{bookmark::Bookmarks, tracks::Info};
|
||||
|
||||
fn test_info(path: &str, display: &str) -> Info {
|
||||
Info {
|
||||
path: path.into(),
|
||||
display: display.into(),
|
||||
width: display.len(),
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_and_check() {
|
||||
let mut bm = Bookmarks { entries: vec![] };
|
||||
let info = test_info("p.mp3", "Nice Track");
|
||||
|
||||
// initially not bookmarked
|
||||
assert!(!bm.bookmarked(&info));
|
||||
|
||||
// bookmark it
|
||||
let added = bm.bookmark(&info).unwrap();
|
||||
assert!(added);
|
||||
assert!(bm.bookmarked(&info));
|
||||
|
||||
// un-bookmark it
|
||||
let removed = bm.bookmark(&info).unwrap();
|
||||
assert!(!removed);
|
||||
assert!(!bm.bookmarked(&info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_bookmarks() {
|
||||
let mut bm = Bookmarks { entries: vec![] };
|
||||
let info1 = test_info("track1.mp3", "Track One");
|
||||
let info2 = test_info("track2.mp3", "Track Two");
|
||||
|
||||
bm.bookmark(&info1).unwrap();
|
||||
bm.bookmark(&info2).unwrap();
|
||||
|
||||
assert!(bm.bookmarked(&info1));
|
||||
assert!(bm.bookmarked(&info2));
|
||||
assert_eq!(bm.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_bookmark_removes() {
|
||||
let mut bm = Bookmarks { entries: vec![] };
|
||||
let info = test_info("x.mp3", "X");
|
||||
|
||||
bm.bookmark(&info).unwrap();
|
||||
let is_added = bm.bookmark(&info).unwrap();
|
||||
|
||||
assert!(!is_added);
|
||||
assert!(bm.entries.is_empty());
|
||||
}
|
||||
}
|
||||
182
src/tests/tracks.rs
Normal file
182
src/tests/tracks.rs
Normal file
@ -0,0 +1,182 @@
|
||||
#[cfg(test)]
|
||||
mod format {
|
||||
use crate::tracks::format::name;
|
||||
|
||||
#[test]
|
||||
fn handles_all_numeric_name() {
|
||||
let n = name("12345.mp3").unwrap();
|
||||
assert_eq!(n, "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_url() {
|
||||
let n = name("lofi%20track.mp3").unwrap();
|
||||
assert_eq!(n, "lofi track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_extension_only() {
|
||||
let n = name(".mp3").unwrap();
|
||||
// Should handle edge case gracefully
|
||||
assert!(!n.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod queued {
|
||||
use crate::tracks::{format, Queued};
|
||||
use bytes::Bytes;
|
||||
|
||||
#[test]
|
||||
fn queued_uses_custom_display() {
|
||||
let q = Queued::new(
|
||||
"path/to/file.mp3".into(),
|
||||
Bytes::from_static(b"abc"),
|
||||
Some("Shown".into()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(q.display, "Shown");
|
||||
assert_eq!(q.path, "path/to/file.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_generates_display_if_none() {
|
||||
let q = Queued::new(
|
||||
"path/to/cool_track.mp3".into(),
|
||||
Bytes::from_static(b"abc"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(q.display, format::name("path/to/cool_track.mp3").unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod info {
|
||||
use crate::tracks::Info;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[test]
|
||||
fn to_entry_roundtrip() {
|
||||
let info = Info {
|
||||
path: "p.mp3".into(),
|
||||
display: "Nice Track".into(),
|
||||
width: 10,
|
||||
duration: None,
|
||||
};
|
||||
|
||||
assert_eq!(info.to_entry(), "p.mp3!Nice Track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_counts_graphemes() {
|
||||
// We cannot create a valid decoder for arbitrary bytes here, so test width through constructor logic directly.
|
||||
let display = "a̐é"; // multiple-grapheme clusters
|
||||
let width = display.graphemes(true).count();
|
||||
|
||||
let info = Info {
|
||||
path: "x".into(),
|
||||
display: display.into(),
|
||||
width,
|
||||
duration: None,
|
||||
};
|
||||
|
||||
assert_eq!(info.width, width);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod decoded {
|
||||
use crate::tracks::Queued;
|
||||
use bytes::Bytes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoded_fails_with_invalid_audio() {
|
||||
let q = Queued::new(
|
||||
"path.mp3".into(),
|
||||
Bytes::from_static(b"not audio"),
|
||||
Some("Name".into()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = q.decode();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod list {
|
||||
use crate::{download::PROGRESS, tracks::List};
|
||||
use reqwest::Client;
|
||||
|
||||
#[test]
|
||||
fn base_works() {
|
||||
let text = "http://base/\ntrack1\ntrack2";
|
||||
let list = List::new("test", text, None);
|
||||
assert_eq!(list.header(), "http://base/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_path_parses_custom_display() {
|
||||
let text = "http://x/\npath!Display";
|
||||
let list = List::new("t", text, None);
|
||||
|
||||
let (p, d) = list.random_path();
|
||||
assert_eq!(p, "path");
|
||||
assert_eq!(d, Some("Display".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_path_no_display() {
|
||||
let text = "http://x/\ntrackA";
|
||||
let list = List::new("t", text, None);
|
||||
|
||||
let (p, d) = list.random_path();
|
||||
assert_eq!(p, "trackA");
|
||||
assert!(d.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_trims_lines() {
|
||||
let text = "base\na \nb ";
|
||||
let list = List::new("name", text, None);
|
||||
|
||||
assert_eq!(list.header(), "base");
|
||||
assert_eq!(list.lines[1], "a");
|
||||
assert_eq!(list.lines[2], "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_display_with_exclamation() {
|
||||
let text = "http://base/\nfile.mp3!My Custom Name";
|
||||
let list = List::new("t", text, None);
|
||||
let (path, display) = list.random_path();
|
||||
assert_eq!(path, "file.mp3");
|
||||
assert_eq!(display, Some("My Custom Name".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_track() {
|
||||
let text = "base\nonly_track.mp3";
|
||||
let list = List::new("name", text, None);
|
||||
let (path, _) = list.random_path();
|
||||
assert_eq!(path, "only_track.mp3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download() {
|
||||
let text = "https://stream.chillhop.com/mp3/\n9476!Apple Juice";
|
||||
let list = List::new("name", text, None);
|
||||
|
||||
let client = Client::new();
|
||||
let track = list.random(&client, &PROGRESS).await.unwrap();
|
||||
assert_eq!(track.display, "Apple Juice");
|
||||
assert_eq!(track.path, "https://stream.chillhop.com/mp3/9476");
|
||||
assert_eq!(track.data.len(), 3150424);
|
||||
|
||||
let decoded = track.decode().unwrap();
|
||||
assert_eq!(decoded.info.duration.unwrap().as_secs(), 143);
|
||||
}
|
||||
}
|
||||
251
src/tests/ui.rs
Normal file
251
src/tests/ui.rs
Normal file
@ -0,0 +1,251 @@
|
||||
/* The lowfi UI:
|
||||
┌─────────────────────────────┐
|
||||
│ loading │
|
||||
│ [ ] 00:00/00:00 │
|
||||
│ [s]kip [p]ause [q]uit │
|
||||
└─────────────────────────────┘
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod components {
|
||||
use crate::ui;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn format_duration_works() {
|
||||
let d = Duration::from_secs(62);
|
||||
assert_eq!(ui::components::format_duration(&d), "01:02");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_zero() {
|
||||
let d = Duration::from_secs(0);
|
||||
assert_eq!(ui::components::format_duration(&d), "00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_hours_wrap() {
|
||||
let d = Duration::from_secs(3661); // 1:01:01
|
||||
assert_eq!(ui::components::format_duration(&d), "61:01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_bar_contains_percentage() {
|
||||
let s = ui::components::audio_bar(10, 0.5, "50%");
|
||||
assert!(s.contains("50%"));
|
||||
assert!(s.starts_with(" volume:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_bar_muted_volume() {
|
||||
let s = ui::components::audio_bar(8, 0.0, "0%");
|
||||
assert!(s.contains("0%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_bar_full_volume() {
|
||||
let s = ui::components::audio_bar(10, 1.0, "100%");
|
||||
assert!(s.contains("100%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_has_items() {
|
||||
let s = ui::components::controls(30);
|
||||
assert!(s.contains("[s]"));
|
||||
assert!(s.contains("[p]"));
|
||||
assert!(s.contains("[q]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod window {
|
||||
use crate::ui::window::Window;
|
||||
|
||||
#[test]
|
||||
fn new_border_strings() {
|
||||
let w = Window::new(10, false);
|
||||
assert!(w.borders[0].starts_with('┌'));
|
||||
assert!(w.borders[1].starts_with('└'));
|
||||
|
||||
let w2 = Window::new(5, true);
|
||||
assert!(w2.borders[0].is_empty());
|
||||
assert!(w2.borders[1].is_empty());
|
||||
}
|
||||
|
||||
fn sided(text: &str) -> String {
|
||||
return format!("│ {text} │");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple() {
|
||||
let w = Window::new(3, false);
|
||||
let (render, height) = w.render(vec![String::from("abc")], false, true).unwrap();
|
||||
|
||||
const MIDDLE: &str = "─────";
|
||||
assert_eq!(format!("┌{MIDDLE}┐\n{}\n└{MIDDLE}┘", sided("abc")), render);
|
||||
assert_eq!(height, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaced() {
|
||||
let w = Window::new(3, false);
|
||||
let (render, height) = w
|
||||
.render(
|
||||
vec![String::from("abc"), String::from(" b"), String::from("c")],
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
const MIDDLE: &str = "─────";
|
||||
assert_eq!(
|
||||
format!(
|
||||
"┌{MIDDLE}┐\n{}\n{}\n{}\n└{MIDDLE}┘",
|
||||
sided("abc"),
|
||||
sided(" b "),
|
||||
sided("c "),
|
||||
),
|
||||
render
|
||||
);
|
||||
assert_eq!(height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_window() {
|
||||
let w = Window::new(0, false);
|
||||
assert!(!w.borders[0].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod interface {
|
||||
use crossterm::style::Stylize;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::{
|
||||
download::PROGRESS,
|
||||
player::Current,
|
||||
tracks,
|
||||
ui::{
|
||||
interface::{self, Params},
|
||||
State,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn loading() {
|
||||
let sink = Arc::new(rodio::Sink::new().0);
|
||||
let mut state = State::initial(sink, 3, String::from("test"));
|
||||
let menu = interface::menu(&mut state, Params::default());
|
||||
|
||||
assert_eq!(menu[0], "loading ");
|
||||
assert_eq!(menu[1], " [ ] 00:00/00:00 ");
|
||||
assert_eq!(
|
||||
menu[2],
|
||||
format!(
|
||||
"{}kip {}ause {}uit",
|
||||
"[s]".bold(),
|
||||
"[p]".bold(),
|
||||
"[q]".bold()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume() {
|
||||
let sink = Arc::new(rodio::Sink::new().0);
|
||||
sink.set_volume(0.5);
|
||||
let mut state = State::initial(sink, 3, String::from("test"));
|
||||
state.timer = Some(Instant::now());
|
||||
|
||||
let menu = interface::menu(&mut state, Params::default());
|
||||
|
||||
assert_eq!(menu[0], "loading ");
|
||||
assert_eq!(menu[1], " volume: [///// ] 50% ");
|
||||
assert_eq!(
|
||||
menu[2],
|
||||
format!(
|
||||
"{}kip {}ause {}uit",
|
||||
"[s]".bold(),
|
||||
"[p]".bold(),
|
||||
"[q]".bold()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress() {
|
||||
let sink = Arc::new(rodio::Sink::new().0);
|
||||
PROGRESS.store(50, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut state = State::initial(sink, 3, String::from("test"));
|
||||
state.current = Current::Loading(Some(&PROGRESS));
|
||||
|
||||
let menu = interface::menu(&mut state, Params::default());
|
||||
|
||||
assert_eq!(menu[0], format!("loading {} ", "50%".bold()));
|
||||
assert_eq!(menu[1], " [ ] 00:00/00:00 ");
|
||||
assert_eq!(
|
||||
menu[2],
|
||||
format!(
|
||||
"{}kip {}ause {}uit",
|
||||
"[s]".bold(),
|
||||
"[p]".bold(),
|
||||
"[q]".bold()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track() {
|
||||
let sink = Arc::new(rodio::Sink::new().0);
|
||||
let track = tracks::Info {
|
||||
path: "/path".to_owned(),
|
||||
display: "Test Track".to_owned(),
|
||||
width: 4 + 1 + 5,
|
||||
duration: Some(Duration::from_secs(8)),
|
||||
};
|
||||
|
||||
let mut state = State::initial(sink, 3, String::from("test"));
|
||||
state.current = Current::Track(track.clone());
|
||||
let menu = interface::menu(&mut state, Params::default());
|
||||
|
||||
assert_eq!(
|
||||
menu[0],
|
||||
format!("playing {} ", track.display.bold())
|
||||
);
|
||||
assert_eq!(menu[1], " [ ] 00:00/00:08 ");
|
||||
assert_eq!(
|
||||
menu[2],
|
||||
format!(
|
||||
"{}kip {}ause {}uit",
|
||||
"[s]".bold(),
|
||||
"[p]".bold(),
|
||||
"[q]".bold()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment {
|
||||
use crate::ui::Environment;
|
||||
|
||||
#[test]
|
||||
fn ready_and_cleanup_no_panic() {
|
||||
// Try to create the environment but don't fail the test if the
|
||||
// terminal isn't available. We just assert the API exists.
|
||||
if let Ok(env) = Environment::ready(false) {
|
||||
// cleanup should succeed
|
||||
let _ = env.cleanup(true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_with_alternate_screen() {
|
||||
if let Ok(env) = Environment::ready(true) {
|
||||
let _ = env.cleanup(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/tracks.rs
223
src/tracks.rs
@ -2,74 +2,76 @@
|
||||
//! of tracks, as well as downloading them & finding new ones.
|
||||
//!
|
||||
//! There are several structs which represent the different stages
|
||||
//! that go on in downloading and playing tracks. The proccess for fetching tracks,
|
||||
//! and what structs are relevant in each step, are as follows.
|
||||
//! that go on in downloading and playing tracks. When first queued,
|
||||
//! the downloader will return a [`Queued`] track.
|
||||
//!
|
||||
//! First Stage, when a track is initially fetched.
|
||||
//! 1. Raw entry selected from track list.
|
||||
//! 2. Raw entry split into path & display name.
|
||||
//! 3. Track data fetched, and [`QueuedTrack`] is created which includes a [`TrackName`] that may be raw.
|
||||
//!
|
||||
//! Second Stage, when a track is played.
|
||||
//! 1. Track data is decoded.
|
||||
//! 2. [`Info`] created from decoded data.
|
||||
//! 3. [`Decoded`] made from [`Info`] and the original decoded data.
|
||||
//! Then, when it's time to play the track, it is decoded into
|
||||
//! a [`Decoded`] track, which includes all the information
|
||||
//! in the form of [`Info`].
|
||||
|
||||
use std::{io::Cursor, path::Path, time::Duration};
|
||||
use std::{fmt::Debug, io::Cursor, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use convert_case::{Case, Casing};
|
||||
use regex::Regex;
|
||||
use rodio::{Decoder, Source as _};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use url::form_urlencoded;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
pub mod error;
|
||||
pub mod list;
|
||||
pub use list::List;
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub use error::{Error, Result};
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
use crate::tracks::error::Context;
|
||||
use lazy_static::lazy_static;
|
||||
use crate::tracks::error::WithTrackContext as _;
|
||||
|
||||
/// Just a shorthand for a decoded [Bytes].
|
||||
pub type DecodedData = Decoder<Cursor<Bytes>>;
|
||||
|
||||
/// Specifies a track's name, and specifically,
|
||||
/// whether it has already been formatted or if it
|
||||
/// is still in it's raw path form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrackName {
|
||||
/// Pulled straight from the list,
|
||||
/// with no splitting done at all.
|
||||
Raw(String),
|
||||
|
||||
/// If a track has a custom specified name
|
||||
/// in the list, then it should be defined with this variant.
|
||||
Formatted(String),
|
||||
}
|
||||
|
||||
/// Tracks which are still waiting in the queue, and can't be played yet.
|
||||
///
|
||||
/// This means that only the data & track name are included.
|
||||
pub struct QueuedTrack {
|
||||
/// Name of the track, which may be raw.
|
||||
pub name: TrackName,
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct Queued {
|
||||
/// Display name of the track.
|
||||
pub display: String,
|
||||
|
||||
/// Full downloadable path/url of the track.
|
||||
pub full_path: String,
|
||||
pub path: String,
|
||||
|
||||
/// The raw data of the track, which is not decoded and
|
||||
/// therefore much more memory efficient.
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
impl QueuedTrack {
|
||||
impl Debug for Queued {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Queued")
|
||||
.field("display", &self.display)
|
||||
.field("path", &self.path)
|
||||
.field("data", &self.data.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Queued {
|
||||
/// This will actually decode and format the track,
|
||||
/// returning a [`DecodedTrack`] which can be played
|
||||
/// and also has a duration & formatted name.
|
||||
pub fn decode(self) -> eyre::Result<DecodedTrack, Error> {
|
||||
DecodedTrack::new(self)
|
||||
pub fn decode(self) -> Result<Decoded> {
|
||||
Decoded::new(self)
|
||||
}
|
||||
|
||||
/// Creates a new queued track.
|
||||
pub fn new(path: String, data: Bytes, display: Option<String>) -> Result<Self> {
|
||||
let display = match display {
|
||||
None => self::format::name(&path)?,
|
||||
Some(custom) => custom,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
display,
|
||||
path,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,13 +82,10 @@ impl QueuedTrack {
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub struct Info {
|
||||
/// The full downloadable path/url of the track.
|
||||
pub full_path: String,
|
||||
|
||||
/// Whether the track entry included a custom name, or not.
|
||||
pub custom_name: bool,
|
||||
pub path: String,
|
||||
|
||||
/// This is a formatted name, so it doesn't include the full path.
|
||||
pub display_name: String,
|
||||
pub display: String,
|
||||
|
||||
/// This is the *actual* terminal width of the track name, used to make
|
||||
/// the UI consistent.
|
||||
@ -97,128 +96,30 @@ pub struct Info {
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref MASTER_PATTERNS: [Regex; 5] = [
|
||||
// (master), (master v2)
|
||||
Regex::new(r"\s*\(.*?master(?:\s*v?\d+)?\)$").unwrap(),
|
||||
// mstr or - mstr or (mstr) — now also matches "mstr v3", "mstr2", etc.
|
||||
Regex::new(r"\s*[-(]?\s*mstr(?:\s*v?\d+)?\s*\)?$").unwrap(),
|
||||
// - master, master at end without parentheses
|
||||
Regex::new(r"\s*[-]?\s*master(?:\s*v?\d+)?$").unwrap(),
|
||||
// kupla master1, kupla master v2 (without parentheses or separator)
|
||||
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();
|
||||
}
|
||||
|
||||
impl Info {
|
||||
/// Converts the info back into a full track list entry.
|
||||
pub fn to_entry(&self) -> String {
|
||||
let mut entry = self.full_path.clone();
|
||||
|
||||
if self.custom_name {
|
||||
entry.push('!');
|
||||
entry.push_str(&self.display_name);
|
||||
}
|
||||
let mut entry = self.path.clone();
|
||||
entry.push('!');
|
||||
entry.push_str(&self.display);
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
/// Decodes a URL string into normal UTF-8.
|
||||
fn decode_url(text: &str) -> String {
|
||||
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
||||
#[allow(clippy::tuple_array_conversions)]
|
||||
form_urlencoded::parse(text.as_bytes())
|
||||
.map(|(key, val)| [key, val].concat())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Formats a name with [`convert_case`].
|
||||
///
|
||||
/// This will also strip the first few numbers that are
|
||||
/// usually present on most lofi tracks and do some other
|
||||
/// formatting operations.
|
||||
fn format_name(name: &str) -> eyre::Result<String, Error> {
|
||||
let path = Path::new(name);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or((name, error::Kind::InvalidName))?;
|
||||
|
||||
let name = Self::decode_url(name).to_lowercase();
|
||||
let mut name = name
|
||||
.replace("masster", "master")
|
||||
.replace("(online-audio-converter.com)", "") // Some of these names, man...
|
||||
.replace('_', " ");
|
||||
|
||||
// Get rid of "master" suffix with a few regex patterns.
|
||||
for regex in MASTER_PATTERNS.iter() {
|
||||
name = regex.replace(&name, "").to_string();
|
||||
}
|
||||
|
||||
name = ID_PATTERN.replace(&name, "").to_string();
|
||||
|
||||
let name = name
|
||||
.replace("13lufs", "")
|
||||
.to_case(Case::Title)
|
||||
.replace(" .", "")
|
||||
.replace(" Ft ", " ft. ")
|
||||
.replace("Ft.", "ft.")
|
||||
.replace("Feat.", "ft.")
|
||||
.replace(" W ", " w/ ");
|
||||
|
||||
// This is incremented for each digit in front of the song name.
|
||||
let mut skip = 0;
|
||||
|
||||
for character in name.as_bytes() {
|
||||
if character.is_ascii_digit()
|
||||
|| *character == b'.'
|
||||
|| *character == b')'
|
||||
|| *character == b'('
|
||||
{
|
||||
skip += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the entire name of the track is a number, then just return it.
|
||||
if skip == name.len() {
|
||||
Ok(name.trim().to_string())
|
||||
} else {
|
||||
// We've already checked before that the bound is at an ASCII digit.
|
||||
#[allow(clippy::string_slice)]
|
||||
Ok(String::from(name[skip..].trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
|
||||
pub fn new(
|
||||
name: TrackName,
|
||||
full_path: String,
|
||||
decoded: &DecodedData,
|
||||
) -> eyre::Result<Self, Error> {
|
||||
let (display_name, custom_name) = match name {
|
||||
TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
|
||||
TrackName::Formatted(custom) => (custom, true),
|
||||
};
|
||||
|
||||
/// Creates a new [`Info`] from decoded data & the queued track.
|
||||
pub fn new(decoded: &DecodedData, path: String, display: String) -> Result<Self> {
|
||||
Ok(Self {
|
||||
duration: decoded.total_duration(),
|
||||
width: display_name.graphemes(true).count(),
|
||||
full_path,
|
||||
custom_name,
|
||||
display_name,
|
||||
width: display.graphemes(true).count(),
|
||||
path,
|
||||
display,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// This struct is seperate from [Track] since it is generated lazily from
|
||||
/// This struct is separate from [Track] since it is generated lazily from
|
||||
/// a track, and not when the track is first downloaded.
|
||||
pub struct DecodedTrack {
|
||||
pub struct Decoded {
|
||||
/// Has both the formatted name and some information from the decoded data.
|
||||
pub info: Info,
|
||||
|
||||
@ -226,18 +127,18 @@ pub struct DecodedTrack {
|
||||
pub data: DecodedData,
|
||||
}
|
||||
|
||||
impl DecodedTrack {
|
||||
impl Decoded {
|
||||
/// Creates a new track.
|
||||
/// This is equivalent to [`QueuedTrack::decode`].
|
||||
pub fn new(track: QueuedTrack) -> eyre::Result<Self, Error> {
|
||||
pub fn new(track: Queued) -> Result<Self> {
|
||||
let (path, display) = (track.path.clone(), track.display.clone());
|
||||
let data = Decoder::builder()
|
||||
.with_byte_len(track.data.len().try_into().unwrap())
|
||||
.with_byte_len(track.data.len().try_into()?)
|
||||
.with_data(Cursor::new(track.data))
|
||||
.build()
|
||||
.track(track.full_path.clone())?;
|
||||
|
||||
let info = Info::new(track.name, track.full_path, &data)?;
|
||||
.track(track.display)?;
|
||||
|
||||
let info = Info::new(&data, path, display)?;
|
||||
Ok(Self { info, data })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Kind {
|
||||
#[error("unable to decode: {0}")]
|
||||
@ -17,19 +19,20 @@ pub enum Kind {
|
||||
|
||||
#[error("unable to fetch data: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
|
||||
#[error("couldn't handle integer track length: {0}")]
|
||||
Integer(#[from] std::num::TryFromIntError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{kind} (track: {track})")]
|
||||
#[error("{kind} (track: {track:?})")]
|
||||
pub struct Error {
|
||||
pub track: String,
|
||||
|
||||
#[source]
|
||||
pub track: Option<String>,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn is_timeout(&self) -> bool {
|
||||
pub fn timeout(&self) -> bool {
|
||||
if let Kind::Request(x) = &self.kind {
|
||||
x.is_timeout()
|
||||
} else {
|
||||
@ -45,22 +48,34 @@ where
|
||||
{
|
||||
fn from((track, err): (T, E)) -> Self {
|
||||
Self {
|
||||
track: track.into(),
|
||||
track: Some(track.into()),
|
||||
kind: Kind::from(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Context<T> {
|
||||
fn track(self, name: impl Into<String>) -> Result<T, Error>;
|
||||
impl<E> From<E> for Error
|
||||
where
|
||||
Kind: From<E>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self {
|
||||
track: None,
|
||||
kind: Kind::from(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E> Context<T> for Result<T, E>
|
||||
pub trait WithTrackContext<T> {
|
||||
fn track(self, name: impl Into<String>) -> Result<T>;
|
||||
}
|
||||
|
||||
impl<T, E> WithTrackContext<T> for std::result::Result<T, E>
|
||||
where
|
||||
(String, E): Into<Error>,
|
||||
E: Into<Kind>,
|
||||
{
|
||||
fn track(self, name: impl Into<String>) -> Result<T, Error> {
|
||||
fn track(self, name: impl Into<String>) -> std::result::Result<T, Error> {
|
||||
self.map_err(|e| {
|
||||
let error = match e.into() {
|
||||
Kind::Request(e) => Kind::Request(e.without_url()),
|
||||
|
||||
54
src/tracks/format.rs
Normal file
54
src/tracks/format.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::error::WithTrackContext as _;
|
||||
use url::form_urlencoded;
|
||||
|
||||
/// Decodes a URL string into normal UTF-8.
|
||||
fn decode_url(text: &str) -> String {
|
||||
// The tuple contains smart pointers, so it's not really practical to use `into()`.
|
||||
#[allow(clippy::tuple_array_conversions)]
|
||||
form_urlencoded::parse(text.as_bytes())
|
||||
.map(|(key, val)| [key, val].concat())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Formats a name with [`convert_case`].
|
||||
///
|
||||
/// This will also strip the first few numbers that are
|
||||
/// usually present on most lofi tracks and do some other
|
||||
/// formatting operations.
|
||||
pub fn name(name: &str) -> super::Result<String> {
|
||||
let path = Path::new(name);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or(super::error::Kind::InvalidName)
|
||||
.track(name)?;
|
||||
|
||||
let name = decode_url(name);
|
||||
|
||||
// This is incremented for each digit in front of the song name.
|
||||
let mut skip = 0;
|
||||
|
||||
for character in name.as_bytes() {
|
||||
if character.is_ascii_digit()
|
||||
|| *character == b'.'
|
||||
|| *character == b')'
|
||||
|| *character == b'('
|
||||
{
|
||||
skip += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the entire name of the track is a number, then just return it.
|
||||
if skip == name.len() {
|
||||
Ok(name.trim().to_owned())
|
||||
} else {
|
||||
// We've already checked before that the bound is at an ASCII digit.
|
||||
#[allow(clippy::string_slice)]
|
||||
Ok(String::from(name[skip..].trim()))
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,25 @@
|
||||
//! The module containing all of the logic behind track lists,
|
||||
//! as well as obtaining track names & downloading the raw audio data
|
||||
|
||||
use std::{cmp::min, sync::atomic::Ordering};
|
||||
use std::{
|
||||
cmp::min,
|
||||
sync::atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
use atomic_float::AtomicF32;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use eyre::OptionExt as _;
|
||||
use futures::StreamExt;
|
||||
use bytes::{BufMut as _, Bytes, BytesMut};
|
||||
use futures::StreamExt as _;
|
||||
use reqwest::Client;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
data_dir,
|
||||
tracks::{self, error::Context},
|
||||
tracks::{
|
||||
self,
|
||||
error::{self, WithTrackContext as _},
|
||||
},
|
||||
};
|
||||
|
||||
use super::QueuedTrack;
|
||||
use super::Queued;
|
||||
|
||||
/// Represents a list of tracks that can be played.
|
||||
///
|
||||
@ -28,7 +32,7 @@ pub struct List {
|
||||
|
||||
/// Just the raw file, but seperated by `/n` (newlines).
|
||||
/// `lines[0]` is the base/heaeder, with the rest being tracks.
|
||||
lines: Vec<String>,
|
||||
pub lines: Vec<String>,
|
||||
|
||||
/// The file path which the list was read from.
|
||||
#[allow(dead_code)]
|
||||
@ -37,7 +41,7 @@ pub struct List {
|
||||
|
||||
impl List {
|
||||
/// Gets the base URL of the [List].
|
||||
pub fn base(&self) -> &str {
|
||||
pub fn header(&self) -> &str {
|
||||
self.lines[0].trim()
|
||||
}
|
||||
|
||||
@ -45,7 +49,7 @@ impl List {
|
||||
///
|
||||
/// The second value in the tuple specifies whether the
|
||||
/// track has a custom display name.
|
||||
fn random_path(&self) -> (String, Option<String>) {
|
||||
pub fn random_path(&self) -> (String, Option<String>) {
|
||||
// We're getting from 1 here, since the base is at `self.lines[0]`.
|
||||
//
|
||||
// We're also not pre-trimming `self.lines` into `base` & `tracks` due to
|
||||
@ -62,85 +66,75 @@ impl List {
|
||||
}
|
||||
|
||||
/// Downloads a raw track, but doesn't decode it.
|
||||
async fn download(
|
||||
pub(crate) async fn download(
|
||||
&self,
|
||||
track: &str,
|
||||
client: &Client,
|
||||
progress: Option<&AtomicF32>,
|
||||
) -> Result<(Bytes, String), tracks::Error> {
|
||||
progress: Option<&AtomicU8>,
|
||||
) -> tracks::Result<(Bytes, String)> {
|
||||
// If the track has a protocol, then we should ignore the base for it.
|
||||
let full_path = if track.contains("://") {
|
||||
let path = if track.contains("://") {
|
||||
track.to_owned()
|
||||
} else {
|
||||
format!("{}{}", self.base(), track)
|
||||
format!("{}{}", self.header(), track)
|
||||
};
|
||||
|
||||
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
|
||||
let data: Bytes = if let Some(x) = path.strip_prefix("file://") {
|
||||
let path = if x.starts_with('~') {
|
||||
let home_path =
|
||||
dirs::home_dir().ok_or((track, tracks::error::Kind::InvalidPath))?;
|
||||
let home_path = dirs::home_dir()
|
||||
.ok_or(error::Kind::InvalidPath)
|
||||
.track(track)?;
|
||||
let home = home_path
|
||||
.to_str()
|
||||
.ok_or((track, tracks::error::Kind::InvalidPath))?;
|
||||
.ok_or(error::Kind::InvalidPath)
|
||||
.track(track)?;
|
||||
|
||||
x.replace('~', home)
|
||||
} else {
|
||||
x.to_owned()
|
||||
};
|
||||
|
||||
let result = tokio::fs::read(path.clone()).await.track(track)?;
|
||||
let result = tokio::fs::read(path.clone()).await.track(x)?;
|
||||
result.into()
|
||||
} else {
|
||||
let response = client.get(full_path.clone()).send().await.track(track)?;
|
||||
let response = client.get(path.clone()).send().await.track(track)?;
|
||||
let Some(progress) = progress else {
|
||||
let bytes = response.bytes().await.track(track)?;
|
||||
return Ok((bytes, path));
|
||||
};
|
||||
|
||||
if let Some(progress) = progress {
|
||||
let total = response
|
||||
.content_length()
|
||||
.ok_or((track, tracks::error::Kind::UnknownLength))?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = BytesMut::new();
|
||||
let mut downloaded: u64 = 0;
|
||||
let total = response
|
||||
.content_length()
|
||||
.ok_or(error::Kind::UnknownLength)
|
||||
.track(track)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = BytesMut::new();
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.track(track)?;
|
||||
let new = min(downloaded + (chunk.len() as u64), total);
|
||||
downloaded = new;
|
||||
progress.store((new as f32) / (total as f32), Ordering::Relaxed);
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.track(track)?;
|
||||
downloaded = min(downloaded + (chunk.len() as u64), total);
|
||||
let rounded = ((downloaded as f64) / (total as f64) * 100.0).round() as u8;
|
||||
progress.store(rounded, Ordering::Relaxed);
|
||||
|
||||
bytes.put(chunk);
|
||||
}
|
||||
|
||||
bytes.into()
|
||||
} else {
|
||||
response.bytes().await.track(track)?
|
||||
bytes.put(chunk);
|
||||
}
|
||||
|
||||
bytes.into()
|
||||
};
|
||||
|
||||
Ok((data, full_path))
|
||||
Ok((data, path))
|
||||
}
|
||||
|
||||
/// Fetches and downloads a random track from the [List].
|
||||
///
|
||||
/// The Result's error is a bool, which is true if a timeout error occured,
|
||||
/// and false otherwise. This tells lowfi if it shouldn't wait to try again.
|
||||
pub async fn random(
|
||||
&self,
|
||||
client: &Client,
|
||||
progress: Option<&AtomicF32>,
|
||||
) -> Result<QueuedTrack, tracks::Error> {
|
||||
let (path, custom_name) = self.random_path();
|
||||
let (data, full_path) = self.download(&path, client, progress).await?;
|
||||
pub async fn random(&self, client: &Client, progress: &AtomicU8) -> tracks::Result<Queued> {
|
||||
let (path, display) = self.random_path();
|
||||
let (data, path) = self.download(&path, client, Some(progress)).await?;
|
||||
|
||||
let name = custom_name.map_or_else(
|
||||
|| super::TrackName::Raw(path.clone()),
|
||||
super::TrackName::Formatted,
|
||||
);
|
||||
|
||||
Ok(QueuedTrack {
|
||||
name,
|
||||
full_path,
|
||||
data,
|
||||
})
|
||||
Queued::new(path, data, display)
|
||||
}
|
||||
|
||||
/// Parses text into a [List].
|
||||
@ -159,31 +153,34 @@ impl List {
|
||||
}
|
||||
|
||||
/// Reads a [List] from the filesystem using the CLI argument provided.
|
||||
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
|
||||
if let Some(arg) = tracks {
|
||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
||||
let path = data_dir()?.join(format!("{arg}.txt"));
|
||||
let path = if path.exists() { path } else { arg.into() };
|
||||
|
||||
let raw = fs::read_to_string(path.clone()).await?;
|
||||
|
||||
// Get rid of special noheader case for tracklists without a header.
|
||||
let raw = raw
|
||||
.strip_prefix("noheader")
|
||||
.map_or(raw.as_ref(), |stripped| stripped);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or_eyre("invalid track path")?;
|
||||
|
||||
Ok(Self::new(name, raw, path.to_str()))
|
||||
} else {
|
||||
Ok(Self::new(
|
||||
pub async fn load(tracks: &str) -> tracks::Result<Self> {
|
||||
if tracks == "chillhop" {
|
||||
return Ok(Self::new(
|
||||
"chillhop",
|
||||
include_str!("../../data/chillhop.txt"),
|
||||
None,
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
|
||||
let path = data_dir()
|
||||
.map_err(|_| error::Kind::InvalidPath)?
|
||||
.join(format!("{tracks}.txt"));
|
||||
let path = if path.exists() { path } else { tracks.into() };
|
||||
|
||||
let raw = fs::read_to_string(path.clone()).await?;
|
||||
|
||||
// Get rid of special noheader case for tracklists without a header.
|
||||
let raw = raw
|
||||
.strip_prefix("noheader")
|
||||
.map_or_else(|| raw.as_ref(), |stripped| stripped);
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.ok_or(tracks::error::Kind::InvalidName)
|
||||
.track(tracks)?;
|
||||
|
||||
Ok(Self::new(name, raw, path.to_str()))
|
||||
}
|
||||
}
|
||||
|
||||
192
src/ui.rs
Normal file
192
src/ui.rs
Normal file
@ -0,0 +1,192 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
player::Current,
|
||||
ui::{self, window::Window},
|
||||
Args,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{broadcast, mpsc::Sender},
|
||||
task::JoinHandle,
|
||||
time::Instant,
|
||||
};
|
||||
pub mod components;
|
||||
pub mod environment;
|
||||
pub use environment::Environment;
|
||||
pub mod input;
|
||||
pub mod interface;
|
||||
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
|
||||
/// that occur while drawing the UI or handling input.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("unable to convert number: {0}")]
|
||||
Conversion(#[from] std::num::TryFromIntError),
|
||||
|
||||
#[error("unable to write output: {0}")]
|
||||
Write(#[from] std::io::Error),
|
||||
|
||||
#[error("sending message to backend from ui failed: {0}")]
|
||||
CrateSend(#[from] tokio::sync::mpsc::error::SendError<crate::Message>),
|
||||
|
||||
#[error("sharing state between backend and frontend failed: {0}")]
|
||||
Send(#[from] tokio::sync::broadcast::error::SendError<Update>),
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris bus error: {0}")]
|
||||
ZBus(#[from] mpris_server::zbus::Error),
|
||||
|
||||
#[cfg(feature = "mpris")]
|
||||
#[error("mpris fdo (zbus interface) error: {0}")]
|
||||
Fdo(#[from] mpris_server::zbus::fdo::Error),
|
||||
}
|
||||
|
||||
/// The UI state, which is all of the information that
|
||||
/// the user interface needs to display to the user.
|
||||
///
|
||||
/// It should be noted that this is also used by MPRIS to keep
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Creates an initial UI state.
|
||||
pub fn initial(sink: Arc<rodio::Sink>, width: usize, list: String) -> Self {
|
||||
let width = 21 + width.min(32) * 2;
|
||||
Self {
|
||||
width,
|
||||
sink,
|
||||
list,
|
||||
current: Current::default(),
|
||||
bookmarked: false,
|
||||
timer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A UI update sent out by the main player thread, which may
|
||||
/// not be immediately applied by the UI.
|
||||
///
|
||||
/// This corresponds to user actions, like bookmarking a track,
|
||||
/// skipping, or changing the volume. The difference is that it also
|
||||
/// contains the new information about the track.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Update {
|
||||
Track(Current),
|
||||
Bookmarked(bool),
|
||||
Volume,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Just a simple wrapper for the two primary tasks that the UI
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
fn drop(&mut self) {
|
||||
self.tasks.input.abort();
|
||||
self.tasks.render.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Handle {
|
||||
/// The main UI process, which will both render the UI to the terminal
|
||||
/// and also update state.
|
||||
///
|
||||
/// It does both of these things at a fixed interval, due to things
|
||||
/// like the track duration changing too frequently.
|
||||
///
|
||||
/// `rx` is the receiver for state updates, `state` the initial state,
|
||||
/// and `params` specifies aesthetic options that are specified by the user.
|
||||
async fn ui(
|
||||
mut rx: broadcast::Receiver<Update>,
|
||||
mut state: State,
|
||||
params: interface::Params,
|
||||
) -> Result<()> {
|
||||
let mut interval = tokio::time::interval(params.delta);
|
||||
let mut window = Window::new(state.width, params.borderless);
|
||||
|
||||
loop {
|
||||
if let Ok(message) = rx.try_recv() {
|
||||
match message {
|
||||
Update::Track(track) => state.current = track,
|
||||
Update::Bookmarked(bookmarked) => state.bookmarked = bookmarked,
|
||||
Update::Volume => state.timer = Some(Instant::now()),
|
||||
Update::Quit => break,
|
||||
}
|
||||
}
|
||||
|
||||
interface::draw(&mut state, &mut window, params)?;
|
||||
interval.tick().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initializes the UI itself, along with all of the tasks that are related to it.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn init(
|
||||
tx: Sender<crate::Message>,
|
||||
updater: broadcast::Receiver<ui::Update>,
|
||||
state: State,
|
||||
args: &Args,
|
||||
) -> Result<Self> {
|
||||
let environment = Environment::ready(args.alternate)?;
|
||||
Ok(Self {
|
||||
#[cfg(feature = "mpris")]
|
||||
mpris: mpris::Server::new(state.clone(), tx.clone(), updater.resubscribe()).await?,
|
||||
environment,
|
||||
tasks: Tasks {
|
||||
render: tokio::spawn(Self::ui(updater, state, interface::Params::from(args))),
|
||||
input: tokio::spawn(input::listen(tx)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
//! Various different individual components that
|
||||
//! appear in lowfi's UI, like the progress bar.
|
||||
|
||||
use std::{ops::Deref as _, sync::Arc, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::style::Stylize as _;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use crate::{player::Player, tracks::Info};
|
||||
use crate::{player::Current, tracks, ui};
|
||||
|
||||
/// Small helper function to format durations.
|
||||
pub fn format_duration(duration: &Duration) -> String {
|
||||
@ -17,23 +17,23 @@ pub fn format_duration(duration: &Duration) -> String {
|
||||
}
|
||||
|
||||
/// Creates the progress bar, as well as all the padding needed.
|
||||
pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
||||
pub fn progress_bar(state: &ui::State, width: usize) -> String {
|
||||
let mut duration = Duration::new(0, 0);
|
||||
let elapsed = if current.is_some() {
|
||||
player.sink.get_pos()
|
||||
let elapsed = if matches!(&state.current, Current::Track(_)) {
|
||||
state.sink.get_pos()
|
||||
} else {
|
||||
Duration::new(0, 0)
|
||||
};
|
||||
|
||||
let mut filled = 0;
|
||||
if let Some(current) = current {
|
||||
if let Current::Track(current) = &state.current {
|
||||
if let Some(x) = current.duration {
|
||||
duration = x;
|
||||
|
||||
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
|
||||
filled = (elapsed * width as f32).round() as usize;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
format!(
|
||||
" [{}{}] {}/{} ",
|
||||
@ -45,7 +45,7 @@ pub fn progress_bar(player: &Player, current: Option<&Arc<Info>>, width: usize)
|
||||
}
|
||||
|
||||
/// Creates the audio bar, as well as all the padding needed.
|
||||
pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
||||
pub fn audio_bar(width: usize, volume: f32, percentage: &str) -> String {
|
||||
let audio = (volume * width as f32).round() as usize;
|
||||
|
||||
format!(
|
||||
@ -60,13 +60,13 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
||||
/// This represents the main "action" bars state.
|
||||
enum ActionBar {
|
||||
/// When the app is paused.
|
||||
Paused(Info),
|
||||
Paused(tracks::Info),
|
||||
|
||||
/// When the app is playing.
|
||||
Playing(Info),
|
||||
Playing(tracks::Info),
|
||||
|
||||
/// When the app is loading.
|
||||
Loading(f32),
|
||||
Loading(Option<u8>),
|
||||
|
||||
/// When the app is muted.
|
||||
Muted,
|
||||
@ -77,12 +77,15 @@ impl ActionBar {
|
||||
/// The second value is the character length of the result.
|
||||
fn format(&self, star: bool) -> (String, usize) {
|
||||
let (word, subject) = match self {
|
||||
Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
|
||||
Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
|
||||
Self::Playing(x) => ("playing", Some((x.display.clone(), x.width))),
|
||||
Self::Paused(x) => ("paused", Some((x.display.clone(), x.width))),
|
||||
Self::Loading(progress) => {
|
||||
let progress = format!("{: <2.0}%", (progress * 100.0).min(99.0));
|
||||
let progress = match *progress {
|
||||
None | Some(0) => None,
|
||||
Some(progress) => Some((format!("{: <2.0}%", progress.min(99)), 3)),
|
||||
};
|
||||
|
||||
("loading", Some((progress, 3)))
|
||||
("loading", progress)
|
||||
}
|
||||
Self::Muted => {
|
||||
let msg = "+ to increase volume";
|
||||
@ -105,26 +108,23 @@ impl ActionBar {
|
||||
|
||||
/// Creates the top/action bar, which has the name of the track and it's status.
|
||||
/// This also creates all the needed padding.
|
||||
pub fn action(player: &Player, current: Option<&Arc<Info>>, width: usize) -> String {
|
||||
let (main, len) = current
|
||||
.map_or_else(
|
||||
|| ActionBar::Loading(player.progress.load(std::sync::atomic::Ordering::Acquire)),
|
||||
|info| {
|
||||
let info = info.deref().clone();
|
||||
|
||||
if player.sink.volume() < 0.01 {
|
||||
return ActionBar::Muted;
|
||||
}
|
||||
|
||||
if player.sink.is_paused() {
|
||||
ActionBar::Paused(info)
|
||||
} else {
|
||||
ActionBar::Playing(info)
|
||||
}
|
||||
},
|
||||
)
|
||||
.format(player.bookmarks.bookmarked());
|
||||
pub fn action(state: &ui::State, width: usize) -> String {
|
||||
let action = match state.current.clone() {
|
||||
Current::Loading(progress) => {
|
||||
ActionBar::Loading(progress.map(|x| x.load(std::sync::atomic::Ordering::Relaxed)))
|
||||
}
|
||||
Current::Track(info) => {
|
||||
if state.sink.volume() < 0.01 {
|
||||
ActionBar::Muted
|
||||
} else if state.sink.is_paused() {
|
||||
ActionBar::Paused(info)
|
||||
} else {
|
||||
ActionBar::Playing(info)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (main, len) = action.format(state.bookmarked);
|
||||
if len > width {
|
||||
let chopped: String = main.graphemes(true).take(width + 1).collect();
|
||||
|
||||
76
src/ui/environment.rs
Normal file
76
src/ui/environment.rs
Normal file
@ -0,0 +1,76 @@
|
||||
use std::{io::stdout, panic};
|
||||
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, Show},
|
||||
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
|
||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
|
||||
/// Represents the terminal environment, and is used to properly
|
||||
/// initialize and clean up the terminal.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Environment {
|
||||
/// Whether keyboard enhancements are enabled.
|
||||
enhancement: bool,
|
||||
|
||||
/// Whether the terminal is in an alternate screen or not.
|
||||
alternate: bool,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// This prepares the terminal, returning an [Environment] helpful
|
||||
/// for cleaning up afterwards.
|
||||
pub fn ready(alternate: bool) -> super::Result<Self> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
crossterm::execute!(lock, Hide)?;
|
||||
if alternate {
|
||||
crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?;
|
||||
}
|
||||
|
||||
terminal::enable_raw_mode()?;
|
||||
|
||||
let enhancement = terminal::supports_keyboard_enhancement().unwrap_or_default();
|
||||
if enhancement {
|
||||
crossterm::execute!(
|
||||
lock,
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||
)?;
|
||||
}
|
||||
|
||||
let environment = Self {
|
||||
enhancement,
|
||||
alternate,
|
||||
};
|
||||
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
let _ = environment.cleanup(false);
|
||||
eprintln!("panic: {info}");
|
||||
}));
|
||||
|
||||
Ok(environment)
|
||||
}
|
||||
|
||||
/// Uses the information collected from initialization to safely close down
|
||||
/// the terminal & restore it to it's previous state.
|
||||
pub fn cleanup(&self, elegant: bool) -> super::Result<()> {
|
||||
let mut lock = stdout().lock();
|
||||
|
||||
if self.alternate {
|
||||
crossterm::execute!(lock, LeaveAlternateScreen)?;
|
||||
}
|
||||
|
||||
crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?;
|
||||
|
||||
if self.enhancement {
|
||||
crossterm::execute!(lock, PopKeyboardEnhancementFlags)?;
|
||||
}
|
||||
|
||||
terminal::disable_raw_mode()?;
|
||||
if elegant {
|
||||
eprintln!("bye! :)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,13 @@
|
||||
//! Responsible for specifically recieving terminal input
|
||||
//! Responsible for specifically receiving terminal input
|
||||
//! using [`crossterm`].
|
||||
|
||||
use crate::Message;
|
||||
use crossterm::event::{self, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use futures::{FutureExt as _, StreamExt as _};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::player::{
|
||||
ui::{self, UIError},
|
||||
Message,
|
||||
};
|
||||
|
||||
/// Starts the listener to recieve input from the terminal for various events.
|
||||
pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
|
||||
/// Starts the listener to receive input from the terminal for various events.
|
||||
pub async fn listen(sender: Sender<Message>) -> super::Result<()> {
|
||||
let mut reader = EventStream::new();
|
||||
|
||||
loop {
|
||||
@ -66,10 +62,6 @@ pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Message::ChangeVolume(_) = messages {
|
||||
ui::flash_audio();
|
||||
}
|
||||
|
||||
sender.send(messages).await?;
|
||||
}
|
||||
}
|
||||
64
src/ui/interface.rs
Normal file
64
src/ui/interface.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
ui::{self, components, window::Window},
|
||||
Args,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct Params {
|
||||
pub borderless: bool,
|
||||
pub minimalist: bool,
|
||||
pub delta: Duration,
|
||||
}
|
||||
|
||||
impl From<&Args> for Params {
|
||||
fn from(args: &Args) -> Self {
|
||||
let delta = 1.0 / f32::from(args.fps);
|
||||
let delta = Duration::from_secs_f32(delta);
|
||||
|
||||
Self {
|
||||
delta,
|
||||
minimalist: args.minimalist,
|
||||
borderless: args.borderless,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
let middle = match state.timer {
|
||||
Some(timer) => {
|
||||
let volume = state.sink.volume();
|
||||
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)
|
||||
}
|
||||
None => components::progress_bar(state, state.width - 16),
|
||||
};
|
||||
|
||||
let controls = components::controls(state.width);
|
||||
if params.minimalist {
|
||||
vec![action, middle]
|
||||
} else {
|
||||
vec![action, middle, controls]
|
||||
}
|
||||
}
|
||||
|
||||
/// The code for the terminal interface itself.
|
||||
///
|
||||
/// * `minimalist` - All this does is hide the bottom control bar.
|
||||
pub fn draw(state: &mut ui::State, window: &mut Window, params: Params) -> super::Result<()> {
|
||||
let menu = menu(state, params);
|
||||
window.draw(menu, false)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -1,27 +1,62 @@
|
||||
//! Contains the code for the MPRIS server & other helper functions.
|
||||
|
||||
use std::{env, process, sync::Arc};
|
||||
use std::{
|
||||
env,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
process,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use mpris_server::{
|
||||
zbus::{self, fdo, Result},
|
||||
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
||||
Time, TrackId, Volume,
|
||||
};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use rodio::Sink;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use super::ui;
|
||||
use super::Message;
|
||||
use crate::{player::Current, ui::Update};
|
||||
use crate::{ui, Message};
|
||||
|
||||
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
|
||||
|
||||
struct Sender {
|
||||
inner: mpsc::Sender<Message>,
|
||||
}
|
||||
|
||||
impl Sender {
|
||||
pub fn new(inner: mpsc::Sender<Message>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub async fn send(&self, message: Message) -> fdo::Result<()> {
|
||||
self.inner
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|x| fdo::Error::Failed(x.to_string()))
|
||||
}
|
||||
|
||||
pub async fn zbus(&self, message: Message) -> zbus::Result<()> {
|
||||
self.inner
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|x| zbus::Error::Failure(x.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<fdo::Error> for crate::Error {
|
||||
fn into(self) -> fdo::Error {
|
||||
fdo::Error::Failed(self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual MPRIS player.
|
||||
pub struct Player {
|
||||
/// A reference to the [`super::Player`] itself.
|
||||
pub player: Arc<super::Player>,
|
||||
|
||||
/// The audio server sender, which is used to communicate with
|
||||
/// the audio sender for skips and a few other inputs.
|
||||
pub sender: Sender<Message>,
|
||||
sink: Arc<Sink>,
|
||||
current: ArcSwap<Current>,
|
||||
list: String,
|
||||
sender: Sender,
|
||||
}
|
||||
|
||||
impl RootInterface for Player {
|
||||
@ -30,10 +65,7 @@ impl RootInterface for Player {
|
||||
}
|
||||
|
||||
async fn quit(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Quit)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
self.sender.send(Message::Quit).await
|
||||
}
|
||||
|
||||
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||
@ -79,10 +111,7 @@ impl RootInterface for Player {
|
||||
|
||||
impl PlayerInterface for Player {
|
||||
async fn next(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Next)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
self.sender.send(Message::Next).await
|
||||
}
|
||||
|
||||
async fn previous(&self) -> fdo::Result<()> {
|
||||
@ -90,17 +119,11 @@ impl PlayerInterface for Player {
|
||||
}
|
||||
|
||||
async fn pause(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Pause)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
self.sender.send(Message::Pause).await
|
||||
}
|
||||
|
||||
async fn play_pause(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::PlayPause)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
self.sender.send(Message::PlayPause).await
|
||||
}
|
||||
|
||||
async fn stop(&self) -> fdo::Result<()> {
|
||||
@ -108,10 +131,7 @@ impl PlayerInterface for Player {
|
||||
}
|
||||
|
||||
async fn play(&self) -> fdo::Result<()> {
|
||||
self.sender
|
||||
.send(Message::Play)
|
||||
.await
|
||||
.map_err(|_error| ERROR)
|
||||
self.sender.send(Message::Play).await
|
||||
}
|
||||
|
||||
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
|
||||
@ -127,9 +147,9 @@ impl PlayerInterface for Player {
|
||||
}
|
||||
|
||||
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
||||
Ok(if !self.player.current_exists() {
|
||||
Ok(if self.current.load().loading() {
|
||||
PlaybackStatus::Stopped
|
||||
} else if self.player.sink.is_paused() {
|
||||
} else if self.sink.is_paused() {
|
||||
PlaybackStatus::Paused
|
||||
} else {
|
||||
PlaybackStatus::Playing
|
||||
@ -145,11 +165,11 @@ impl PlayerInterface for Player {
|
||||
}
|
||||
|
||||
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
Ok(self.player.sink.speed().into())
|
||||
Ok(self.sink.speed().into())
|
||||
}
|
||||
|
||||
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
|
||||
self.player.sink.set_speed(rate as f32);
|
||||
self.sink.set_speed(rate as f32);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -162,15 +182,23 @@ impl PlayerInterface for Player {
|
||||
}
|
||||
|
||||
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||
let metadata = self
|
||||
.player
|
||||
.current
|
||||
.load()
|
||||
.as_ref()
|
||||
.map_or_else(Metadata::new, |track| {
|
||||
Ok(match self.current.load().as_ref() {
|
||||
Current::Loading(_) => Metadata::new(),
|
||||
Current::Track(track) => {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
track.path.hash(&mut hasher);
|
||||
|
||||
let id = mpris_server::zbus::zvariant::ObjectPath::try_from(format!(
|
||||
"/com/talwat/lowfi/{}/{}",
|
||||
self.list,
|
||||
hasher.finish()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let mut metadata = Metadata::builder()
|
||||
.title(track.display_name.clone())
|
||||
.album(self.player.list.name.clone())
|
||||
.trackid(id)
|
||||
.title(track.display.clone())
|
||||
.album(self.list.clone())
|
||||
.build();
|
||||
|
||||
metadata.set_length(
|
||||
@ -180,26 +208,20 @@ impl PlayerInterface for Player {
|
||||
);
|
||||
|
||||
metadata
|
||||
});
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn volume(&self) -> fdo::Result<Volume> {
|
||||
Ok(self.player.sink.volume().into())
|
||||
Ok(self.sink.volume().into())
|
||||
}
|
||||
|
||||
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||
self.player.set_volume(volume as f32);
|
||||
ui::flash_audio();
|
||||
|
||||
Ok(())
|
||||
self.sender.zbus(Message::SetVolume(volume as f32)).await
|
||||
}
|
||||
|
||||
async fn position(&self) -> fdo::Result<Time> {
|
||||
Ok(Time::from_micros(
|
||||
self.player.sink.get_pos().as_micros() as i64
|
||||
))
|
||||
Ok(Time::from_micros(self.sink.get_pos().as_micros() as i64))
|
||||
}
|
||||
|
||||
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||
@ -240,22 +262,49 @@ impl PlayerInterface for Player {
|
||||
pub struct Server {
|
||||
/// The inner MPRIS server.
|
||||
inner: mpris_server::Server<Player>,
|
||||
|
||||
/// Broadcast receiver.
|
||||
receiver: broadcast::Receiver<Update>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
|
||||
pub async fn changed(
|
||||
&self,
|
||||
&mut self,
|
||||
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
|
||||
) -> zbus::Result<()> {
|
||||
self.inner.properties_changed(properties).await
|
||||
) -> ui::Result<()> {
|
||||
while let Ok(update) = self.receiver.try_recv() {
|
||||
if let Update::Track(current) = update {
|
||||
self.player().current.swap(Arc::new(current));
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.properties_changed(properties).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
|
||||
pub async fn playback(&self, new: PlaybackStatus) -> zbus::Result<()> {
|
||||
self.inner
|
||||
.properties_changed(vec![Property::PlaybackStatus(new)])
|
||||
.await
|
||||
/// Updates the volume with the latest information.
|
||||
pub async fn update_volume(&mut self) -> ui::Result<()> {
|
||||
self.changed(vec![Property::Volume(self.player().sink.volume().into())])
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates the playback with the latest information.
|
||||
pub async fn update_playback(&mut self) -> ui::Result<()> {
|
||||
let status = self.player().playback_status().await?;
|
||||
self.changed(vec![Property::PlaybackStatus(status)]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates the current track data with the current information.
|
||||
pub async fn update_metadata(&mut self) -> ui::Result<()> {
|
||||
let metadata = self.player().metadata().await?;
|
||||
self.changed(vec![Property::Metadata(metadata)]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shorthand to get the inner mpris player object.
|
||||
@ -265,17 +314,30 @@ impl Server {
|
||||
|
||||
/// Creates a new MPRIS server.
|
||||
pub async fn new(
|
||||
player: Arc<super::Player>,
|
||||
sender: Sender<Message>,
|
||||
) -> eyre::Result<Self, zbus::Error> {
|
||||
state: ui::State,
|
||||
sender: mpsc::Sender<Message>,
|
||||
receiver: broadcast::Receiver<Update>,
|
||||
) -> ui::Result<Server> {
|
||||
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
|
||||
String::from("lowfi")
|
||||
} else {
|
||||
format!("lowfi.{}.instance{}", player.list.name, process::id())
|
||||
format!("lowfi.{}.instance{}", state.list, process::id())
|
||||
};
|
||||
|
||||
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
|
||||
let server = mpris_server::Server::new(
|
||||
&suffix,
|
||||
Player {
|
||||
sender: Sender::new(sender),
|
||||
sink: state.sink,
|
||||
current: ArcSwap::new(Arc::new(state.current)),
|
||||
list: state.list,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Self { inner: server })
|
||||
Ok(Self {
|
||||
inner: server,
|
||||
receiver,
|
||||
})
|
||||
}
|
||||
}
|
||||
118
src/ui/window.rs
Normal file
118
src/ui/window.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use std::io::{stdout, Stdout};
|
||||
|
||||
use crossterm::{
|
||||
cursor::{MoveToColumn, MoveUp},
|
||||
style::{Print, Stylize as _},
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
use std::fmt::Write as _;
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
/// Represents an abstraction for drawing the actual lowfi window itself.
|
||||
///
|
||||
/// The main purpose of this struct is just to add the fancy border,
|
||||
/// as well as clear the screen before drawing.
|
||||
pub struct Window {
|
||||
/// Whether or not to include borders in the output.
|
||||
borderless: bool,
|
||||
|
||||
/// The top & bottom borders, which are here since they can be
|
||||
/// prerendered, as they don't change from window to window.
|
||||
///
|
||||
/// If the option to not include borders is set, these will just be empty [String]s.
|
||||
pub(crate) borders: [String; 2],
|
||||
|
||||
/// The inner width of the window.
|
||||
width: usize,
|
||||
|
||||
/// The output, currently just an [`Stdout`].
|
||||
out: Stdout,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Initializes a new [Window].
|
||||
///
|
||||
/// * `width` - Inner width of the window.
|
||||
/// * `borderless` - Whether to include borders in the window, or not.
|
||||
pub fn new(width: usize, borderless: bool) -> Self {
|
||||
let borders = if borderless {
|
||||
[String::new(), String::new()]
|
||||
} else {
|
||||
let middle = "─".repeat(width + 2);
|
||||
|
||||
[format!("┌{middle}┐"), format!("└{middle}┘")]
|
||||
};
|
||||
|
||||
Self {
|
||||
borders,
|
||||
borderless,
|
||||
width,
|
||||
out: stdout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
space: bool,
|
||||
testing: bool,
|
||||
) -> super::Result<(String, u16)> {
|
||||
let linefeed = if testing { "\n" } else { "\r\n" };
|
||||
let len: u16 = content.len().try_into()?;
|
||||
|
||||
// Note that this will have a trailing newline, which we use later.
|
||||
let menu: String = content.into_iter().fold(String::new(), |mut output, x| {
|
||||
// Horizontal Padding & Border
|
||||
let padding = if self.borderless { " " } else { "│" };
|
||||
let space = if space {
|
||||
" ".repeat(self.width.saturating_sub(x.graphemes(true).count()))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let center = if testing { x } else { x.reset().to_string() };
|
||||
write!(output, "{padding} {center}{space} {padding}{linefeed}").unwrap();
|
||||
|
||||
output
|
||||
});
|
||||
|
||||
// We're doing this because Windows is stupid and can't stand
|
||||
// writing to the last line repeatedly.
|
||||
#[cfg(windows)]
|
||||
let (height, suffix) = (len + 3, linefeed);
|
||||
#[cfg(not(windows))]
|
||||
let (height, suffix) = (len + 2, "");
|
||||
|
||||
// There's no need for another newline after the main menu content, because it already has one.
|
||||
Ok((
|
||||
format!(
|
||||
"{}{linefeed}{menu}{}{suffix}",
|
||||
self.borders[0], self.borders[1]
|
||||
),
|
||||
height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Actually draws the window, with each element in `content` being on a new line.
|
||||
pub fn draw(&mut self, content: Vec<String>, space: bool) -> super::Result<()> {
|
||||
let (rendered, height) = self.render(content, space, false)?;
|
||||
|
||||
crossterm::execute!(
|
||||
self.out,
|
||||
Clear(ClearType::FromCursorDown),
|
||||
MoveToColumn(0),
|
||||
Print(rendered),
|
||||
MoveToColumn(0),
|
||||
MoveUp(height - 1),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
85
src/volume.rs
Normal file
85
src/volume.rs
Normal file
@ -0,0 +1,85 @@
|
||||
//! 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;
|
||||
|
||||
/// Shorthand for a [`Result`] with a persistent volume error.
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Errors which occur when loading/unloading persistent volume.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("couldn't find config directory")]
|
||||
Directory,
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("error parsing volume integer: {0}")]
|
||||
Parse(#[from] ParseIntError),
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) inner: u16,
|
||||
}
|
||||
|
||||
impl PersistentVolume {
|
||||
/// Retrieves the config directory, creating it if necessary.
|
||||
async fn config() -> Result<PathBuf> {
|
||||
let config = dirs::config_dir()
|
||||
.ok_or(Error::Directory)?
|
||||
.join(PathBuf::from("lowfi"));
|
||||
|
||||
if !config.exists() {
|
||||
fs::create_dir_all(&config).await?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 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 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"));
|
||||
|
||||
// Basically just read from the volume file if it exists, otherwise return 100.
|
||||
let volume = if volume.exists() {
|
||||
let contents = fs::read_to_string(volume).await?;
|
||||
let trimmed = contents.trim();
|
||||
let stripped = trimmed.strip_suffix("%").unwrap_or(trimmed);
|
||||
stripped.parse()?
|
||||
} else {
|
||||
fs::write(&volume, "100").await?;
|
||||
100u16
|
||||
};
|
||||
|
||||
Ok(Self { inner: volume })
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
let percentage = (volume * 100.0).abs().round() as u16;
|
||||
fs::write(path, percentage.to_string()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user