feat: merge pull request #108 from talwat/rewrite

This commit is contained in:
Tal 2025-12-06 16:54:11 +01:00 committed by GitHub
commit a26623f9c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2812 additions and 2469 deletions

View File

@ -1,6 +1,7 @@
name: Release Build
on:
workflow_dispatch:
release:
types: [created]

27
.github/workflows/tests.yml vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,6 +5,24 @@ It'll do this as simply as it can: no albums, no ads, just lofi.
![example image](media/example1.png)
## 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://`,

View File

@ -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
View 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(&notify))),
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
View 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
View 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
View 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),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,16 +3,16 @@
//! This command is completely optional, and as such isn't subject to the same
//! quality standards as the rest of the codebase.
use std::sync::LazyLock;
use futures::{stream::FuturesOrdered, StreamExt};
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{Html, Selector};
use crate::scrapers::{get, Source};
lazy_static! {
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
}
static SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;

View File

@ -2,9 +2,8 @@ use eyre::eyre;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use indicatif::ProgressBar;
use lazy_static::lazy_static;
use std::fmt;
use std::str::FromStr;
use std::{fmt, sync::LazyLock};
use reqwest::Client;
use scraper::{Html, Selector};
@ -16,14 +15,13 @@ use tokio::fs;
use crate::scrapers::{get, Source};
lazy_static! {
static ref RELEASES: Selector = Selector::parse(".table-body > a").unwrap();
static ref RELEASE_LABEL: Selector = Selector::parse("label").unwrap();
// static ref RELEASE_DATE: Selector = Selector::parse(".release-feat-props > .text-xs").unwrap();
// static ref RELEASE_NAME: Selector = Selector::parse(".release-feat-props > h2").unwrap();
static ref RELEASE_AUTHOR: Selector = Selector::parse(".release-feat-props .artist-link").unwrap();
static ref RELEASE_TEXTAREA: Selector = Selector::parse("textarea").unwrap();
}
static RELEASES: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".table-body > a").unwrap());
static RELEASE_LABEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse("label").unwrap());
// static ref RELEASE_DATE: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > .text-xs").unwrap());
// static ref RELEASE_NAME: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props > h2").unwrap());
// static RELEASE_AUTHOR: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".release-feat-props .artist-link").unwrap());
static RELEASE_TEXTAREA: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("textarea").unwrap());
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]

View File

@ -3,16 +3,16 @@
//! This command is completely optional, and as such isn't subject to the same
//! quality standards as the rest of the codebase.
use std::sync::LazyLock;
use futures::{stream::FuturesOrdered, StreamExt};
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{Html, Selector};
use crate::scrapers::get;
lazy_static! {
static ref SELECTOR: Selector = Selector::parse("html > body > pre > a").unwrap();
}
static SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("html > body > pre > a").unwrap());
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;

5
src/tests.rs Normal file
View 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
View 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
View 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
View 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);
}
}
}

View File

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

View File

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

View File

@ -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
View 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)),
},
})
}
}

View File

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

View File

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

View File

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