mirror of
https://github.com/talwat/lowfi
synced 2025-05-05 03:42:20 +00:00
feat: implement basic bookmarking, still wip
This commit is contained in:
parent
a83a052ae9
commit
d24c6b1a74
15
src/main.rs
15
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<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]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
#[cfg(target_os = "android")]
|
||||
|
37
src/messages.rs
Normal file
37
src/messages.rs
Normal 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,
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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),
|
||||
};
|
||||
|
||||
|
@ -43,6 +43,9 @@ pub async fn listen(sender: Sender<Messages>) -> eyre::Result<()> {
|
||||
'+' | '=' | 'k' => Messages::ChangeVolume(0.1),
|
||||
'-' | '_' | 'j' => Messages::ChangeVolume(-0.1),
|
||||
|
||||
// Bookmark
|
||||
'b' => Messages::Bookmark,
|
||||
|
||||
_ => continue,
|
||||
},
|
||||
// Media keys
|
||||
|
113
src/tracks.rs
113
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<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.
|
||||
///
|
||||
/// 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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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> {
|
||||
Decoded::new(self)
|
||||
}
|
||||
}
|
||||
|
27
src/tracks/bookmark.rs
Normal file
27
src/tracks/bookmark.rs
Normal 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(())
|
||||
}
|
@ -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<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.
|
||||
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<Track, bool> {
|
||||
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<Self> {
|
||||
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() };
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user