diff --git a/Cargo.lock b/Cargo.lock index 0584ed3..607f66b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 52aca5f..d6222bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/audio.rs b/src/audio.rs index 4dbda0e..7f93526 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -37,4 +37,4 @@ pub fn silent_get_output_stream() -> eyre::Result = std::result::Result; + +/// 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, + + /// 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 { + 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 { + let text = fs::read_to_string(Self::path().await?) + .await + .unwrap_or_default(); + + let entries: Vec = 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()); + } +} diff --git a/src/download.rs b/src/download.rs index 324586c..2c02c5f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -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, + queue: Receiver, + handle: JoinHandle>, } impl Downloader { - async fn downloader(tx: Sender<()>) -> crate::Result<()> { - - // todo - Ok(()) + pub async fn track(&mut self) -> Option { + 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: tracks::List, + client: Client, + progress: Arc, + 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, + ) -> 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() { - -} \ No newline at end of file +impl Drop for Downloader { + fn drop(&mut self) { + self.handle.abort(); + } +} diff --git a/src/error.rs b/src/error.rs index fa0e8a1..2faba65 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,16 @@ use tokio::sync::mpsc; +use crate::{bookmark, tracks, ui, volume}; + pub type Result = std::result::Result; #[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), + + #[error("couldn't add track to the queue: {0}")] + Queue(#[from] mpsc::error::SendError), + + #[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, -} - -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 From<(T, E)> for Error -where - T: Into, - Kind: From, -{ - fn from((track, err): (T, E)) -> Self { - Self { - context: Context { track: Some(track.into()) }, - kind: Kind::from(err), - } - } -} - -impl From for Error -where - Kind: From, -{ - fn from(err: E) -> Self { - Self { - context: Context::default(), - kind: Kind::from(err), - } - } -} - -pub trait WithContextExt { - fn context(self, name: impl Into) -> std::result::Result; -} - -impl WithContextExt for std::result::Result -where - (String, E): Into, - E: Into, -{ - fn context(self, name: impl Into) -> std::result::Result { - self.map_err(|e| { - let error = match e.into() { - Kind::Request(error) => Kind::Request(error.without_url()), - kind => kind, - }; - - (name.into(), error).into() - }) - } -} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 8b53c2a..8129e25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>; +#[cfg(feature = "scrape")] +use crate::scrapers::Source; -pub struct Player { - ui: UI, - downloader: Downloader, - sink: rodio::Sink, - stream: rodio::OutputStream, - rx: Receiver, +/// 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, } -impl Player { - pub async fn init() -> crate::Result { - #[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 { + 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(()) } diff --git a/src/message.rs b/src/message.rs index 5ce6a2d..11beed7 100644 --- a/src/message.rs +++ b/src/message.rs @@ -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, -} \ No newline at end of file +} diff --git a/src/mpris.rs b/src/mpris.rs new file mode 100644 index 0000000..d260d72 --- /dev/null +++ b/src/mpris.rs @@ -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, + + /// The audio server sender, which is used to communicate with + /// the audio sender for skips and a few other inputs. + pub sender: Sender, +} + +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 { + Ok(true) + } + + async fn fullscreen(&self) -> fdo::Result { + Ok(false) + } + + async fn set_fullscreen(&self, _: bool) -> Result<()> { + Ok(()) + } + + async fn can_set_fullscreen(&self) -> fdo::Result { + Ok(false) + } + + async fn can_raise(&self) -> fdo::Result { + Ok(false) + } + + async fn has_track_list(&self) -> fdo::Result { + Ok(false) + } + + async fn identity(&self) -> fdo::Result { + Ok("lowfi".to_owned()) + } + + async fn desktop_entry(&self) -> fdo::Result { + Ok("dev.talwat.lowfi".to_owned()) + } + + async fn supported_uri_schemes(&self) -> fdo::Result> { + Ok(vec!["https".to_owned()]) + } + + async fn supported_mime_types(&self) -> fdo::Result> { + 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 { + 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 { + Err(ERROR) + } + + async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> { + Ok(()) + } + + async fn rate(&self) -> fdo::Result { + 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 { + Ok(true) + } + + async fn set_shuffle(&self, _shuffle: bool) -> Result<()> { + Ok(()) + } + + async fn metadata(&self) -> fdo::Result { + 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 { + 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