feat: implement basic bookmarking, still wip

This commit is contained in:
talwat 2025-04-22 11:48:50 +02:00
parent a83a052ae9
commit d24c6b1a74
11 changed files with 190 additions and 97 deletions

View File

@ -2,8 +2,12 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)] #![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 play;
mod player; mod player;
mod tracks; mod tracks;
@ -72,6 +76,15 @@ enum Commands {
}, },
} }
/// Gets lowfi's data directory.
pub fn data_dir() -> eyre::Result<PathBuf> {
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] #[tokio::main]
async fn main() -> eyre::Result<()> { async fn main() -> eyre::Result<()> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]

37
src/messages.rs Normal file
View File

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

View File

@ -7,8 +7,9 @@ use eyre::eyre;
use tokio::fs; use tokio::fs;
use tokio::{sync::mpsc, task}; use tokio::{sync::mpsc, task};
use crate::messages::Messages;
use crate::player::ui;
use crate::player::Player; use crate::player::Player;
use crate::player::{ui, Messages};
use crate::Args; use crate::Args;
/// This is the representation of the persistent volume, /// This is the representation of the persistent volume,

View File

@ -22,8 +22,9 @@ use tokio::{
use mpris_server::{PlaybackStatus, PlayerInterface, Property}; use mpris_server::{PlaybackStatus, PlayerInterface, Property};
use crate::{ use crate::{
messages::Messages,
play::{PersistentVolume, SendableOutputStream}, play::{PersistentVolume, SendableOutputStream},
tracks::{self, list::List}, tracks::{self, bookmark, list::List},
Args, Args,
}; };
@ -33,41 +34,6 @@ pub mod ui;
#[cfg(feature = "mpris")] #[cfg(feature = "mpris")]
pub mod 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. /// The time to wait in between errors.
const TIMEOUT: Duration = Duration::from_secs(3); const TIMEOUT: Duration = Duration::from_secs(3);
@ -416,6 +382,20 @@ impl Player {
continue; 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, Messages::Quit => break,
} }
} }

View File

@ -169,7 +169,7 @@ impl PlayerInterface for Player {
.as_ref() .as_ref()
.map_or_else(Metadata::new, |track| { .map_or_else(Metadata::new, |track| {
let mut metadata = Metadata::builder() let mut metadata = Metadata::builder()
.title(track.name.clone()) .title(track.display_name.clone())
.album(self.player.list.name.clone()) .album(self.player.list.name.clone())
.build(); .build();

View File

@ -74,8 +74,8 @@ impl ActionBar {
/// The second value is the character length of the result. /// The second value is the character length of the result.
fn format(&self) -> (String, usize) { fn format(&self) -> (String, usize) {
let (word, subject) = match self { let (word, subject) = match self {
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))), Self::Playing(x) => ("playing", Some((x.display_name.clone(), x.width))),
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))), Self::Paused(x) => ("paused", Some((x.display_name.clone(), x.width))),
Self::Loading => ("loading", None), Self::Loading => ("loading", None),
}; };

View File

@ -43,6 +43,9 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
'+' | '=' | 'k' => Messages::ChangeVolume(0.1), '+' | '=' | 'k' => Messages::ChangeVolume(0.1),
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1), '-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
// Bookmark
'b' => Messages::Bookmark,
_ => continue, _ => continue,
}, },
// Media keys // Media keys

View File

@ -1,6 +1,19 @@
//! Has all of the structs for managing the state //! Has all of the structs for managing the state
//! of tracks, as well as downloading them & //! of tracks, as well as downloading them & finding new ones.
//! 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}; use std::{io::Cursor, time::Duration};
@ -11,19 +24,62 @@ use rodio::{Decoder, Source as _};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use url::form_urlencoded; use url::form_urlencoded;
pub mod bookmark;
pub mod list; pub mod list;
/// Just a shorthand for a decoded [Bytes]. /// Just a shorthand for a decoded [Bytes].
pub type DecodedData = Decoder<Cursor<Bytes>>; pub type DecodedData = Decoder<Cursor<Bytes>>;
/// Specifies a track's name, and specifically,
/// whether it has already been formatted or if it
/// is still in it's raw path form.
#[derive(Debug, Clone)]
pub enum TrackName {
/// Pulled straight from the list,
/// with no splitting done at all.
Raw(String),
/// If a track has a custom specified name
/// in the list, then it should be defined with this variant.
Formatted(String),
}
/// 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> {
Decoded::new(self)
}
}
/// The [`Info`] struct, which has the name and duration of a track. /// 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 /// This is not included in [Track] as the duration has to be acquired
/// from the decoded data and not from the raw data. /// from the decoded data and not from the raw data.
#[derive(Debug, Eq, PartialEq, Clone)] #[derive(Debug, Eq, PartialEq, Clone)]
pub struct Info { 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. /// 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 /// This is the *actual* terminal width of the track name, used to make
/// the UI consistent. /// the UI consistent.
@ -93,17 +149,19 @@ impl Info {
} }
} }
/// Creates a new [`TrackInfo`] from a possibly raw name & decoded track data. /// Creates a new [`TrackInfo`] from a possibly raw name & decoded data.
pub fn new(name: TrackName, decoded: &DecodedData) -> eyre::Result<Self> { pub fn new(name: TrackName, full_path: String, decoded: &DecodedData) -> eyre::Result<Self> {
let name = match name { let (display_name, custom_name) = match name {
TrackName::Raw(raw) => Self::format_name(&raw)?, TrackName::Raw(raw) => (Self::format_name(&raw)?, false),
TrackName::Formatted(formatted) => formatted, TrackName::Formatted(custom) => (custom, true),
}; };
Ok(Self { Ok(Self {
duration: decoded.total_duration(), duration: decoded.total_duration(),
width: name.graphemes(true).count(), width: display_name.graphemes(true).count(),
name, full_path,
custom_name,
display_name,
}) })
} }
} }
@ -123,41 +181,8 @@ impl Decoded {
/// This is equivalent to [`Track::decode`]. /// This is equivalent to [`Track::decode`].
pub fn new(track: Track) -> eyre::Result<Self> { pub fn new(track: Track) -> eyre::Result<Self> {
let data = Decoder::new(Cursor::new(track.data))?; 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 }) 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> {
Decoded::new(self)
}
}

27
src/tracks/bookmark.rs Normal file
View File

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

View File

@ -7,6 +7,8 @@ use rand::Rng as _;
use reqwest::Client; use reqwest::Client;
use tokio::fs; use tokio::fs;
use crate::data_dir;
use super::Track; use super::Track;
/// Represents a list of tracks that can be played. /// Represents a list of tracks that can be played.
@ -50,15 +52,15 @@ impl List {
} }
/// Downloads a raw track, but doesn't decode it. /// Downloads a raw track, but doesn't decode it.
async fn download(&self, track: &str, client: &Client) -> Result<Bytes, bool> { 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. // 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() track.to_owned()
} else { } else {
format!("{}{}", self.base(), track) 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 path = if x.starts_with("~") {
let home_path = dirs::home_dir().ok_or(false)?; let home_path = dirs::home_dir().ok_or(false)?;
let home = home_path.to_str().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)?; let result = tokio::fs::read(path).await.map_err(|_| false)?;
result.into() result.into()
} else { } 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)? response.bytes().await.map_err(|_| false)?
}; };
Ok(data) Ok((data, full_path))
} }
/// Fetches and downloads a random track from the [List]. /// 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. /// and false otherwise. This tells lowfi if it shouldn't wait to try again.
pub async fn random(&self, client: &Client) -> Result<Track, bool> { pub async fn random(&self, client: &Client) -> Result<Track, bool> {
let (path, custom_name) = self.random_path(); 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) super::TrackName::Formatted(formatted)
}); });
Ok(Track { name, data: data }) Ok(Track {
name,
data,
full_path,
})
} }
/// Parses text into a [List]. /// Parses text into a [List].
@ -111,10 +121,7 @@ impl List {
pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> { pub async fn load(tracks: Option<&String>) -> eyre::Result<Self> {
if let Some(arg) = tracks { if let Some(arg) = tracks {
// Check if the track is in ~/.local/share/lowfi, in which case we'll load that. // Check if the track is in ~/.local/share/lowfi, in which case we'll load that.
let name = dirs::data_dir() let name = data_dir()?.join(format!("{arg}.txt"));
.ok_or_eyre("data directory not found, are you *really* running this on wasm?")?
.join("lowfi")
.join(format!("{arg}.txt"));
let name = if name.exists() { name } else { arg.into() }; let name = if name.exists() { name } else { arg.into() };

0
test.txt Normal file
View File