chore: even more error handling improvements

This commit is contained in:
Tal 2025-08-06 15:34:44 +02:00
parent 9f7c895154
commit 1884d2ebed
21 changed files with 285 additions and 194 deletions

22
Cargo.lock generated
View File

@ -433,6 +433,19 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "color-eyre"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
dependencies = [
"backtrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.3" version = "1.0.3"
@ -1392,6 +1405,7 @@ dependencies = [
"arc-swap", "arc-swap",
"bytes", "bytes",
"clap", "clap",
"color-eyre",
"crossterm", "crossterm",
"dirs", "dirs",
"eyre", "eyre",
@ -1777,6 +1791,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "owo-colors"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -3113,7 +3133,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]

View File

@ -25,6 +25,8 @@ extra-audio-formats = ["rodio/default"]
clap = { version = "4.5.21", features = ["derive", "cargo"] } clap = { version = "4.5.21", features = ["derive", "cargo"] }
eyre = { version = "0.6.12" } eyre = { version = "0.6.12" }
rand = "0.8.5" rand = "0.8.5"
thiserror = "2.0.12"
color-eyre = { version = "0.6.5", default-features = false }
# Async # Async
tokio = { version = "1.41.1", features = [ tokio = { version = "1.41.1", features = [
@ -52,4 +54,3 @@ lazy_static = "1.5.0"
libc = "0.2.167" libc = "0.2.167"
url = "2.5.4" url = "2.5.4"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
thiserror = "2.0.12"

View File

@ -219,13 +219,13 @@ For example, in this list:
https://lofigirl.com/wp-content/uploads/ https://lofigirl.com/wp-content/uploads/
2023/06/Foudroie-Finding-The-Edge-V2.mp3 2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/04/2-In-Front-Of-Me.mp3 2023/04/2-In-Front-Of-Me.mp3
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3 https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3
``` ```
lowfi would download these three URLs: lowfi would download these three URLs:
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3` - `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
- `https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3` - `https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3`
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3` - `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
Additionally, you may also specify a custom display name for the track which is indicated by a `!`. Additionally, you may also specify a custom display name for the track which is indicated by a `!`.

View File

@ -1,16 +0,0 @@
https://archive.org/download/jack-stauber-s-micropop-extended-micropops/Jack%20Stauber-%27s%20Micropop%20-%20
Al%20Dente.mp3
Baby%20Hotline.mp3
Cupid.mp3
Deploy.mp3
Dinner%20Is%20Not%20Over.mp3
Fighter.mp3
Inchman.mp3
Keyman.mp3
Out%20the%20Ox.mp3
Tea%20Errors.mp3
The%20Ballad%20of%20Hamantha.mp3
There%27s%20Something%20Happening.mp3
Those%20Eggs%20Aren%27t%20Dippy%20.mp3
Today%20Today.mp3
Two%20Time.mp3

View File

@ -1,4 +1,4 @@
https://lofigirl.com/wp-content/uploads/ https://lofigirl.com/wp-content/uploads/
2023/06/Foudroie-Finding-The-Edge-V2.mp3 2023/06/Foudroie-Finding-The-Edge-V2.mp3
2023/04/2-In-Front-Of-Me.mp3 2023/04/2-In-Front-Of-Me.mp3
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3 https://file-examples.com/storage/fe85f7a43b689349d9c8f18/2017/11/file_example_MP3_1MG.mp3

View File

@ -2,10 +2,8 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)] #![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use std::path::PathBuf;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use eyre::OptionExt; use std::path::PathBuf;
mod messages; mod messages;
mod play; mod play;
@ -81,9 +79,9 @@ enum Commands {
} }
/// Gets lowfi's data directory. /// Gets lowfi's data directory.
pub fn data_dir() -> eyre::Result<PathBuf> { pub fn data_dir() -> eyre::Result<PathBuf, player::Error> {
let dir = dirs::data_dir() let dir = dirs::data_dir()
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")? .ok_or(player::Error::DataDir)?
.join("lowfi"); .join("lowfi");
Ok(dir) Ok(dir)
@ -91,8 +89,7 @@ pub fn data_dir() -> eyre::Result<PathBuf> {
#[tokio::main] #[tokio::main]
async fn main() -> eyre::Result<()> { async fn main() -> eyre::Result<()> {
#[cfg(target_os = "android")] color_eyre::install()?;
compile_error!("Android Audio API not supported due to threading shenanigans");
let cli = Args::parse(); let cli = Args::parse();
@ -100,12 +97,14 @@ async fn main() -> eyre::Result<()> {
match command { match command {
// TODO: Actually distinguish between sources. // TODO: Actually distinguish between sources.
Commands::Scrape { Commands::Scrape {
source, source: _,
extension, extension,
include_full, include_full,
} => scrapers::lofigirl::scrape(extension, include_full).await, } => scrapers::lofigirl::scrape(extension, include_full).await?,
} }
} else { } else {
play::play(cli).await play::play(cli).await?;
} };
Ok(())
} }

View File

@ -1,6 +1,6 @@
/// Handles communication between the frontend & audio player. /// Handles communication between the frontend & audio player.
#[derive(PartialEq, Debug, Clone, Copy)] #[derive(PartialEq, Debug, Clone, Copy)]
pub enum Messages { pub enum Message {
/// Notifies the audio server that it should update the track. /// Notifies the audio server that it should update the track.
Next, Next,

View File

@ -1,89 +1,42 @@
//! Responsible for the basic initialization & shutdown of the audio server & frontend. //! Responsible for the basic initialization & shutdown of the audio server & frontend.
use std::env; use crossterm::cursor::Show;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::terminal::{self, Clear, ClearType};
use std::io::{stdout, IsTerminal}; use std::io::{stdout, IsTerminal};
use std::path::PathBuf; use std::process::exit;
use std::sync::Arc; use std::sync::Arc;
use std::{env, panic};
use eyre::eyre;
use tokio::fs;
use tokio::{sync::mpsc, task}; use tokio::{sync::mpsc, task};
use crate::messages::Messages; use crate::messages::Message;
use crate::player::ui; use crate::player::persistent_volume::PersistentVolume;
use crate::player::Player; use crate::player::Player;
use crate::player::{self, ui};
use crate::Args; use crate::Args;
/// 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(())
}
}
/// Initializes the audio server, and then safely stops /// Initializes the audio server, and then safely stops
/// it when the frontend quits. /// it when the frontend quits.
pub async fn play(args: Args) -> eyre::Result<()> { 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. // Actually initializes the player.
// Stream kept here in the master thread to keep it alive. // Stream kept here in the master thread to keep it alive.
let (player, stream) = Player::new(&args).await?; let (player, stream) = Player::new(&args).await?;
@ -102,16 +55,19 @@ pub async fn play(args: Args) -> eyre::Result<()> {
}; };
// Sends the player an "init" signal telling it to start playing a song straight away. // Sends the player an "init" signal telling it to start playing a song straight away.
tx.send(Messages::Init).await?; tx.send(Message::Init).await?;
// Actually starts the player. // Actually starts the player.
Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?; Player::play(Arc::clone(&player), tx.clone(), rx, args.debug).await?;
// Save the volume.txt file for the next session. // Save the volume.txt file for the next session.
PersistentVolume::save(player.sink.volume()).await?; PersistentVolume::save(player.sink.volume())
.await
.map_err(player::Error::PersistentVolumeSave)?;
drop(stream); drop(stream);
player.sink.stop(); player.sink.stop();
ui.and_then(|x| Some(x.abort())); ui.map(|x| x.abort());
Ok(()) Ok(())
} }

View File

@ -13,7 +13,6 @@ use std::{
use arc_swap::ArcSwapOption; use arc_swap::ArcSwapOption;
use downloader::Downloader; use downloader::Downloader;
use eyre::Context;
use reqwest::Client; use reqwest::Client;
use rodio::{OutputStream, OutputStreamBuilder, Sink}; use rodio::{OutputStream, OutputStreamBuilder, Sink};
use tokio::{ use tokio::{
@ -29,8 +28,8 @@ use tokio::{
use mpris_server::{PlaybackStatus, PlayerInterface, Property}; use mpris_server::{PlaybackStatus, PlayerInterface, Property};
use crate::{ use crate::{
messages::Messages, messages::Message,
play::PersistentVolume, player::{self, persistent_volume::PersistentVolume},
tracks::{self, list::List}, tracks::{self, list::List},
Args, Args,
}; };
@ -38,9 +37,13 @@ use crate::{
pub mod audio; pub mod audio;
pub mod bookmark; pub mod bookmark;
pub mod downloader; pub mod downloader;
pub mod error;
pub mod persistent_volume;
pub mod queue; pub mod queue;
pub mod ui; pub mod ui;
pub use error::Error;
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
pub mod mpris; pub mod mpris;
@ -104,14 +107,16 @@ impl Player {
/// Initializes the entire player, including audio devices & sink. /// Initializes the entire player, including audio devices & sink.
/// ///
/// This also will load the track list & persistent volume. /// This also will load the track list & persistent volume.
pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream)> { pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> {
// Load the volume file. // Load the volume file.
let volume = PersistentVolume::load().await?; let volume = PersistentVolume::load()
.await
.map_err(player::Error::PersistentVolumeLoad)?;
// Load the track list. // Load the track list.
let list = List::load(args.track_list.as_ref()) let list = List::load(args.track_list.as_ref())
.await .await
.wrap_err("unable to load the track list")?; .map_err(player::Error::TrackListLoad)?;
// We should only shut up alsa forcefully on Linux if we really have to. // We should only shut up alsa forcefully on Linux if we really have to.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -160,10 +165,10 @@ impl Player {
/// The [Downloader]s internal buffer size is determined by `buf_size`. /// The [Downloader]s internal buffer size is determined by `buf_size`.
pub async fn play( pub async fn play(
player: Arc<Self>, player: Arc<Self>,
tx: Sender<Messages>, tx: Sender<Message>,
mut rx: Receiver<Messages>, mut rx: Receiver<Message>,
debug: bool, debug: bool,
) -> eyre::Result<()> { ) -> eyre::Result<(), player::Error> {
// Initialize the mpris player. // Initialize the mpris player.
// //
// We're initializing here, despite MPRIS being a "user interface", // We're initializing here, despite MPRIS being a "user interface",
@ -213,11 +218,11 @@ impl Player {
// It's also important to note that the condition is only checked at the // It's also important to note that the condition is only checked at the
// beginning of the loop, not throughout. // beginning of the loop, not throughout.
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()), Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()),
if new => Messages::Next, if new => Message::Next,
}; };
match msg { match msg {
Messages::Next | Messages::Init | Messages::TryAgain => { Message::Next | Message::Init | Message::TryAgain => {
player.bookmarked.swap(false, Ordering::Relaxed); player.bookmarked.swap(false, Ordering::Relaxed);
// We manually skipped, so we shouldn't actually wait for the song // We manually skipped, so we shouldn't actually wait for the song
@ -225,7 +230,7 @@ impl Player {
new = false; new = false;
// This basically just prevents `Next` while a song is still currently loading. // This basically just prevents `Next` while a song is still currently loading.
if msg == Messages::Next && !player.current_exists() { if msg == Message::Next && !player.current_exists() {
continue; continue;
} }
@ -238,19 +243,19 @@ impl Player {
debug, debug,
)); ));
} }
Messages::Play => { Message::Play => {
player.sink.play(); player.sink.play();
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Playing).await?; mpris.playback(PlaybackStatus::Playing).await?;
} }
Messages::Pause => { Message::Pause => {
player.sink.pause(); player.sink.pause();
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
mpris.playback(PlaybackStatus::Paused).await?; mpris.playback(PlaybackStatus::Paused).await?;
} }
Messages::PlayPause => { Message::PlayPause => {
if player.sink.is_paused() { if player.sink.is_paused() {
player.sink.play(); player.sink.play();
} else { } else {
@ -262,7 +267,7 @@ impl Player {
.playback(mpris.player().playback_status().await?) .playback(mpris.player().playback_status().await?)
.await?; .await?;
} }
Messages::ChangeVolume(change) => { Message::ChangeVolume(change) => {
player.set_volume(player.sink.volume() + change); player.set_volume(player.sink.volume() + change);
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
@ -273,7 +278,7 @@ impl Player {
// This basically just continues, but more importantly, it'll re-evaluate // This basically just continues, but more importantly, it'll re-evaluate
// the select macro at the beginning of the loop. // the select macro at the beginning of the loop.
// See the top section to find out why this matters. // See the top section to find out why this matters.
Messages::NewSong => { Message::NewSong => {
// We've recieved `NewSong`, so on the next loop iteration we'll // We've recieved `NewSong`, so on the next loop iteration we'll
// begin waiting for the song to be over in order to autoplay. // begin waiting for the song to be over in order to autoplay.
new = true; new = true;
@ -288,7 +293,7 @@ impl Player {
continue; continue;
} }
Messages::Bookmark => { Message::Bookmark => {
let current = player.current.load(); let current = player.current.load();
let current = current.as_ref().unwrap(); let current = current.as_ref().unwrap();
@ -300,11 +305,12 @@ impl Player {
None None
}, },
) )
.await?; .await
.map_err(player::Error::Bookmark)?;
player.bookmarked.swap(bookmarked, Ordering::Relaxed); player.bookmarked.swap(bookmarked, Ordering::Relaxed);
} }
Messages::Quit => break, Message::Quit => break,
} }
} }

View File

@ -1,10 +1,13 @@
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use rodio::OutputStream; use rodio::OutputStream;
#[cfg(target_os = "linux")]
use crate::player;
/// This gets the output stream while also shutting up alsa with [libc]. /// This gets the output stream while also shutting up alsa with [libc].
/// Uses raw libc calls, and therefore is functional only on Linux. /// Uses raw libc calls, and therefore is functional only on Linux.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn silent_get_output_stream() -> eyre::Result<OutputStream> { pub fn silent_get_output_stream() -> eyre::Result<OutputStream, player::Error> {
use libc::freopen; use libc::freopen;
use rodio::OutputStreamBuilder; use rodio::OutputStreamBuilder;
use std::ffi::CString; use std::ffi::CString;

View File

@ -9,7 +9,7 @@ use crate::data_dir;
/// ///
/// Returns whether the track is now bookmarked, or not. /// Returns whether the track is now bookmarked, or not.
pub async fn bookmark(path: String, custom: Option<String>) -> eyre::Result<bool> { pub async fn bookmark(path: String, custom: Option<String>) -> eyre::Result<bool> {
let mut entry = format!("{path}"); let mut entry = path.to_string();
if let Some(custom) = custom { if let Some(custom) = custom {
entry.push('!'); entry.push('!');
entry.push_str(&custom); entry.push_str(&custom);
@ -25,6 +25,7 @@ pub async fn bookmark(path: String, custom: Option<String>) -> eyre::Result<bool
.write(true) .write(true)
.read(true) .read(true)
.append(false) .append(false)
.truncate(true)
.open(data_dir.join("bookmarks.txt")) .open(data_dir.join("bookmarks.txt"))
.await?; .await?;

View File

@ -72,6 +72,6 @@ impl Downloader {
} }
}); });
return (tx, handle); (tx, handle)
} }
} }

51
src/player/error.rs Normal file
View File

@ -0,0 +1,51 @@
use std::ffi::NulError;
use crate::messages::Message;
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 bookmark track")]
Bookmark(eyre::Error),
#[error("unable to find data directory")]
DataDir,
}

View File

@ -10,7 +10,7 @@ use mpris_server::{
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use super::ui; use super::ui;
use super::Messages; use super::Message;
const ERROR: fdo::Error = fdo::Error::Failed(String::new()); const ERROR: fdo::Error = fdo::Error::Failed(String::new());
@ -21,7 +21,7 @@ pub struct Player {
/// The audio server sender, which is used to communicate with /// The audio server sender, which is used to communicate with
/// the audio sender for skips and a few other inputs. /// the audio sender for skips and a few other inputs.
pub sender: Sender<Messages>, pub sender: Sender<Message>,
} }
impl RootInterface for Player { impl RootInterface for Player {
@ -31,7 +31,7 @@ impl RootInterface for Player {
async fn quit(&self) -> fdo::Result<()> { async fn quit(&self) -> fdo::Result<()> {
self.sender self.sender
.send(Messages::Quit) .send(Message::Quit)
.await .await
.map_err(|_error| ERROR) .map_err(|_error| ERROR)
} }
@ -80,7 +80,7 @@ impl RootInterface for Player {
impl PlayerInterface for Player { impl PlayerInterface for Player {
async fn next(&self) -> fdo::Result<()> { async fn next(&self) -> fdo::Result<()> {
self.sender self.sender
.send(Messages::Next) .send(Message::Next)
.await .await
.map_err(|_error| ERROR) .map_err(|_error| ERROR)
} }
@ -91,14 +91,14 @@ impl PlayerInterface for Player {
async fn pause(&self) -> fdo::Result<()> { async fn pause(&self) -> fdo::Result<()> {
self.sender self.sender
.send(Messages::Pause) .send(Message::Pause)
.await .await
.map_err(|_error| ERROR) .map_err(|_error| ERROR)
} }
async fn play_pause(&self) -> fdo::Result<()> { async fn play_pause(&self) -> fdo::Result<()> {
self.sender self.sender
.send(Messages::PlayPause) .send(Message::PlayPause)
.await .await
.map_err(|_error| ERROR) .map_err(|_error| ERROR)
} }
@ -109,7 +109,7 @@ impl PlayerInterface for Player {
async fn play(&self) -> fdo::Result<()> { async fn play(&self) -> fdo::Result<()> {
self.sender self.sender
.send(Messages::Play) .send(Message::Play)
.await .await
.map_err(|_error| ERROR) .map_err(|_error| ERROR)
} }
@ -247,10 +247,8 @@ impl Server {
pub async fn changed( pub async fn changed(
&self, &self,
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync, properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
) -> eyre::Result<()> { ) -> zbus::Result<()> {
self.inner.properties_changed(properties).await?; self.inner.properties_changed(properties).await
Ok(())
} }
/// Shorthand to emit a `PropertiesChanged` signal, specifically about playback. /// Shorthand to emit a `PropertiesChanged` signal, specifically about playback.
@ -266,7 +264,10 @@ impl Server {
} }
/// Creates a new MPRIS server. /// Creates a new MPRIS server.
pub async fn new(player: Arc<super::Player>, sender: Sender<Messages>) -> eyre::Result<Self> { pub async fn new(
player: Arc<super::Player>,
sender: Sender<Message>,
) -> eyre::Result<Self, zbus::Error> {
let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") { let suffix = if env::var("LOWFI_FIXED_MPRIS_NAME").is_ok_and(|x| x == "1") {
String::from("lowfi") String::from("lowfi")
} else { } else {

View File

@ -0,0 +1,70 @@
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

@ -2,7 +2,7 @@ use std::sync::Arc;
use tokio::{sync::mpsc::Sender, time::sleep}; use tokio::{sync::mpsc::Sender, time::sleep};
use crate::{ use crate::{
messages::Messages, messages::Message,
player::{downloader::Downloader, Player, TIMEOUT}, player::{downloader::Downloader, Player, TIMEOUT},
tracks, tracks,
}; };
@ -43,7 +43,7 @@ impl Player {
pub async fn next( pub async fn next(
player: Arc<Self>, player: Arc<Self>,
itx: Sender<()>, itx: Sender<()>,
tx: Sender<Messages>, tx: Sender<Message>,
debug: bool, debug: bool,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
// Stop the sink. // Stop the sink.
@ -61,18 +61,18 @@ impl Player {
Downloader::notify(&itx).await?; Downloader::notify(&itx).await?;
// Notify the audio server that the next song has actually been downloaded. // Notify the audio server that the next song has actually been downloaded.
tx.send(Messages::NewSong).await?; tx.send(Message::NewSong).await?;
} }
Err(error) => { Err(error) => {
if !error.is_timeout() { if !error.is_timeout() {
if debug { if debug {
panic!("{:?}", error) panic!("{error:?}")
} }
sleep(TIMEOUT).await; sleep(TIMEOUT).await;
} }
tx.send(Messages::TryAgain).await?; tx.send(Message::TryAgain).await?;
} }
}; };

View File

@ -32,7 +32,8 @@ use thiserror::Error;
use tokio::{sync::mpsc::Sender, task, time::sleep}; use tokio::{sync::mpsc::Sender, task, time::sleep};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use super::{Messages, Player}; use super::Player;
use crate::messages::Message;
mod components; mod components;
mod input; mod input;
@ -47,8 +48,8 @@ pub enum UIError {
#[error("unable to write output")] #[error("unable to write output")]
Write(#[from] std::io::Error), Write(#[from] std::io::Error),
#[error("sending message to backend failed")] #[error("sending message to backend from ui failed")]
Communication(#[from] tokio::sync::mpsc::error::SendError<Messages>), Communication(#[from] tokio::sync::mpsc::error::SendError<Message>),
} }
/// How long the audio bar will be visible for when audio is adjusted. /// How long the audio bar will be visible for when audio is adjusted.
@ -204,7 +205,7 @@ async fn interface(
window.draw(menu, false)?; window.draw(menu, false)?;
let delta = 1.0 / (fps as f32); let delta = 1.0 / f32::from(fps);
sleep(Duration::from_secs_f32(delta)).await; sleep(Duration::from_secs_f32(delta)).await;
} }
} }
@ -284,10 +285,11 @@ impl Drop for Environment {
/// previous terminal history. /// previous terminal history.
pub async fn start( pub async fn start(
player: Arc<Player>, player: Arc<Player>,
sender: Sender<Messages>, sender: Sender<Message>,
args: Args, args: Args,
) -> eyre::Result<(), UIError> { ) -> eyre::Result<(), UIError> {
let environment = Environment::ready(args.alternate)?; let environment = Environment::ready(args.alternate)?;
let interface = task::spawn(interface( let interface = task::spawn(interface(
Arc::clone(&player), Arc::clone(&player),
args.minimalist, args.minimalist,

View File

@ -88,7 +88,7 @@ impl ActionBar {
|(subject, len)| { |(subject, len)| {
( (
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()), format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
word.len() + 1 + len + if star { 1 } else { 0 }, word.len() + 1 + len + usize::from(star),
) )
}, },
) )

View File

@ -7,11 +7,11 @@ use tokio::sync::mpsc::Sender;
use crate::player::{ use crate::player::{
ui::{self, UIError}, ui::{self, UIError},
Messages, Message,
}; };
/// Starts the listener to recieve input from the terminal for various events. /// Starts the listener to recieve input from the terminal for various events.
pub async fn listen(sender: Sender<Messages>) -> eyre::Result<(), UIError> { pub async fn listen(sender: Sender<Message>) -> eyre::Result<(), UIError> {
let mut reader = EventStream::new(); let mut reader = EventStream::new();
loop { loop {
@ -25,29 +25,29 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<(), UIError> {
let messages = match event.code { let messages = match event.code {
// Arrow key volume controls. // Arrow key volume controls.
KeyCode::Up => Messages::ChangeVolume(0.1), KeyCode::Up => Message::ChangeVolume(0.1),
KeyCode::Right => Messages::ChangeVolume(0.01), KeyCode::Right => Message::ChangeVolume(0.01),
KeyCode::Down => Messages::ChangeVolume(-0.1), KeyCode::Down => Message::ChangeVolume(-0.1),
KeyCode::Left => Messages::ChangeVolume(-0.01), KeyCode::Left => Message::ChangeVolume(-0.01),
KeyCode::Char(character) => match character.to_ascii_lowercase() { KeyCode::Char(character) => match character.to_ascii_lowercase() {
// Ctrl+C // Ctrl+C
'c' if event.modifiers == KeyModifiers::CONTROL => Messages::Quit, 'c' if event.modifiers == KeyModifiers::CONTROL => Message::Quit,
// Quit // Quit
'q' => Messages::Quit, 'q' => Message::Quit,
// Skip/Next // Skip/Next
's' | 'n' | 'l' => Messages::Next, 's' | 'n' | 'l' => Message::Next,
// Pause // Pause
'p' | ' ' => Messages::PlayPause, 'p' | ' ' => Message::PlayPause,
// Volume up & down // Volume up & down
'+' | '=' | 'k' => Messages::ChangeVolume(0.1), '+' | '=' | 'k' => Message::ChangeVolume(0.1),
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1), '-' | '_' | 'j' => Message::ChangeVolume(-0.1),
// Bookmark // Bookmark
'b' => Messages::Bookmark, 'b' => Message::Bookmark,
_ => continue, _ => continue,
}, },
@ -55,18 +55,18 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<(), UIError> {
KeyCode::Media(media) => match media { KeyCode::Media(media) => match media {
event::MediaKeyCode::Pause event::MediaKeyCode::Pause
| event::MediaKeyCode::Play | event::MediaKeyCode::Play
| event::MediaKeyCode::PlayPause => Messages::PlayPause, | event::MediaKeyCode::PlayPause => Message::PlayPause,
event::MediaKeyCode::Stop => Messages::Pause, event::MediaKeyCode::Stop => Message::Pause,
event::MediaKeyCode::TrackNext => Messages::Next, event::MediaKeyCode::TrackNext => Message::Next,
event::MediaKeyCode::LowerVolume => Messages::ChangeVolume(-0.1), event::MediaKeyCode::LowerVolume => Message::ChangeVolume(-0.1),
event::MediaKeyCode::RaiseVolume => Messages::ChangeVolume(0.1), event::MediaKeyCode::RaiseVolume => Message::ChangeVolume(0.1),
event::MediaKeyCode::MuteVolume => Messages::ChangeVolume(-1.0), event::MediaKeyCode::MuteVolume => Message::ChangeVolume(-1.0),
_ => continue, _ => continue,
}, },
_ => continue, _ => continue,
}; };
if let Messages::ChangeVolume(_) = messages { if let Message::ChangeVolume(_) = messages {
ui::flash_audio(); ui::flash_audio();
} }

View File

@ -51,8 +51,8 @@ pub enum TrackError {
} }
impl TrackError { impl TrackError {
pub fn is_timeout(&self) -> bool { pub const fn is_timeout(&self) -> bool {
return matches!(self, TrackError::Timeout); matches!(self, Self::Timeout)
} }
} }
@ -124,10 +124,8 @@ pub struct Info {
impl Info { impl Info {
/// Decodes a URL string into normal UTF-8. /// Decodes a URL string into normal UTF-8.
fn decode_url(text: &str) -> String { fn decode_url(text: &str) -> String {
#[expect( // The tuple contains smart pointers, so it's not really practical to use `into()`.
clippy::tuple_array_conversions, #[allow(clippy::tuple_array_conversions)]
reason = "the tuple contains smart pointers, so it's not really practical to use `into()`"
)]
form_urlencoded::parse(text.as_bytes()) form_urlencoded::parse(text.as_bytes())
.map(|(key, val)| [key, val].concat()) .map(|(key, val)| [key, val].concat())
.collect() .collect()
@ -172,10 +170,8 @@ impl Info {
if skip == formatted.len() { if skip == formatted.len() {
Ok(formatted) Ok(formatted)
} else { } else {
#[expect( // We've already checked before that the bound is at an ASCII digit.
clippy::string_slice, #[allow(clippy::string_slice)]
reason = "We've already checked before that the bound is at an ASCII digit."
)]
Ok(String::from(&formatted[skip..])) Ok(String::from(&formatted[skip..]))
} }
} }

View File

@ -17,7 +17,7 @@ use super::QueuedTrack;
#[derive(Clone)] #[derive(Clone)]
pub struct List { pub struct List {
/// The "name" of the list, usually derived from a filename. /// The "name" of the list, usually derived from a filename.
#[allow(dead_code, reason = "this code may not be dead depending on features")] #[allow(dead_code)]
pub name: String, pub name: String,
/// Just the raw file, but seperated by `/n` (newlines). /// Just the raw file, but seperated by `/n` (newlines).
@ -61,11 +61,11 @@ impl List {
}; };
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") { let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
let path = if x.starts_with("~") { let path = if x.starts_with('~') {
let home_path = dirs::home_dir().ok_or(TrackError::InvalidPath)?; let home_path = dirs::home_dir().ok_or(TrackError::InvalidPath)?;
let home = home_path.to_str().ok_or(TrackError::InvalidPath)?; let home = home_path.to_str().ok_or(TrackError::InvalidPath)?;
x.replace("~", home) x.replace('~', home)
} else { } else {
x.to_owned() x.to_owned()
}; };
@ -97,14 +97,15 @@ impl List {
let (path, custom_name) = self.random_path(); let (path, custom_name) = self.random_path();
let (data, full_path) = self.download(&path, client).await?; let (data, full_path) = self.download(&path, client).await?;
let name = custom_name.map_or(super::TrackName::Raw(path.clone()), |formatted| { let name = custom_name.map_or_else(
super::TrackName::Formatted(formatted) || super::TrackName::Raw(path.clone()),
}); |formatted| super::TrackName::Formatted(formatted),
);
Ok(QueuedTrack { Ok(QueuedTrack {
name, name,
data,
full_path, full_path,
data,
}) })
} }