From d24c6b1a742ec8da1c4672a49810d451adf6025e Mon Sep 17 00:00:00 2001 From: talwat <83217276+talwat@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:48:50 +0200 Subject: [PATCH] feat: implement basic bookmarking, still wip --- src/main.rs | 15 ++++- src/messages.rs | 37 ++++++++++++ src/play.rs | 3 +- src/player.rs | 52 +++++------------ src/player/mpris.rs | 2 +- src/player/ui/components.rs | 4 +- src/player/ui/input.rs | 3 + src/tracks.rs | 113 ++++++++++++++++++++++-------------- src/tracks/bookmark.rs | 27 +++++++++ src/tracks/list.rs | 31 ++++++---- test.txt | 0 11 files changed, 190 insertions(+), 97 deletions(-) create mode 100644 src/messages.rs create mode 100644 src/tracks/bookmark.rs create mode 100644 test.txt diff --git a/src/main.rs b/src/main.rs index f67ca5d..7451ee2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,12 @@ #![warn(clippy::all, clippy::pedantic, clippy::nursery)] -use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use clap::{Parser, Subcommand}; +use eyre::OptionExt; + +mod messages; mod play; mod player; mod tracks; @@ -72,6 +76,15 @@ enum Commands { }, } +/// Gets lowfi's data directory. +pub fn data_dir() -> eyre::Result { + let dir = dirs::data_dir() + .ok_or_eyre("data directory not found, are you *really* running this on wasm?")? + .join("lowfi"); + + Ok(dir) +} + #[tokio::main] async fn main() -> eyre::Result<()> { #[cfg(target_os = "android")] diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..b26b555 --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,37 @@ +/// Handles communication between the frontend & audio player. +#[derive(PartialEq, Debug, Clone, Copy)] +pub enum Messages { + /// Notifies the audio server that it should update the track. + Next, + + /// Special in that this isn't sent in a "client to server" sort of way, + /// but rather is sent by a child of the server when a song has not only + /// been requested but also downloaded aswell. + NewSong, + + /// This signal is only sent if a track timed out. In that case, + /// lowfi will try again and again to retrieve the track. + TryAgain, + + /// Similar to Next, but specific to the first track. + Init, + + /// Unpause the [Sink]. + #[allow(dead_code, reason = "this code may not be dead depending on features")] + Play, + + /// Pauses the [Sink]. + Pause, + + /// Pauses the [Sink]. This will also unpause it if it is paused. + PlayPause, + + /// Change the volume of playback. + ChangeVolume(f32), + + /// Bookmark the current track. + Bookmark, + + /// Quits gracefully. + Quit, +} diff --git a/src/play.rs b/src/play.rs index 424be66..ef55f4b 100644 --- a/src/play.rs +++ b/src/play.rs @@ -7,8 +7,9 @@ use eyre::eyre; use tokio::fs; use tokio::{sync::mpsc, task}; +use crate::messages::Messages; +use crate::player::ui; use crate::player::Player; -use crate::player::{ui, Messages}; use crate::Args; /// This is the representation of the persistent volume, diff --git a/src/player.rs b/src/player.rs index 69dd440..bd70df1 100644 --- a/src/player.rs +++ b/src/player.rs @@ -22,8 +22,9 @@ use tokio::{ use mpris_server::{PlaybackStatus, PlayerInterface, Property}; use crate::{ + messages::Messages, play::{PersistentVolume, SendableOutputStream}, - tracks::{self, list::List}, + tracks::{self, bookmark, list::List}, Args, }; @@ -33,41 +34,6 @@ pub mod ui; #[cfg(feature = "mpris")] pub mod mpris; -/// Handles communication between the frontend & audio player. -#[derive(PartialEq, Debug, Clone, Copy)] -pub enum Messages { - /// Notifies the audio server that it should update the track. - Next, - - /// Special in that this isn't sent in a "client to server" sort of way, - /// but rather is sent by a child of the server when a song has not only - /// been requested but also downloaded aswell. - NewSong, - - /// This signal is only sent if a track timed out. In that case, - /// lowfi will try again and again to retrieve the track. - TryAgain, - - /// Similar to Next, but specific to the first track. - Init, - - /// Unpause the [Sink]. - #[allow(dead_code, reason = "this code may not be dead depending on features")] - Play, - - /// Pauses the [Sink]. - Pause, - - /// Pauses the [Sink]. This will also unpause it if it is paused. - PlayPause, - - /// Change the volume of playback. - ChangeVolume(f32), - - /// Quits gracefully. - Quit, -} - /// The time to wait in between errors. const TIMEOUT: Duration = Duration::from_secs(3); @@ -416,6 +382,20 @@ impl Player { continue; } + Messages::Bookmark => { + let current = player.current.load(); + let current = current.as_ref().unwrap(); + + bookmark::bookmark( + current.full_path.clone(), + if current.custom_name { + Some(current.display_name.clone()) + } else { + None + }, + ) + .await?; + } Messages::Quit => break, } } diff --git a/src/player/mpris.rs b/src/player/mpris.rs index 863c889..2ab8b6a 100644 --- a/src/player/mpris.rs +++ b/src/player/mpris.rs @@ -169,7 +169,7 @@ impl PlayerInterface for Player { .as_ref() .map_or_else(Metadata::new, |track| { let mut metadata = Metadata::builder() - .title(track.name.clone()) + .title(track.display_name.clone()) .album(self.player.list.name.clone()) .build(); diff --git a/src/player/ui/components.rs b/src/player/ui/components.rs index 42ed7bc..aca2f81 100644 --- a/src/player/ui/components.rs +++ b/src/player/ui/components.rs @@ -74,8 +74,8 @@ impl ActionBar { /// The second value is the character length of the result. fn format(&self) -> (String, usize) { let (word, subject) = match self { - Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))), - Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))), + Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))), + Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))), Self::Loading => ("loading", None), }; diff --git a/src/player/ui/input.rs b/src/player/ui/input.rs index 00f4ae1..6b021de 100644 --- a/src/player/ui/input.rs +++ b/src/player/ui/input.rs @@ -43,6 +43,9 @@ pub async fn listen(sender: Sender) -> eyre::Result<()> { '+' | '=' | 'k' => Messages::ChangeVolume(0.1), '-' | '_' | 'j' => Messages::ChangeVolume(-0.1), + // Bookmark + 'b' => Messages::Bookmark, + _ => continue, }, // Media keys diff --git a/src/tracks.rs b/src/tracks.rs index 9bd3a4e..161b39d 100644 --- a/src/tracks.rs +++ b/src/tracks.rs @@ -1,6 +1,19 @@ //! Has all of the structs for managing the state -//! of tracks, as well as downloading them & -//! finding new ones. +//! 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 [`Track`] 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}; @@ -11,19 +24,62 @@ use rodio::{Decoder, Source as _}; use unicode_segmentation::UnicodeSegmentation; use url::form_urlencoded; +pub mod bookmark; pub mod list; /// Just a shorthand for a decoded [Bytes]. pub type DecodedData = Decoder>; +/// Specifies a track's name, and specifically, +/// whether it has already been formatted or if it +/// is still in it's raw path form. +#[derive(Debug, Clone)] +pub enum TrackName { + /// Pulled straight from the list, + /// with no splitting done at all. + Raw(String), + + /// If a track has a custom specified name + /// in the list, then it should be defined with this variant. + Formatted(String), +} + +/// The main track struct, which only includes data & the track name. +pub struct Track { + /// Name of the track, which may be raw. + pub name: TrackName, + + /// Full downloadable path/url of the track. + pub full_path: String, + + /// The raw data of the track, which is not decoded and + /// therefore much more memory efficient. + pub data: Bytes, +} + +impl Track { + /// This will actually decode and format the track, + /// returning a [`DecodedTrack`] which can be played + /// and also has a duration & formatted name. + pub fn decode(self) -> eyre::Result { + Decoded::new(self) + } +} + /// 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 full_path: String, + + /// Whether the track entry included a custom name, or not. + pub custom_name: bool, + /// This is a formatted name, so it doesn't include the full path. - pub name: String, + pub display_name: String, /// This is the *actual* terminal width of the track name, used to make /// the UI consistent. @@ -93,17 +149,19 @@ impl Info { } } - /// Creates a new [`TrackInfo`] from a possibly raw name & decoded track data. - pub fn new(name: TrackName, decoded: &DecodedData) -> eyre::Result { - let name = match name { - TrackName::Raw(raw) => Self::format_name(&raw)?, - TrackName::Formatted(formatted) => formatted, + /// Creates a new [`TrackInfo`] from a possibly raw name & decoded data. + pub fn new(name: TrackName, full_path: String, decoded: &DecodedData) -> eyre::Result { + let (display_name, custom_name) = match name { + TrackName::Raw(raw) => (Self::format_name(&raw)?, false), + TrackName::Formatted(custom) => (custom, true), }; Ok(Self { duration: decoded.total_duration(), - width: name.graphemes(true).count(), - name, + width: display_name.graphemes(true).count(), + full_path, + custom_name, + display_name, }) } } @@ -123,41 +181,8 @@ impl Decoded { /// This is equivalent to [`Track::decode`]. pub fn new(track: Track) -> eyre::Result { let data = Decoder::new(Cursor::new(track.data))?; - let info = Info::new(track.name, &data)?; + let info = Info::new(track.name, track.full_path, &data)?; Ok(Self { info, data }) } } - -/// Specifies a track's name, and specifically, -/// whether it has already been formatted or if it -/// is still in it's raw form. -#[derive(Debug, Clone)] -pub enum TrackName { - /// Pulled straight from the list, - /// with no splitting done at all. - Raw(String), - - /// If a track has a custom specified name - /// in the list, then it should be defined with this variant. - Formatted(String), -} - -/// The main track struct, which only includes data & the track name. -pub struct Track { - /// Name of the track. - pub name: TrackName, - - /// The raw data of the track, which is not decoded and - /// therefore much more memory efficient. - pub data: Bytes, -} - -impl Track { - /// This will actually decode and format the track, - /// returning a [`DecodedTrack`] which can be played - /// and also has a duration & formatted name. - pub fn decode(self) -> eyre::Result { - Decoded::new(self) - } -} diff --git a/src/tracks/bookmark.rs b/src/tracks/bookmark.rs new file mode 100644 index 0000000..a5964fb --- /dev/null +++ b/src/tracks/bookmark.rs @@ -0,0 +1,27 @@ +use tokio::fs::{create_dir_all, OpenOptions}; +use tokio::io::AsyncWriteExt; + +use crate::data_dir; + +pub async fn bookmark(path: String, custom: Option) -> eyre::Result<()> { + let mut entry = format!("\n{path}"); + + if let Some(custom) = custom { + entry.push('!'); + entry.push_str(&custom); + } + + let data_dir = data_dir()?; + create_dir_all(data_dir.clone()).await?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .append(true) + .open(data_dir.join("bookmarks.txt")) + .await?; + + file.write_all(entry.as_bytes()).await?; + + Ok(()) +} diff --git a/src/tracks/list.rs b/src/tracks/list.rs index 300645e..e1c6e20 100644 --- a/src/tracks/list.rs +++ b/src/tracks/list.rs @@ -7,6 +7,8 @@ use rand::Rng as _; use reqwest::Client; use tokio::fs; +use crate::data_dir; + use super::Track; /// Represents a list of tracks that can be played. @@ -50,15 +52,15 @@ impl List { } /// Downloads a raw track, but doesn't decode it. - async fn download(&self, track: &str, client: &Client) -> Result { + async fn download(&self, track: &str, client: &Client) -> Result<(Bytes, String), bool> { // If the track has a protocol, then we should ignore the base for it. - let url = if track.contains("://") { + let full_path = if track.contains("://") { track.to_owned() } else { format!("{}{}", self.base(), track) }; - let data: Bytes = if let Some(x) = url.strip_prefix("file://") { + 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(false)?; let home = home_path.to_str().ok_or(false)?; @@ -71,11 +73,15 @@ impl List { let result = tokio::fs::read(path).await.map_err(|_| false)?; result.into() } else { - let response = client.get(url).send().await.map_err(|x| x.is_timeout())?; + let response = client + .get(full_path.clone()) + .send() + .await + .map_err(|x| x.is_timeout())?; response.bytes().await.map_err(|_| false)? }; - Ok(data) + Ok((data, full_path)) } /// Fetches and downloads a random track from the [List]. @@ -84,13 +90,17 @@ impl List { /// and false otherwise. This tells lowfi if it shouldn't wait to try again. pub async fn random(&self, client: &Client) -> Result { let (path, custom_name) = self.random_path(); - let data = self.download(&path, client).await?; + let (data, full_path) = self.download(&path, client).await?; - let name = custom_name.map_or(super::TrackName::Raw(path), |formatted| { + let name = custom_name.map_or(super::TrackName::Raw(path.clone()), |formatted| { super::TrackName::Formatted(formatted) }); - Ok(Track { name, data: data }) + Ok(Track { + name, + data, + full_path, + }) } /// Parses text into a [List]. @@ -111,10 +121,7 @@ impl List { pub async fn load(tracks: Option<&String>) -> eyre::Result { if let Some(arg) = tracks { // Check if the track is in ~/.local/share/lowfi, in which case we'll load that. - let name = dirs::data_dir() - .ok_or_eyre("data directory not found, are you *really* running this on wasm?")? - .join("lowfi") - .join(format!("{arg}.txt")); + let name = data_dir()?.join(format!("{arg}.txt")); let name = if name.exists() { name } else { arg.into() }; diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..e69de29