feat: mostly reimplement UI and track loading

This commit is contained in:
talwat 2025-11-16 00:06:48 +01:00
parent 09dd58664b
commit bf1b5f4f4e
25 changed files with 2188 additions and 154 deletions

8
Cargo.lock generated
View File

@ -265,12 +265,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_float"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a"
[[package]]
name = "autocfg"
version = "1.4.0"
@ -1491,7 +1485,6 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
name = "lowfi"
version = "1.7.2"
dependencies = [
"atomic_float",
"bytes",
"clap",
"convert_case 0.8.0",
@ -1502,6 +1495,7 @@ dependencies = [
"futures",
"html-escape",
"indicatif",
"lazy_static",
"libc",
"mpris-server",
"regex",

View File

@ -54,7 +54,7 @@ scraper = { version = "0.21.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"
lazy_static = "1.5.0"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.167"

View File

@ -37,4 +37,4 @@ pub fn silent_get_output_stream() -> eyre::Result<rodio::OutputStream, crate::Er
}
Ok(stream)
}
}

99
src/bookmark.rs Normal file
View File

@ -0,0 +1,99 @@
//! Module for handling saving, loading, and adding bookmarks.
use std::path::PathBuf;
use tokio::{fs, io};
use crate::{data_dir, tracks};
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.
entries: Vec<String>,
/// The internal bookmarked register, which keeps track
/// of whether a track is bookmarked or not.
bookmarked: bool,
}
impl Bookmarks {
/// Gets the path of the bookmarks file.
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 the `bookmarks.txt` file.
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_string())
}
})
.collect();
Ok(Self {
entries,
bookmarked: false,
})
}
// Saves the bookmarks to the `bookmarks.txt` file.
pub async fn save(&self) -> Result<()> {
let text = format!("noheader\n{}", self.entries.join("\n"));
fs::write(Self::path().await?, text).await?;
Ok(())
}
/// Bookmarks a given track with a full path and optional custom name.
///
/// Returns whether the track is now bookmarked, or not.
pub async fn bookmark(&mut self, track: &tracks::Info) -> Result<()> {
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);
};
self.bookmarked = idx.is_none();
Ok(())
}
/// Returns whether a track is bookmarked or not by using the internal
/// bookmarked register.
pub fn bookmarked(&self) -> bool {
self.bookmarked
}
/// Sets the internal bookmarked register by checking against
/// the current track's info.
pub async fn set_bookmarked(&mut self, track: &tracks::Info) {
self.bookmarked = self.entries.contains(&track.to_entry());
}
}

View File

@ -1,27 +1,72 @@
use tokio::sync::mpsc::{self, Receiver, Sender};
use std::{
sync::{atomic::AtomicU8, Arc},
time::Duration,
};
use reqwest::Client;
use tokio::{
sync::mpsc::{self, Receiver, Sender},
task::JoinHandle,
};
use crate::tracks;
pub struct Downloader {
/// TODO: Actually have a track type here.
queue: Receiver<()>,
handle: crate::Handle,
pub progress: Arc<AtomicU8>,
queue: Receiver<tracks::Queued>,
handle: JoinHandle<crate::Result<()>>,
}
impl Downloader {
async fn downloader(tx: Sender<()>) -> crate::Result<()> {
// todo
Ok(())
pub async fn track(&mut self) -> Option<tracks::Queued> {
return self.queue.recv().await;
}
pub async fn init(buffer_size: usize) -> Self {
let (tx, rx) = mpsc::channel(buffer_size);
async fn downloader(
tx: Sender<tracks::Queued>,
tracks: tracks::List,
client: Client,
progress: Arc<AtomicU8>,
timeout: Duration,
) -> crate::Result<()> {
loop {
let result = tracks.random(&client, progress.as_ref()).await;
match result {
Ok(track) => tx.send(track).await?,
Err(error) => {
if !error.timeout() {
tokio::time::sleep(timeout).await;
}
}
}
}
}
pub async fn init(
size: usize,
tracks: tracks::List,
client: Client,
progress: Arc<AtomicU8>,
) -> Self {
let (tx, rx) = mpsc::channel(size);
Self {
queue: rx,
handle: tokio::spawn(Self::downloader(tx)),
progress: progress.clone(),
handle: tokio::spawn(Self::downloader(
tx,
tracks,
client,
progress,
Duration::from_secs(1),
)),
}
}
}
pub async fn downloader() {
}
impl Drop for Downloader {
fn drop(&mut self) {
self.handle.abort();
}
}

View File

@ -1,8 +1,16 @@
use tokio::sync::mpsc;
use crate::{bookmark, tracks, ui, volume};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Kind {
pub enum Error {
#[error("unable to load/save the persistent volume: {0}")]
PersistentVolume(#[from] volume::Error),
#[error("unable to load/save bookmarks: {0}")]
Bookmarks(#[from] bookmark::Error),
#[error("unable to fetch data: {0}")]
Request(#[from] reqwest::Error),
@ -14,75 +22,32 @@ pub enum Kind {
#[error("couldn't send internal message: {0}")]
Send(#[from] mpsc::error::SendError<crate::Message>),
#[error("couldn't add track to the queue: {0}")]
Queue(#[from] mpsc::error::SendError<tracks::Queued>),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("directory not found")]
Directory,
#[error("couldn't parse integer: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("track error: {0}")]
Track(#[from] tracks::Error),
#[error("ui error: {0}")]
UI(#[from] ui::Error),
#[cfg(feature = "mpris")]
#[error("mpris bus error")]
ZBus(#[from] mpris_server::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] mpris_server::zbus::fdo::Error),
}
#[derive(Debug, Default)]
pub struct Context {
track: Option<String>,
}
impl std::fmt::Display for Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(track) = &self.track {
write!(f, " ")?;
write!(f, "(track: {track})")?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[error("{kind}{context}")]
pub struct Error {
pub context: Context,
#[source]
pub kind: Kind,
}
impl<T, E> From<(T, E)> for Error
where
T: Into<String>,
Kind: From<E>,
{
fn from((track, err): (T, E)) -> Self {
Self {
context: Context { track: Some(track.into()) },
kind: Kind::from(err),
}
}
}
impl<E> From<E> for Error
where
Kind: From<E>,
{
fn from(err: E) -> Self {
Self {
context: Context::default(),
kind: Kind::from(err),
}
}
}
pub trait WithContextExt<T> {
fn context(self, name: impl Into<String>) -> std::result::Result<T, Error>;
}
impl<T, E> WithContextExt<T> for std::result::Result<T, E>
where
(String, E): Into<Error>,
E: Into<Kind>,
{
fn context(self, name: impl Into<String>) -> std::result::Result<T, Error> {
self.map_err(|e| {
let error = match e.into() {
Kind::Request(error) => Kind::Request(error.without_url()),
kind => kind,
};
(name.into(), error).into()
})
}
}

View File

@ -1,52 +1,115 @@
//! An extremely simple lofi player.
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
pub mod error;
use crate::{download::Downloader, ui::UI};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
pub use error::{Error, Result};
pub mod message;
pub mod ui;
pub use message::Message;
use tokio::sync::mpsc::{self, Receiver};
use crate::player::Player;
pub mod audio;
pub mod bookmark;
pub mod download;
pub mod player;
#[allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)]
#[cfg(feature = "scrape")]
mod scrapers;
pub mod tracks;
pub mod volume;
pub type Handle = tokio::task::JoinHandle<crate::Result<()>>;
#[cfg(feature = "scrape")]
use crate::scrapers::Source;
pub struct Player {
ui: UI,
downloader: Downloader,
sink: rodio::Sink,
stream: rodio::OutputStream,
rx: Receiver<crate::Message>,
/// An extremely simple lofi player.
#[derive(Parser, Clone)]
#[command(about, version)]
#[allow(clippy::struct_excessive_bools)]
pub struct Args {
/// Use an alternate terminal screen.
#[clap(long, short)]
alternate: bool,
/// Hide the bottom control bar.
#[clap(long, short)]
minimalist: bool,
/// Exclude borders in UI.
#[clap(long, short)]
borderless: bool,
/// Start lowfi paused.
#[clap(long, short)]
paused: bool,
/// FPS of the UI.
#[clap(long, short, default_value_t = 12)]
fps: u8,
/// Timeout in seconds for music downloads.
#[clap(long, default_value_t = 3)]
timeout: u64,
/// Include ALSA & other logs.
#[clap(long, short)]
debug: bool,
/// Width of the player, from 0 to 32.
#[clap(long, short, default_value_t = 3)]
width: usize,
/// 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,
/// The command that was ran.
/// This is [None] if no command was specified.
#[command(subcommand)]
command: Option<Commands>,
}
impl Player {
pub async fn init() -> crate::Result<Self> {
#[cfg(target_os = "linux")]
let mut stream = audio::silent_get_output_stream()?;
#[cfg(not(target_os = "linux"))]
let mut stream = rodio::OutputStreamBuilder::open_default_stream()?;
/// Defines all of the extra commands lowfi can run.
#[derive(Subcommand, Clone)]
enum Commands {
/// Scrapes a music source for files.
#[cfg(feature = "scrape")]
Scrape {
// The source to scrape from.
source: scrapers::Source,
},
}
stream.log_on_drop(false);
let sink = rodio::Sink::connect_new(stream.mixer());
let (tx, rx) = mpsc::channel(8);
/// Gets lowfi's data directory.
pub fn data_dir() -> crate::Result<PathBuf> {
let dir = dirs::data_dir().unwrap().join("lowfi");
Ok(Self {
downloader: Downloader::init(5).await,
ui: UI::init(tx).await,
rx,
sink,
stream,
})
}
Ok(dir)
}
#[tokio::main]
pub async fn main() -> crate::Result<()> {
let mut player: Player = Player::init().await?;
player.ui.render(ui::Render { track: "test".to_owned() }).await?;
while let Some(message) = player.rx.recv().await {
if message == Message::Quit { break };
}
async fn main() -> eyre::Result<()> {
let args = Args::parse();
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 {
let player = Player::init(args).await?;
player.play().await?;
};
Ok(())
}

View File

@ -4,7 +4,7 @@ use crate::ui;
#[derive(PartialEq, Debug, Clone)]
pub enum Message {
/// Sent to update the UI with new information.
Render(ui::Render),
Render(ui::Update),
/// Notifies the audio server that it should update the track.
Next,
@ -30,4 +30,4 @@ pub enum Message {
/// Quits gracefully.
Quit,
}
}

281
src/mpris.rs Normal file
View File

@ -0,0 +1,281 @@
//! Contains the code for the MPRIS server & other helper functions.
use std::{env, process, sync::Arc};
use mpris_server::{
zbus::{self, fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Time, TrackId, Volume,
};
use tokio::sync::mpsc::Sender;
use super::ui;
use super::Message;
const ERROR: fdo::Error = fdo::Error::Failed(String::new());
/// 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>,
}
impl RootInterface for Player {
async fn raise(&self) -> fdo::Result<()> {
Err(ERROR)
}
async fn quit(&self) -> fdo::Result<()> {
self.sender
.send(Message::Quit)
.await
.map_err(|_error| ERROR)
}
async fn can_quit(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_fullscreen(&self, _: bool) -> Result<()> {
Ok(())
}
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_raise(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn has_track_list(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn identity(&self) -> fdo::Result<String> {
Ok("lowfi".to_owned())
}
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok("dev.talwat.lowfi".to_owned())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["https".to_owned()])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["audio/mpeg".to_owned()])
}
}
impl PlayerInterface for Player {
async fn next(&self) -> fdo::Result<()> {
self.sender
.send(Message::Next)
.await
.map_err(|_error| ERROR)
}
async fn previous(&self) -> fdo::Result<()> {
Err(ERROR)
}
async fn pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::Pause)
.await
.map_err(|_error| ERROR)
}
async fn play_pause(&self) -> fdo::Result<()> {
self.sender
.send(Message::PlayPause)
.await
.map_err(|_error| ERROR)
}
async fn stop(&self) -> fdo::Result<()> {
self.pause().await
}
async fn play(&self) -> fdo::Result<()> {
self.sender
.send(Message::Play)
.await
.map_err(|_error| ERROR)
}
async fn seek(&self, _offset: Time) -> fdo::Result<()> {
Err(ERROR)
}
async fn set_position(&self, _track_id: TrackId, _position: Time) -> fdo::Result<()> {
Err(ERROR)
}
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
Err(ERROR)
}
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
Ok(if !self.player.current_exists() {
PlaybackStatus::Stopped
} else if self.player.sink.is_paused() {
PlaybackStatus::Paused
} else {
PlaybackStatus::Playing
})
}
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
Err(ERROR)
}
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
Ok(())
}
async fn rate(&self) -> fdo::Result<PlaybackRate> {
Ok(self.player.sink.speed().into())
}
async fn set_rate(&self, rate: PlaybackRate) -> Result<()> {
self.player.sink.set_speed(rate as f32);
Ok(())
}
async fn shuffle(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
Ok(())
}
async fn metadata(&self) -> fdo::Result<Metadata> {
let metadata = self
.player
.current
.load()
.as_ref()
.map_or_else(Metadata::new, |track| {
let mut metadata = Metadata::builder()
.title(track.display_name.clone())
.album(self.player.list.name.clone())
.build();
metadata.set_length(
track
.duration
.map(|x| Time::from_micros(x.as_micros() as i64)),
);
metadata
});
Ok(metadata)
}
async fn volume(&self) -> fdo::Result<Volume> {
Ok(self.player.sink.volume().into())
}
async fn set_volume(&self, volume: Volume) -> Result<()> {
self.player.set_volume(volume as f32);
ui::flash_audio();
Ok(())
}
async fn position(&self) -> fdo::Result<Time> {
Ok(Time::from_micros(
self.player.sink.get_pos().as_micros() as i64
))
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(0.2f64)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(3.0f64)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_play(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_pause(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_seek(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_control(&self) -> fdo::Result<bool> {
Ok(true)
}
}
/// A struct which contains the MPRIS [Server], and has some helper functions
/// to make it easier to work with.
pub struct Server {
/// The inner MPRIS server.
inner: mpris_server::Server<Player>,
}
impl Server {
/// Shorthand to emit a `PropertiesChanged` signal, like when pausing/unpausing.
pub async fn changed(
&self,
properties: impl IntoIterator<Item = mpris_server::Property> + Send + Sync,
) -> zbus::Result<()> {
self.inner.properties_changed(properties).await
}
/// 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
}
/// Shorthand to get the inner mpris player object.
pub fn player(&self) -> &Player {
self.inner.imp()
}
/// Creates a new MPRIS server.
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") {
String::from("lowfi")
} else {
format!("lowfi.{}.instance{}", player.list.name, process::id())
};
let server = mpris_server::Server::new(&suffix, Player { player, sender }).await?;
Ok(Self { inner: server })
}
}

74
src/player.rs Normal file
View File

@ -0,0 +1,74 @@
use std::sync::{atomic::AtomicU8, Arc};
use reqwest::Client;
use tokio::sync::mpsc::{self, Receiver};
use crate::{
bookmark::Bookmarks, download::Downloader, tracks::List, ui::UI, volume::PersistentVolume,
Message,
};
pub struct Player {
ui: UI,
volume: PersistentVolume,
bookmarks: Bookmarks,
downloader: Downloader,
sink: Arc<rodio::Sink>,
stream: rodio::OutputStream,
rx: Receiver<crate::Message>,
}
impl Drop for Player {
fn drop(&mut self) {
self.sink.stop();
}
}
impl Player {
pub async fn init(args: crate::Args) -> crate::Result<Self> {
#[cfg(target_os = "linux")]
let mut stream = audio::silent_get_output_stream()?;
#[cfg(not(target_os = "linux"))]
let mut stream = rodio::OutputStreamBuilder::open_default_stream()?;
stream.log_on_drop(false);
let sink = Arc::new(rodio::Sink::connect_new(stream.mixer()));
let progress = Arc::new(AtomicU8::new(0));
let (tx, rx) = mpsc::channel(8);
let ui = UI::init(tx, progress.clone(), sink.clone(), &args).await?;
let volume = PersistentVolume::load().await?;
let bookmarks = Bookmarks::load().await?;
let client = Client::new();
let list = List::load(args.track_list.as_ref()).await?;
let downloader = Downloader::init(args.buffer_size, list, client, progress).await;
Ok(Self {
downloader,
ui,
rx,
sink,
stream,
bookmarks,
volume,
})
}
pub async fn play(mut self) -> crate::Result<()> {
// self.ui
// .render(ui::Update {
// track: None,
// bookmarked: false,
// })
// .await?;
while let Some(message) = self.rx.recv().await {
if message == Message::Quit {
break;
};
}
Ok(())
}
}

88
src/scrapers.rs Normal file
View File

@ -0,0 +1,88 @@
use std::path::{Path, PathBuf};
use clap::ValueEnum;
use eyre::bail;
use reqwest::Client;
use tokio::{
fs::{self, File},
io::AsyncWriteExt,
};
pub mod archive;
pub mod chillhop;
pub mod lofigirl;
/// Represents the different sources which can be scraped.
#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
pub enum Source {
Lofigirl,
Archive,
Chillhop,
}
impl Source {
/// Gets the cache directory name, for example, `chillhop`.
pub fn cache_dir(&self) -> &'static str {
match self {
Source::Lofigirl => "lofigirl",
Source::Archive => "archive",
Source::Chillhop => "chillhop",
}
}
/// Gets the full root URL of the source.
pub fn url(&self) -> &'static str {
match self {
Source::Chillhop => "https://chillhop.com",
Source::Archive => "https://ia601004.us.archive.org/31/items/lofigirl",
Source::Lofigirl => "https://lofigirl.com/wp-content/uploads",
}
}
}
/// Sends a get request, with caching.
async fn get(client: &Client, path: &str, source: Source) -> eyre::Result<String> {
let trimmed = path.trim_matches('/');
let cache = PathBuf::from(format!("./cache/{}/{trimmed}.html", source.cache_dir()));
if let Ok(x) = fs::read_to_string(&cache).await {
Ok(x)
} else {
let resp = client
.get(format!("{}/{trimmed}", source.url()))
.send()
.await?;
let status = resp.status();
if status == 429 {
bail!("rate limit reached: {path}");
}
if status != 404 && !status.is_success() && !status.is_redirection() {
bail!("non success code {}: {path}", resp.status().as_u16());
}
let text = resp.text().await?;
let parent = cache.parent();
if let Some(x) = parent {
if x != Path::new("") {
fs::create_dir_all(x).await?;
}
}
let mut file = File::create(&cache).await?;
file.write_all(text.as_bytes()).await?;
if status.is_redirection() {
bail!("redirect: {path}")
}
if status == 404 {
bail!("not found: {path}")
}
Ok(text)
}
}

74
src/scrapers/archive.rs Normal file
View File

@ -0,0 +1,74 @@
//! Has all of the functions for the `scrape` command.
//!
//! This command is completely optional, and as such isn't subject to the same
//! quality standards as the rest of the codebase.
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();
}
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;
let html = Html::parse_document(&document);
Ok(html
.select(&SELECTOR)
.skip(1)
.map(|x| String::from(x.attr("href").unwrap()))
.collect())
}
/// This function basically just scans the entire file server, and returns a list of paths to mp3 files.
///
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
async fn scan() -> eyre::Result<Vec<String>> {
let client = Client::new();
let mut releases = parse(&client, "/").await?;
releases.truncate(releases.len() - 4);
// A little bit of async to run all of the months concurrently.
let mut futures = FuturesOrdered::new();
for release in releases {
let client = client.clone();
futures.push_back(async move {
let items = parse(&client, &release).await.unwrap();
items
.into_iter()
.filter_map(|x| {
if x.ends_with(".mp3") {
Some(format!("{release}{x}"))
} else {
None
}
})
.collect::<Vec<String>>()
});
}
let mut files = Vec::new();
while let Some(mut result) = futures.next().await {
files.append(&mut result);
}
eyre::Result::Ok(files)
}
pub async fn scrape() -> eyre::Result<()> {
println!("{}/", Source::Lofigirl.url());
let files = scan().await?;
for file in files {
println!("{file}");
}
Ok(())
}

223
src/scrapers/chillhop.rs Normal file
View File

@ -0,0 +1,223 @@
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 reqwest::Client;
use scraper::{Html, Selector};
use serde::{
de::{self, Visitor},
Deserialize, Deserializer,
};
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();
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Track {
title: String,
#[serde(deserialize_with = "deserialize_u32_from_string")]
file_id: u32,
artists: String,
}
impl Track {
pub fn clean(&mut self) {
self.artists = html_escape::decode_html_entities(&self.artists).to_string();
self.title = html_escape::decode_html_entities(&self.title).to_string();
}
}
#[derive(Deserialize, Debug)]
struct Release {
#[serde(skip)]
pub path: String,
#[serde(skip)]
pub index: usize,
pub tracks: Vec<Track>,
}
#[derive(thiserror::Error, Debug)]
enum ReleaseError {
#[error("invalid release: {0}")]
Invalid(#[from] eyre::Error),
}
impl Release {
pub async fn scan(
path: String,
index: usize,
client: Client,
bar: ProgressBar,
) -> Result<Self, ReleaseError> {
let content = get(&client, &path, Source::Chillhop).await?;
let html = Html::parse_document(&content);
let textarea = html
.select(&RELEASE_TEXTAREA)
.next()
.ok_or(eyre!("unable to find textarea: {path}"))?;
let mut release: Self = serde_json::from_str(&textarea.inner_html()).unwrap();
release.path = path;
release.index = index;
release.tracks.reverse();
bar.inc(release.tracks.len() as u64);
Ok(release)
}
}
async fn scan_page(
number: usize,
client: &Client,
bar: ProgressBar,
) -> eyre::Result<Vec<impl futures::Future<Output = Result<Release, ReleaseError>>>> {
let path = format!("releases/?page={number}");
let content = get(client, &path, Source::Chillhop).await?;
let html = Html::parse_document(&content);
let elements = html.select(&RELEASES);
Ok(elements
.enumerate()
.filter_map(|(i, x)| {
let label = x.select(&RELEASE_LABEL).next()?.inner_html();
if label == "Compilation" {
return None;
}
Some(Release::scan(
x.attr("href")?.to_string(),
(number * 12) + i,
client.clone(),
bar.clone(),
))
})
.collect())
}
pub async fn scrape() -> eyre::Result<()> {
const PAGE_COUNT: usize = 40;
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
const TRACK_COUNT: u64 = 1625;
const IGNORED_TRACKS: [u32; 24] = [
// 404
74707, // Lyrics
21655, 21773, 8172, 55397, 75135, 24827, 8141, 8157, 64052, 31612, 41956, 8001, 9217, 8730,
55372, 9262, 30131, 9372, // Abnormal
8469, 7832, 10448, 9446, 9396,
];
const IGNORED_ARTISTS: [&str; 1] = [
"Kenji", // Lyrics
];
fs::create_dir_all("./cache/chillhop").await.unwrap();
let client = Client::builder().user_agent(USER_AGENT).build().unwrap();
let futures = FuturesUnordered::new();
let bar = ProgressBar::new(TRACK_COUNT + (12 * (PAGE_COUNT as u64)));
let mut errors = Vec::new();
// This is slightly less memory efficient than I'd hope, but it is what it is.
for page in 0..=PAGE_COUNT {
bar.inc(12);
for x in scan_page(page, &client, bar.clone()).await? {
futures.push(x);
}
}
let mut results: Vec<Result<Release, ReleaseError>> = futures.collect().await;
bar.finish_and_clear();
// I mean, is it... optimal? Absolutely not. Does it work? Yes.
eprintln!("sorting...");
results.sort_by_key(|x| if let Ok(x) = x { x.index } else { 0 });
results.reverse();
eprintln!("printing...");
let mut printed = Vec::with_capacity(TRACK_COUNT as usize); // Lazy way to get rid of dupes.
for result in results {
let release = match result {
Ok(release) => release,
Err(error) => {
errors.push(error);
continue;
}
};
for mut track in release.tracks {
if IGNORED_TRACKS.contains(&track.file_id) {
continue;
}
if IGNORED_ARTISTS.contains(&track.artists.as_ref()) {
continue;
}
if printed.contains(&track.file_id) {
continue;
}
printed.push(track.file_id);
track.clean();
println!("{}!{}", track.file_id, track.title);
}
}
eprintln!("-- ERROR REPORT --");
for error in errors {
eprintln!("{error}");
}
Ok(())
}
pub fn deserialize_u32_from_string<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
struct U32FromStringVisitor;
impl<'de> Visitor<'de> for U32FromStringVisitor {
type Value = u32;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string containing an unsigned 32-bit integer")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
u32::from_str(value).map_err(|_| {
de::Error::invalid_value(
de::Unexpected::Str(value),
&"a valid unsigned 32-bit integer",
)
})
}
}
deserializer.deserialize_str(U32FromStringVisitor)
}

87
src/scrapers/lofigirl.rs Normal file
View File

@ -0,0 +1,87 @@
//! Has all of the functions for the `scrape` command.
//!
//! This command is completely optional, and as such isn't subject to the same
//! quality standards as the rest of the codebase.
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();
}
async fn parse(client: &Client, path: &str) -> eyre::Result<Vec<String>> {
let document = get(client, path, super::Source::Lofigirl).await?;
let html = Html::parse_document(&document);
Ok(html
.select(&SELECTOR)
.skip(5)
.map(|x| String::from(x.attr("href").unwrap()))
.collect())
}
/// This function basically just scans the entire file server, and returns a list of paths to mp3 files.
///
/// It's a bit hacky, and basically works by checking all of the years, then months, and then all of the files.
/// This is done as a way to avoid recursion, since async rust really hates recursive functions.
async fn scan() -> eyre::Result<Vec<String>> {
let client = Client::new();
let items = parse(&client, "/").await?;
let mut years: Vec<u32> = items
.iter()
.filter_map(|x| {
let year = x.strip_suffix("/")?;
year.parse().ok()
})
.collect();
years.sort();
// A little bit of async to run all of the months concurrently.
let mut futures = FuturesOrdered::new();
for year in years {
let months = parse(&client, &year.to_string()).await?;
for month in months {
let client = client.clone();
futures.push_back(async move {
let path = format!("{}/{}", year, month);
let items = parse(&client, &path).await.unwrap();
items
.into_iter()
.filter_map(|x| {
if x.ends_with(".mp3") {
Some(format!("{path}{x}"))
} else {
None
}
})
.collect::<Vec<String>>()
});
}
}
let mut files = Vec::new();
while let Some(mut result) = futures.next().await {
files.append(&mut result);
}
eyre::Result::Ok(files)
}
pub async fn scrape() -> eyre::Result<()> {
let files = scan().await?;
for file in files {
println!("{file}");
}
Ok(())
}

138
src/tracks.rs Normal file
View File

@ -0,0 +1,138 @@
//! Has all of the structs for managing the state
//! 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.
//!
//! 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.
use std::{io::Cursor, time::Duration};
use bytes::Bytes;
use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation;
pub mod list;
pub use list::List;
pub mod error;
pub mod format;
pub use error::{Error, Result};
use crate::tracks::error::WithTrackContext;
/// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>;
/// 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 Queued {
/// Display name of the track.
pub display: String,
/// Full downloadable path/url of the track.
pub path: String,
/// The raw data of the track, which is not decoded and
/// therefore much more memory efficient.
pub data: Bytes,
}
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) -> Result<Decoded> {
Decoded::new(self)
}
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 {
path,
display,
data,
})
}
}
/// The [`Info`] struct, which has the name and duration of a track.
///
/// This is not included in [Track] as the duration has to be acquired
/// from the decoded data and not from the raw data.
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Info {
/// The full downloadable path/url of the track.
pub path: String,
/// This is a formatted name, so it doesn't include the full path.
pub display: String,
/// This is the *actual* terminal width of the track name, used to make
/// the UI consistent.
pub width: usize,
/// The duration of the track, this is an [Option] because there are
/// cases where the duration of a track is unknown.
pub duration: Option<Duration>,
}
impl Info {
/// Converts the info back into a full track list entry.
pub fn to_entry(&self) -> String {
let mut entry = self.path.clone();
entry.push('!');
entry.push_str(&self.display);
entry
}
/// 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.graphemes(true).count(),
path,
display,
})
}
}
/// This struct is seperate from [Track] since it is generated lazily from
/// a track, and not when the track is first downloaded.
pub struct Decoded {
/// Has both the formatted name and some information from the decoded data.
pub info: Info,
/// The decoded data, which is able to be played by [rodio].
pub data: DecodedData,
}
impl Decoded {
/// Creates a new track.
/// This is equivalent to [`QueuedTrack::decode`].
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_data(Cursor::new(track.data))
.build()
.track(track.display)?;
let info = Info::new(&data, path, display)?;
Ok(Self { info, data })
}
}

87
src/tracks/error.rs Normal file
View File

@ -0,0 +1,87 @@
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Kind {
#[error("unable to decode: {0}")]
Decode(#[from] rodio::decoder::DecoderError),
#[error("invalid name")]
InvalidName,
#[error("invalid file path")]
InvalidPath,
#[error("unknown target track length")]
UnknownLength,
#[error("unable to read file: {0}")]
File(#[from] std::io::Error),
#[error("unable to fetch data: {0}")]
Request(#[from] reqwest::Error),
}
#[derive(Debug, thiserror::Error)]
#[error("{kind} (track: {track:?})")]
pub struct Error {
pub track: Option<String>,
#[source]
pub kind: Kind,
}
impl Error {
pub fn timeout(&self) -> bool {
if let Kind::Request(x) = &self.kind {
x.is_timeout()
} else {
false
}
}
}
impl<T, E> From<(T, E)> for Error
where
T: Into<String>,
Kind: From<E>,
{
fn from((track, err): (T, E)) -> Self {
Self {
track: Some(track.into()),
kind: Kind::from(err),
}
}
}
impl<E> From<E> for Error
where
Kind: From<E>,
{
fn from(err: E) -> Self {
Self {
track: None,
kind: Kind::from(err),
}
}
}
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>) -> std::result::Result<T, Error> {
self.map_err(|e| {
let error = match e.into() {
Kind::Request(e) => Kind::Request(e.without_url()),
e => e,
};
(name.into(), error).into()
})
}
}

93
src/tracks/format.rs Normal file
View File

@ -0,0 +1,93 @@
use convert_case::{Case, Casing};
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use url::form_urlencoded;
use super::error::WithTrackContext;
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();
}
/// 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).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()))
}
}

182
src/tracks/list.rs Normal file
View File

@ -0,0 +1,182 @@
//! 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::{AtomicU8, Ordering},
};
use bytes::{BufMut, Bytes, BytesMut};
use futures::StreamExt;
use reqwest::Client;
use tokio::fs;
use crate::{
data_dir,
tracks::{
self,
error::{self, WithTrackContext},
},
};
use super::Queued;
/// Represents a list of tracks that can be played.
///
/// See the [README](https://github.com/talwat/lowfi?tab=readme-ov-file#the-format) for more details about the format.
#[derive(Clone)]
pub struct List {
/// The "name" of the list, usually derived from a filename.
#[allow(dead_code)]
pub name: String,
/// Just the raw file, but seperated by `/n` (newlines).
/// `lines[0]` is the base/heaeder, with the rest being tracks.
lines: Vec<String>,
/// The file path which the list was read from.
#[allow(dead_code)]
pub path: Option<String>,
}
impl List {
/// Gets the base URL of the [List].
pub fn base(&self) -> &str {
self.lines[0].trim()
}
/// Gets the path of a random track.
///
/// The second value in the tuple specifies whether the
/// track has a custom display name.
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
// how rust vectors work, since it is slower to drain only a single element from
// the start, so it's faster to just keep it in & work around it.
let random = fastrand::usize(1..self.lines.len());
let line = self.lines[random].clone();
if let Some((first, second)) = line.split_once('!') {
(first.to_owned(), Some(second.to_owned()))
} else {
(line, None)
}
}
/// Downloads a raw track, but doesn't decode it.
async fn download(
&self,
track: &str,
client: &Client,
progress: &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("://") {
track.to_owned()
} else {
format!("{}{}", self.base(), track)
};
let data: Bytes = if let Some(x) = full_path.strip_prefix("file://") {
let path = if x.starts_with('~') {
let home_path = dirs::home_dir()
.ok_or(error::Kind::InvalidPath)
.track(track)?;
let home = home_path
.to_str()
.ok_or(error::Kind::InvalidPath)
.track(track)?;
x.replace('~', home)
} else {
x.to_owned()
};
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 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)?;
downloaded = min(downloaded + (chunk.len() as u64), total);
let rounded = ((downloaded as f32) / (total as f32) * 100.0).round() as u8;
progress.store(rounded, Ordering::Relaxed);
bytes.put(chunk);
}
bytes.into()
};
Ok((data, full_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: &AtomicU8) -> tracks::Result<Queued> {
let (path, display) = self.random_path();
let (data, path) = self.download(&path, client, progress).await?;
Queued::new(path, data, display)
}
/// Parses text into a [List].
pub fn new(name: &str, text: &str, path: Option<&str>) -> Self {
let lines: Vec<String> = text
.trim_end()
.lines()
.map(|x| x.trim_end().to_owned())
.collect();
Self {
lines,
path: path.map(ToOwned::to_owned),
name: name.to_owned(),
}
}
/// Reads a [List] from the filesystem using the CLI argument provided.
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(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()))
}
}

140
src/ui.rs
View File

@ -1,22 +1,88 @@
use tokio::sync::mpsc::{self, Receiver, Sender};
use crate::Message;
mod input;
use std::{
sync::{atomic::AtomicU8, Arc},
time::Duration,
};
#[derive(Debug, Clone, PartialEq)]
pub struct Render {
pub track: String,
use crate::{
tracks,
ui::{environment::Environment, window::Window},
Args, Message,
};
use rodio::Sink;
use tokio::{
sync::mpsc::{self, Receiver, Sender},
task::JoinHandle,
};
mod components;
mod environment;
mod input;
mod interface;
mod window;
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")]
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>),
}
pub struct State {
pub sink: Arc<rodio::Sink>,
pub progress: Arc<AtomicU8>,
pub track: Option<tracks::Info>,
pub bookmarked: bool,
width: usize,
}
impl State {
pub fn update(&mut self, update: Update) {
self.track = update.track;
self.bookmarked = update.bookmarked;
}
pub fn initial(sink: Arc<rodio::Sink>, width: usize, progress: Arc<AtomicU8>) -> Self {
Self {
width,
sink,
progress,
track: None,
bookmarked: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Update {
pub track: Option<tracks::Info>,
pub bookmarked: bool,
}
#[derive(Debug)]
struct Handles {
render: crate::Handle,
input: crate::Handle,
render: JoinHandle<Result<()>>,
input: JoinHandle<Result<()>>,
}
#[derive(Copy, Clone, Debug)]
struct Params {
borderless: bool,
minimalist: bool,
delta: Duration,
}
#[derive(Debug)]
pub struct UI {
pub utx: Sender<Message>,
handles: Handles
handles: Handles,
_environment: Environment,
}
impl Drop for UI {
@ -27,32 +93,62 @@ impl Drop for UI {
}
impl UI {
pub async fn render(&mut self, data: Render) -> crate::Result<()> {
pub async fn render(&mut self, data: Update) -> Result<()> {
self.utx.send(Message::Render(data)).await?;
Ok(())
}
async fn ui(mut rx: Receiver<Message>) -> crate::Result<()> {
while let Some(message) = rx.recv().await {
let Message::Render(data) = message else {
continue;
async fn ui(mut rx: Receiver<Message>, mut state: State, params: Params) -> Result<()> {
let mut interval = tokio::time::interval(params.delta);
let mut window = Window::new(state.width, params.borderless);
loop {
interface::draw(&state, &mut window, params).await?;
if let Ok(message) = rx.try_recv() {
match message {
Message::Render(update) => state.update(update),
Message::Quit => break,
_ => continue,
}
};
eprintln!("data: {data:?}");
interval.tick().await;
}
// environment.cleanup()?;
Ok(())
}
pub async fn init(tx: Sender<Message>) -> Self {
let (utx, urx) = mpsc::channel(8);
pub async fn init(
tx: Sender<Message>,
progress: Arc<AtomicU8>,
sink: Arc<Sink>,
args: &Args,
) -> Result<Self> {
let environment = Environment::ready(args.alternate)?;
Self {
let (utx, urx) = mpsc::channel(8);
let delta = 1.0 / f32::from(args.fps);
let delta = Duration::from_secs_f32(delta);
let width = 21 + args.width.min(32) * 2;
Ok(Self {
utx,
_environment: environment,
handles: Handles {
render: tokio::spawn(Self::ui(urx)),
render: tokio::spawn(Self::ui(
urx,
State::initial(sink, width, progress),
Params {
delta,
minimalist: args.minimalist,
borderless: args.borderless,
},
)),
input: tokio::spawn(input::listen(tx)),
}
}
},
})
}
}
}

160
src/ui/components.rs Normal file
View File

@ -0,0 +1,160 @@
//! Various different individual components that
//! appear in lowfi's UI, like the progress bar.
use std::time::Duration;
use crossterm::style::Stylize as _;
use unicode_segmentation::UnicodeSegmentation as _;
use crate::{tracks, ui};
/// Small helper function to format durations.
pub fn format_duration(duration: &Duration) -> String {
let seconds = duration.as_secs() % 60;
let minutes = duration.as_secs() / 60;
format!("{minutes:02}:{seconds:02}")
}
/// Creates the progress bar, as well as all the padding needed.
pub fn progress_bar(state: &ui::State, width: usize) -> String {
let mut duration = Duration::new(0, 0);
let elapsed = if state.track.is_some() {
state.sink.get_pos()
} else {
Duration::new(0, 0)
};
let mut filled = 0;
if let Some(current) = &state.track {
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!(
" [{}{}] {}/{} ",
"/".repeat(filled),
" ".repeat(width.saturating_sub(filled)),
format_duration(&elapsed),
format_duration(&duration),
)
}
/// Creates the audio bar, as well as all the padding needed.
pub fn audio_bar(width: usize, volume: f32, percentage: &str) -> String {
let audio = (volume * width as f32).round() as usize;
format!(
" volume: [{}{}] {}{} ",
"/".repeat(audio),
" ".repeat(width.saturating_sub(audio)),
" ".repeat(4usize.saturating_sub(percentage.len())),
percentage,
)
}
/// This represents the main "action" bars state.
enum ActionBar {
/// When the app is paused.
Paused(tracks::Info),
/// When the app is playing.
Playing(tracks::Info),
/// When the app is loading.
Loading(u8),
/// When the app is muted.
Muted,
}
impl ActionBar {
/// Formats the action bar to be displayed.
/// 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.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.display.clone(), x.width))),
Self::Loading(progress) => {
let progress = format!("{: <2.0}%", progress.min(&99));
("loading", Some((progress, 3)))
}
Self::Muted => {
let msg = "+ to increase volume";
("muted,", Some((String::from(msg), msg.len())))
}
};
subject.map_or_else(
|| (word.to_owned(), word.len()),
|(subject, len)| {
(
format!("{} {}{}", word, if star { "*" } else { "" }, subject.bold()),
word.len() + 1 + len + usize::from(star),
)
},
)
}
}
/// 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(state: &ui::State, width: usize) -> String {
let (main, len) = state
.track
.as_ref()
.map_or_else(
|| {
ActionBar::Loading(
state
.progress
.load(std::sync::atomic::Ordering::Acquire)
.into(),
)
},
|info| {
if state.sink.volume() < 0.01 {
return ActionBar::Muted;
}
let info = info.clone();
if state.sink.is_paused() {
ActionBar::Paused(info)
} else {
ActionBar::Playing(info)
}
},
)
.format(state.bookmarked);
if len > width {
let chopped: String = main.graphemes(true).take(width + 1).collect();
format!("{chopped}...")
} else {
format!("{}{}", main, " ".repeat(width - len))
}
}
/// Creates the bottom controls bar, and also spaces it properly.
pub fn controls(width: usize) -> String {
let controls = [["[s]", "kip"], ["[p]", "ause"], ["[q]", "uit"]];
let len: usize = controls.concat().iter().map(|x| x.len()).sum();
let controls = controls.map(|x| format!("{}{}", x[0].bold(), x[1]));
let mut controls = controls.join(&" ".repeat((width - len) / (controls.len() - 1)));
// This is needed because changing the above line
// only works for when the width is even
controls.push_str(match width % 2 {
0 => " ",
_ => "",
});
controls
}

75
src/ui/environment.rs Normal file
View File

@ -0,0 +1,75 @@
use std::io::stdout;
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)]
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()?;
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) -> 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()?;
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();
}
}

View File

@ -1,13 +1,13 @@
//! Responsible for specifically recieving 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::Message;
/// Starts the listener to recieve input from the terminal for various events.
pub async fn listen(sender: Sender<Message>) -> crate::Result<()> {
pub async fn listen(sender: Sender<Message>) -> super::Result<()> {
let mut reader = EventStream::new();
loop {
@ -64,4 +64,4 @@ pub async fn listen(sender: Sender<Message>) -> crate::Result<()> {
sender.send(messages).await?;
}
}
}

36
src/ui/interface.rs Normal file
View File

@ -0,0 +1,36 @@
use crate::ui::{self, components, window::Window};
/// The code for the terminal interface itself.
///
/// * `minimalist` - All this does is hide the bottom control bar.
pub async fn draw(state: &ui::State, window: &mut Window, params: ui::Params) -> super::Result<()> {
let action = components::action(&state, state.width);
let volume = state.sink.volume();
let percentage = format!("{}%", (volume * 100.0).round().abs());
// let timer = VOLUME_TIMER.load(Ordering::Relaxed);
// let middle = match timer {
let middle = components::progress_bar(&state, state.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(state.width);
let menu = match (params.minimalist, &state.track) {
(true, _) => vec![action, middle],
// (false, Some(x)) => vec![x.path.clone(), action, middle, controls],
_ => vec![action, middle, controls],
};
window.draw(menu, false)?;
Ok(())
}

93
src/ui/window.rs Normal file
View File

@ -0,0 +1,93 @@
use std::io::{stdout, Stdout};
use crossterm::{
cursor::{MoveToColumn, MoveUp},
style::{Print, Stylize},
terminal::{Clear, ClearType},
};
use std::fmt::Write;
use unicode_segmentation::UnicodeSegmentation;
/// 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) -> super::Result<()> {
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(())
}
}

81
src/volume.rs Normal file
View File

@ -0,0 +1,81 @@
use std::{num::ParseIntError, path::PathBuf};
use tokio::fs;
type Result<T> = std::result::Result<T, Error>;
#[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),
}
/// 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() -> Result<PathBuf> {
let config = dirs::config_dir()
.ok_or_else(|| Error::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() -> 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` to `volume.txt`.
pub async fn save(volume: f32) -> 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(())
}
}