mirror of
https://github.com/talwat/lowfi
synced 2024-12-26 19:21:54 +00:00
feat: add custom sources (#21)
* feat: initial work on decoupling embedded list file & the rest of the app * chore: rename tracks.txt to lofigirl.txt * fix: make base optional * feat: partially revert previous commit * feat: fix loading tracks with explicit url * fix: include list in main player struct * chore: reduce timeout * chore: remove unused import * fix: rename InitialProperties to PersistentVolume * feat: move persistent volume init to player init function * feat: add micropop.txt as an example of a custom tracklist * docs: add note about mp3 * docs: move format of lists to list struct docs * docs: document custom track lists * fix: fix silly spelling error * docs: update formatting * docs: fix sample formatting * docs: add missing sample track * fix: fix ui when track name has special characters * fix: use proper char counting on subject, not word * fix: use unicode-segmentation to finally fix handling special characters * fix: precompute track len * fix: switch to using the unicode-width crate * style: split off list into it's own module * fix: move logic to read a list from the fs into the list module
This commit is contained in:
parent
b2c225256f
commit
543aeee78c
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -962,7 +962,7 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-width",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1330,6 +1330,8 @@ dependencies = [
|
|||||||
"rodio",
|
"rodio",
|
||||||
"scraper",
|
"scraper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2671,6 +2673,12 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
@ -49,3 +49,5 @@ scraper = "0.20.0"
|
|||||||
Inflector = "0.11.4"
|
Inflector = "0.11.4"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
libc = "0.2.159"
|
libc = "0.2.159"
|
||||||
|
url = "2.5.2"
|
||||||
|
unicode-width = "0.2.0"
|
||||||
|
48
README.md
48
README.md
@ -105,3 +105,51 @@ An example of scrape is as follows,
|
|||||||
`lowfi scrape --extension zip --include-full`
|
`lowfi scrape --extension zip --include-full`
|
||||||
|
|
||||||
where more information can be found by running `lowfi help scrape`.
|
where more information can be found by running `lowfi help scrape`.
|
||||||
|
|
||||||
|
### Custom Track Lists
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> Custom track lists are going to be pretty particular.
|
||||||
|
> This is because I still want to keep `lowfi` as simple as possible,
|
||||||
|
> so custom lists will be very similar to how the built in list functions.
|
||||||
|
>
|
||||||
|
> This also means that there will be no added flexibility to these lists,
|
||||||
|
> so you'll have to work that out on your own.
|
||||||
|
|
||||||
|
lowfi also can support custom track lists, although the default one with Lofi Girl's
|
||||||
|
is embedded into the binary.
|
||||||
|
|
||||||
|
To use a custom list, use the `--tracks` flag. This can either be a path to some file,
|
||||||
|
or it could also be the name of a file (without the `.txt` extension) in the data
|
||||||
|
directory, so on Linux it's `~/.local/share/lowfi`.
|
||||||
|
|
||||||
|
For example, `lowfi --tracks minipop` would load `~/.local/share/lowfi/minipop.txt`.
|
||||||
|
Whereas if you did `lowfi --tracks /home/user/Music/minipop.txt` it would load from that
|
||||||
|
specified directory.
|
||||||
|
|
||||||
|
#### The Format
|
||||||
|
|
||||||
|
In List's, the first line should be the base URL, followed by the rest of the tracks.
|
||||||
|
|
||||||
|
Each track will be first appended to the base URL, and then the result use to download
|
||||||
|
the track. All tracks should end in `.mp3` and as such must be in the MP3 format.
|
||||||
|
|
||||||
|
lowfi won't put a `/` between the base & track for added flexibility, so for most cases you
|
||||||
|
should have a trailing `/` in your base url. The exception to this is if the track name begins
|
||||||
|
with something like `https://`, where in that case the base will not be prepended to it.
|
||||||
|
|
||||||
|
For example, in this list:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://lofigirl.com/wp-content/uploads/
|
||||||
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
|
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
lowfi would download these three URLs:
|
||||||
|
|
||||||
|
- `https://lofigirl.com/wp-content/uploads/2023/06/Foudroie-Finding-The-Edge-V2.mp3`
|
||||||
|
- `https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3`
|
||||||
|
- `https://lofigirl.com/wp-content/uploads/2023/04/2-In-Front-Of-Me.mp3`
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
https://lofigirl.com/wp-content/uploads/
|
||||||
2023/06/01-gCoope-Odd-Panda-Passing-Time.mp3
|
2023/06/01-gCoope-Odd-Panda-Passing-Time.mp3
|
||||||
2023/06/02-gCoope-Odd-Panda-cxlt.-When-The-Stars-Align.mp3
|
2023/06/02-gCoope-Odd-Panda-cxlt.-When-The-Stars-Align.mp3
|
||||||
2023/06/03-gCoope-Odd-Panda-Wind-Rider.mp3
|
2023/06/03-gCoope-Odd-Panda-Wind-Rider.mp3
|
16
data/micropop.txt
Normal file
16
data/micropop.txt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
https://archive.org/download/jack-stauber-s-micropop-extended-micropops/Jack%20Stauber-%27s%20Micropop%20-%20
|
||||||
|
Al%20Dente.mp3
|
||||||
|
Baby%20Hotline.mp3
|
||||||
|
Cupid.mp3
|
||||||
|
Deploy.mp3
|
||||||
|
Dinner%20Is%20Not%20Over.mp3
|
||||||
|
Fighter.mp3
|
||||||
|
Inchman.mp3
|
||||||
|
Keyman.mp3
|
||||||
|
Out%20the%20Ox.mp3
|
||||||
|
Tea%20Errors.mp3
|
||||||
|
The%20Ballad%20of%20Hamantha.mp3
|
||||||
|
There%27s%20Something%20Happening.mp3
|
||||||
|
Those%20Eggs%20Aren%27t%20Dippy%20.mp3
|
||||||
|
Today%20Today.mp3
|
||||||
|
Two%20Time.mp3
|
4
data/sample.txt
Normal file
4
data/sample.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
https://lofigirl.com/wp-content/uploads/
|
||||||
|
2023/06/Foudroie-Finding-The-Edge-V2.mp3
|
||||||
|
2023/04/2-In-Front-Of-Me.mp3
|
||||||
|
https://file-examples.com/storage/fea570b16e6703ef79e65b4/2017/11/file_example_MP3_5MG.mp3
|
@ -25,6 +25,10 @@ struct Args {
|
|||||||
#[clap(long, short)]
|
#[clap(long, short)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
|
|
||||||
|
/// This is either a path, or a name of a file in the data directory (eg. ~/.local/share/lowfi).
|
||||||
|
#[clap(long, short, alias = "list", short_alias = 'l')]
|
||||||
|
tracks: Option<String>,
|
||||||
|
|
||||||
/// The command that was ran.
|
/// The command that was ran.
|
||||||
/// This is [None] if no command was specified.
|
/// This is [None] if no command was specified.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
39
src/play.rs
39
src/play.rs
@ -11,17 +11,15 @@ use crate::player::Player;
|
|||||||
use crate::player::{ui, Messages};
|
use crate::player::{ui, Messages};
|
||||||
use crate::Args;
|
use crate::Args;
|
||||||
|
|
||||||
/// The attributes that are applied at startup.
|
/// This is the representation of the persistent volume,
|
||||||
/// This includes the volume, but also the config file.
|
/// which is loaded at startup and saved on shutdown.
|
||||||
///
|
#[derive(Clone, Copy)]
|
||||||
/// The volume is seperated from the config since it specifically
|
pub struct PersistentVolume {
|
||||||
/// will be written by lowfi, whereas the config will not.
|
|
||||||
pub struct InitialProperties {
|
|
||||||
/// The volume, as a percentage.
|
/// The volume, as a percentage.
|
||||||
pub volume: u16,
|
inner: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InitialProperties {
|
impl PersistentVolume {
|
||||||
/// Retrieves the config directory.
|
/// Retrieves the config directory.
|
||||||
async fn config() -> eyre::Result<PathBuf> {
|
async fn config() -> eyre::Result<PathBuf> {
|
||||||
let config = dirs::config_dir()
|
let config = dirs::config_dir()
|
||||||
@ -35,10 +33,14 @@ impl InitialProperties {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the [InitialProperties], including the config and volume file.
|
/// Returns the volume as a float from 0 to 1.
|
||||||
|
pub fn float(&self) -> f32 {
|
||||||
|
self.inner as f32 / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the [PersistentVolume] from [dirs::config_dir()].
|
||||||
pub async fn load() -> eyre::Result<Self> {
|
pub async fn load() -> eyre::Result<Self> {
|
||||||
let config = Self::config().await?;
|
let config = Self::config().await?;
|
||||||
|
|
||||||
let volume = config.join(PathBuf::from("volume.txt"));
|
let volume = config.join(PathBuf::from("volume.txt"));
|
||||||
|
|
||||||
// Basically just read from the volume file if it exists, otherwise return 100.
|
// Basically just read from the volume file if it exists, otherwise return 100.
|
||||||
@ -54,11 +56,11 @@ impl InitialProperties {
|
|||||||
100u16
|
100u16
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(InitialProperties { volume })
|
Ok(PersistentVolume { inner: volume })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves `volume.txt`, and uses the home directory which was previously acquired.
|
/// Saves `volume` to `volume.txt`.
|
||||||
pub async fn save_volume(volume: f32) -> eyre::Result<()> {
|
pub async fn save(volume: f32) -> eyre::Result<()> {
|
||||||
let config = Self::config().await?;
|
let config = Self::config().await?;
|
||||||
let path = config.join(PathBuf::from("volume.txt"));
|
let path = config.join(PathBuf::from("volume.txt"));
|
||||||
|
|
||||||
@ -71,19 +73,20 @@ impl InitialProperties {
|
|||||||
/// Initializes the audio server, and then safely stops
|
/// Initializes the audio server, and then safely stops
|
||||||
/// it when the frontend quits.
|
/// it when the frontend quits.
|
||||||
pub async fn play(args: Args) -> eyre::Result<()> {
|
pub async fn play(args: Args) -> eyre::Result<()> {
|
||||||
// Load the initial properties (volume & config).
|
// Actually initializes the player.
|
||||||
let properties = InitialProperties::load().await?;
|
let player = Arc::new(Player::new(&args).await?);
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel(8);
|
let (tx, rx) = mpsc::channel(8);
|
||||||
let player = Arc::new(Player::new(!args.alternate, &args).await?);
|
|
||||||
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
|
let ui = task::spawn(ui::start(Arc::clone(&player), tx.clone(), args));
|
||||||
|
|
||||||
|
// Sends the player an "init" signal telling it to start playing a song straight away.
|
||||||
tx.send(Messages::Init).await?;
|
tx.send(Messages::Init).await?;
|
||||||
|
|
||||||
Player::play(Arc::clone(&player), properties, tx.clone(), rx).await?;
|
// Actually starts the player.
|
||||||
|
Player::play(Arc::clone(&player), tx.clone(), rx).await?;
|
||||||
|
|
||||||
// Save the volume.txt file for the next session.
|
// Save the volume.txt file for the next session.
|
||||||
InitialProperties::save_volume(player.sink.volume()).await?;
|
PersistentVolume::save(player.sink.volume()).await?;
|
||||||
player.sink.stop();
|
player.sink.stop();
|
||||||
ui.abort();
|
ui.abort();
|
||||||
|
|
||||||
|
@ -19,8 +19,8 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
play::InitialProperties,
|
play::PersistentVolume,
|
||||||
tracks::{DecodedTrack, Track, TrackInfo},
|
tracks::{self, list::List},
|
||||||
Args,
|
Args,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -64,7 +64,8 @@ pub enum Messages {
|
|||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMEOUT: Duration = Duration::from_secs(8);
|
/// The time to wait in between errors.
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
/// The amount of songs to buffer up.
|
/// The amount of songs to buffer up.
|
||||||
const BUFFER_SIZE: usize = 5;
|
const BUFFER_SIZE: usize = 5;
|
||||||
@ -76,16 +77,22 @@ pub struct Player {
|
|||||||
|
|
||||||
/// The [`TrackInfo`] of the current track.
|
/// The [`TrackInfo`] of the current track.
|
||||||
/// This is [`None`] when lowfi is buffering/loading.
|
/// This is [`None`] when lowfi is buffering/loading.
|
||||||
pub current: ArcSwapOption<TrackInfo>,
|
current: ArcSwapOption<tracks::Info>,
|
||||||
|
|
||||||
/// This is the MPRIS server, which is initialized later on in the
|
/// This is the MPRIS server, which is initialized later on in the
|
||||||
/// user interface.
|
/// user interface.
|
||||||
#[cfg(feature = "mpris")]
|
#[cfg(feature = "mpris")]
|
||||||
pub mpris: tokio::sync::OnceCell<mpris_server::Server<mpris::Player>>,
|
mpris: tokio::sync::OnceCell<mpris_server::Server<mpris::Player>>,
|
||||||
|
|
||||||
/// The tracks, which is a [VecDeque] that holds
|
/// The tracks, which is a [VecDeque] that holds
|
||||||
/// *undecoded* [Track]s.
|
/// *undecoded* [Track]s.
|
||||||
tracks: RwLock<VecDeque<Track>>,
|
tracks: RwLock<VecDeque<tracks::Track>>,
|
||||||
|
|
||||||
|
/// The actual list of tracks to be played.
|
||||||
|
list: List,
|
||||||
|
|
||||||
|
/// The initial volume level.
|
||||||
|
volume: PersistentVolume,
|
||||||
|
|
||||||
/// The web client, which can contain a UserAgent & some
|
/// The web client, which can contain a UserAgent & some
|
||||||
/// settings that help lowfi work more effectively.
|
/// settings that help lowfi work more effectively.
|
||||||
@ -132,7 +139,7 @@ impl Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Just a shorthand for setting `current`.
|
/// Just a shorthand for setting `current`.
|
||||||
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
|
async fn set_current(&self, info: tracks::Info) -> eyre::Result<()> {
|
||||||
self.current.store(Some(Arc::new(info)));
|
self.current.store(Some(Arc::new(info)));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -150,11 +157,16 @@ impl Player {
|
|||||||
|
|
||||||
/// Initializes the entire player, including audio devices & sink.
|
/// Initializes the entire player, including audio devices & sink.
|
||||||
///
|
///
|
||||||
/// `silent` can control whether alsa's output should be redirected,
|
/// This also will load the track list & persistent volume.
|
||||||
/// but this option is only applicable on Linux, as on MacOS & Windows
|
pub async fn new(args: &Args) -> eyre::Result<Self> {
|
||||||
/// it will never be silent.
|
// Load the volume file.
|
||||||
pub async fn new(silent: bool, args: &Args) -> eyre::Result<Self> {
|
let volume = PersistentVolume::load().await?;
|
||||||
let (_stream, handle) = if silent && cfg!(target_os = "linux") && !args.debug {
|
|
||||||
|
// Load the track list.
|
||||||
|
let list = List::load(&args.tracks).await?;
|
||||||
|
|
||||||
|
// We should only shut up alsa forcefully if we really have to.
|
||||||
|
let (_stream, handle) = if cfg!(target_os = "linux") && !args.alternate && !args.debug {
|
||||||
Self::silent_get_output_stream()?
|
Self::silent_get_output_stream()?
|
||||||
} else {
|
} else {
|
||||||
OutputStream::try_default()?
|
OutputStream::try_default()?
|
||||||
@ -177,6 +189,8 @@ impl Player {
|
|||||||
.timeout(TIMEOUT)
|
.timeout(TIMEOUT)
|
||||||
.build()?,
|
.build()?,
|
||||||
sink,
|
sink,
|
||||||
|
volume,
|
||||||
|
list,
|
||||||
_handle: handle,
|
_handle: handle,
|
||||||
_stream,
|
_stream,
|
||||||
|
|
||||||
@ -188,12 +202,12 @@ impl Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// This will play the next track, as well as refilling the buffer in the background.
|
/// This will play the next track, as well as refilling the buffer in the background.
|
||||||
pub async fn next(&self) -> eyre::Result<DecodedTrack> {
|
pub async fn next(&self) -> eyre::Result<tracks::Decoded> {
|
||||||
let track = match self.tracks.write().await.pop_front() {
|
let track = match self.tracks.write().await.pop_front() {
|
||||||
Some(x) => x,
|
Some(x) => x,
|
||||||
// If the queue is completely empty, then fallback to simply getting a new track.
|
// If the queue is completely empty, then fallback to simply getting a new track.
|
||||||
// This is relevant particularly at the first song.
|
// This is relevant particularly at the first song.
|
||||||
None => Track::random(&self.client).await?,
|
None => self.list.random(&self.client).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoded = track.decode()?;
|
let decoded = track.decode()?;
|
||||||
@ -255,7 +269,6 @@ impl Player {
|
|||||||
/// skip tracks or pause.
|
/// skip tracks or pause.
|
||||||
pub async fn play(
|
pub async fn play(
|
||||||
player: Arc<Self>,
|
player: Arc<Self>,
|
||||||
properties: InitialProperties,
|
|
||||||
tx: Sender<Messages>,
|
tx: Sender<Messages>,
|
||||||
mut rx: Receiver<Messages>,
|
mut rx: Receiver<Messages>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
@ -267,7 +280,7 @@ impl Player {
|
|||||||
Downloader::notify(&itx).await?;
|
Downloader::notify(&itx).await?;
|
||||||
|
|
||||||
// Set the initial sink volume to the one specified.
|
// Set the initial sink volume to the one specified.
|
||||||
player.set_volume(properties.volume as f32 / 100.0);
|
player.set_volume(player.volume.float());
|
||||||
|
|
||||||
// Whether the last signal was a `NewSong`.
|
// Whether the last signal was a `NewSong`.
|
||||||
// This is helpful, since we only want to autoplay
|
// This is helpful, since we only want to autoplay
|
||||||
|
@ -7,9 +7,7 @@ use tokio::{
|
|||||||
task::{self, JoinHandle},
|
task::{self, JoinHandle},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::tracks::Track;
|
use super::{Player, BUFFER_SIZE, TIMEOUT};
|
||||||
|
|
||||||
use super::{Player, BUFFER_SIZE};
|
|
||||||
|
|
||||||
/// This struct is responsible for downloading tracks in the background.
|
/// This struct is responsible for downloading tracks in the background.
|
||||||
///
|
///
|
||||||
@ -52,11 +50,14 @@ impl Downloader {
|
|||||||
while self.rx.recv().await == Some(()) {
|
while self.rx.recv().await == Some(()) {
|
||||||
// For each update notification, we'll push tracks until the buffer is completely full.
|
// For each update notification, we'll push tracks until the buffer is completely full.
|
||||||
while self.player.tracks.read().await.len() < BUFFER_SIZE {
|
while self.player.tracks.read().await.len() < BUFFER_SIZE {
|
||||||
let Ok(track) = Track::random(&self.player.client).await else {
|
match self.player.list.random(&self.player.client).await {
|
||||||
continue;
|
Ok(track) => self.player.tracks.write().await.push_back(track),
|
||||||
};
|
Err(error) => {
|
||||||
|
if !error.is_timeout() {
|
||||||
self.player.tracks.write().await.push_back(track);
|
tokio::time::sleep(TIMEOUT).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use crossterm::style::Stylize;
|
use crossterm::style::Stylize;
|
||||||
|
|
||||||
use crate::{player::Player, tracks::TrackInfo};
|
use crate::{player::Player, tracks::Info};
|
||||||
|
|
||||||
/// Small helper function to format durations.
|
/// Small helper function to format durations.
|
||||||
pub fn format_duration(duration: &Duration) -> String {
|
pub fn format_duration(duration: &Duration) -> String {
|
||||||
@ -51,8 +51,8 @@ pub fn audio_bar(volume: f32, percentage: &str, width: usize) -> String {
|
|||||||
|
|
||||||
/// This represents the main "action" bars state.
|
/// This represents the main "action" bars state.
|
||||||
enum ActionBar {
|
enum ActionBar {
|
||||||
Paused(TrackInfo),
|
Paused(Info),
|
||||||
Playing(TrackInfo),
|
Playing(Info),
|
||||||
Loading,
|
Loading,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,17 +61,17 @@ 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())),
|
Self::Playing(x) => ("playing", Some((x.name.clone(), x.width))),
|
||||||
Self::Paused(x) => ("paused", Some(x.name.clone())),
|
Self::Paused(x) => ("paused", Some((x.name.clone(), x.width))),
|
||||||
Self::Loading => ("loading", None),
|
Self::Loading => ("loading", None),
|
||||||
};
|
};
|
||||||
|
|
||||||
subject.map_or_else(
|
subject.map_or_else(
|
||||||
|| (word.to_owned(), word.len()),
|
|| (word.to_owned(), word.len()),
|
||||||
|subject| {
|
|(subject, len)| {
|
||||||
(
|
(
|
||||||
format!("{} {}", word, subject.clone().bold()),
|
format!("{} {}", word, subject.clone().bold()),
|
||||||
word.len() + 1 + subject.len(),
|
word.len() + 1 + len,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -6,28 +6,11 @@ use std::{io::Cursor, time::Duration};
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use inflector::Inflector;
|
use inflector::Inflector;
|
||||||
use rand::Rng;
|
|
||||||
use reqwest::Client;
|
|
||||||
use rodio::{Decoder, Source};
|
use rodio::{Decoder, Source};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
/// Downloads a raw track, but doesn't decode it.
|
pub mod list;
|
||||||
async fn download(track: &str, client: &Client) -> eyre::Result<Bytes> {
|
|
||||||
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
|
|
||||||
let response = client.get(url).send().await?;
|
|
||||||
let data = response.bytes().await?;
|
|
||||||
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a random track from `tracks.txt` and returns it.
|
|
||||||
fn random() -> &'static str {
|
|
||||||
let tracks: Vec<&str> = include_str!("../data/tracks.txt")
|
|
||||||
.split_ascii_whitespace()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let random = rand::thread_rng().gen_range(0..tracks.len());
|
|
||||||
tracks[random]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>>;
|
||||||
@ -37,26 +20,38 @@ pub type DecodedData = Decoder<Cursor<Bytes>>;
|
|||||||
/// 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, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
pub struct TrackInfo {
|
pub struct Info {
|
||||||
/// 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 name: 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
|
/// The duration of the track, this is an [Option] because there are
|
||||||
/// cases where the duration of a track is unknown.
|
/// cases where the duration of a track is unknown.
|
||||||
pub duration: Option<Duration>,
|
pub duration: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrackInfo {
|
impl Info {
|
||||||
|
/// Decodes a URL string into normal UTF-8.
|
||||||
|
fn decode_url(text: &str) -> String {
|
||||||
|
form_urlencoded::parse(text.as_bytes())
|
||||||
|
.map(|(key, val)| [key, val].concat())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Formats a name with [Inflector].
|
/// Formats a name with [Inflector].
|
||||||
/// This will also strip the first few numbers that are
|
/// This will also strip the first few numbers that are
|
||||||
/// usually present on most lofi tracks.
|
/// usually present on most lofi tracks.
|
||||||
fn format_name(name: &'static str) -> String {
|
fn format_name(name: &str) -> String {
|
||||||
let mut formatted = name
|
let formatted = Self::decode_url(
|
||||||
.split("/")
|
name.split("/")
|
||||||
.nth(2)
|
.last()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.strip_suffix(".mp3")
|
.strip_suffix(".mp3")
|
||||||
.unwrap()
|
.unwrap(),
|
||||||
|
)
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.to_title_case()
|
.to_title_case()
|
||||||
// Inflector doesn't like contractions...
|
// Inflector doesn't like contractions...
|
||||||
@ -73,9 +68,7 @@ impl TrackInfo {
|
|||||||
// This is incremented for each digit in front of the song name.
|
// This is incremented for each digit in front of the song name.
|
||||||
let mut skip = 0;
|
let mut skip = 0;
|
||||||
|
|
||||||
// SAFETY: All of the track names originate with the `'static` lifetime,
|
for character in formatted.as_bytes() {
|
||||||
// SAFETY: so basically this has already been checked.
|
|
||||||
for character in unsafe { formatted.as_bytes_mut() } {
|
|
||||||
if character.is_ascii_digit() {
|
if character.is_ascii_digit() {
|
||||||
skip += 1;
|
skip += 1;
|
||||||
} else {
|
} else {
|
||||||
@ -87,30 +80,33 @@ impl TrackInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
|
/// Creates a new [`TrackInfo`] from a raw name & decoded track data.
|
||||||
pub fn new(name: &'static str, decoded: &DecodedData) -> Self {
|
pub fn new(name: String, decoded: &DecodedData) -> Self {
|
||||||
|
let name = Self::format_name(&name);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
duration: decoded.total_duration(),
|
duration: decoded.total_duration(),
|
||||||
name: Self::format_name(name),
|
width: name.width(),
|
||||||
|
name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This struct is seperate from [Track] since it is generated lazily from
|
/// This struct is seperate from [Track] since it is generated lazily from
|
||||||
/// a track, and not when the track is first downloaded.
|
/// a track, and not when the track is first downloaded.
|
||||||
pub struct DecodedTrack {
|
pub struct Decoded {
|
||||||
/// Has both the formatted name and some information from the decoded data.
|
/// Has both the formatted name and some information from the decoded data.
|
||||||
pub info: TrackInfo,
|
pub info: Info,
|
||||||
|
|
||||||
/// The decoded data, which is able to be played by [rodio].
|
/// The decoded data, which is able to be played by [rodio].
|
||||||
pub data: DecodedData,
|
pub data: DecodedData,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedTrack {
|
impl Decoded {
|
||||||
/// Creates a new track.
|
/// Creates a new track.
|
||||||
/// 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 = TrackInfo::new(track.name, &data);
|
let info = Info::new(track.name, &data);
|
||||||
|
|
||||||
Ok(Self { info, data })
|
Ok(Self { info, data })
|
||||||
}
|
}
|
||||||
@ -119,7 +115,7 @@ impl DecodedTrack {
|
|||||||
/// The main track struct, which only includes data & the track name.
|
/// The main track struct, which only includes data & the track name.
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
/// This name is not formatted, and also includes the month & year of the track.
|
/// This name is not formatted, and also includes the month & year of the track.
|
||||||
pub name: &'static str,
|
pub name: String,
|
||||||
|
|
||||||
/// The raw data of the track, which is not decoded and
|
/// The raw data of the track, which is not decoded and
|
||||||
/// therefore much more memory efficient.
|
/// therefore much more memory efficient.
|
||||||
@ -127,18 +123,10 @@ pub struct Track {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Track {
|
impl Track {
|
||||||
/// Fetches and downloads a random track from the tracklist.
|
|
||||||
pub async fn random(client: &Client) -> eyre::Result<Self> {
|
|
||||||
let name = random();
|
|
||||||
let data = download(name, client).await?;
|
|
||||||
|
|
||||||
Ok(Self { data, name })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This will actually decode and format the track,
|
/// This will actually decode and format the track,
|
||||||
/// returning a [`DecodedTrack`] which can be played
|
/// returning a [`DecodedTrack`] which can be played
|
||||||
/// and also has a duration & formatted name.
|
/// and also has a duration & formatted name.
|
||||||
pub fn decode(self) -> eyre::Result<DecodedTrack> {
|
pub fn decode(self) -> eyre::Result<Decoded> {
|
||||||
DecodedTrack::new(self)
|
Decoded::new(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
96
src/tracks/list.rs
Normal file
96
src/tracks/list.rs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
use bytes::Bytes;
|
||||||
|
use rand::Rng;
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use super::Track;
|
||||||
|
|
||||||
|
/// Represents a list of tracks that can be played.
|
||||||
|
///
|
||||||
|
/// # Format
|
||||||
|
///
|
||||||
|
/// In [List]'s, the first line should be the base URL, followed
|
||||||
|
/// by the rest of the tracks.
|
||||||
|
///
|
||||||
|
/// Each track will be first appended to the base URL, and then
|
||||||
|
/// the result use to download the track. All tracks should end
|
||||||
|
/// in `.mp3` and as such must be in the MP3 format.
|
||||||
|
///
|
||||||
|
/// lowfi won't put a `/` between the base & track for added flexibility,
|
||||||
|
/// so for most cases you should have a trailing `/` in your base url.
|
||||||
|
/// The exception to this is if the track name begins with something like
|
||||||
|
/// `https://`, where in that case the base will not be prepended to it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct List {
|
||||||
|
lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl List {
|
||||||
|
/// Gets the base URL of the [List].
|
||||||
|
pub fn base(&self) -> &str {
|
||||||
|
self.lines[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the name of a random track.
|
||||||
|
fn random_name(&self) -> String {
|
||||||
|
// We're getting from 1 here, since due to how rust vectors work it's
|
||||||
|
// slow to drain only a single element from the start, so we can just keep it in.
|
||||||
|
let random = rand::thread_rng().gen_range(1..self.lines.len());
|
||||||
|
self.lines[random].to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a raw track, but doesn't decode it.
|
||||||
|
async fn download(&self, track: &str, client: &Client) -> reqwest::Result<Bytes> {
|
||||||
|
// If the track has a protocol, then we should ignore the base for it.
|
||||||
|
let url = if track.contains("://") {
|
||||||
|
track.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", self.base(), track)
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.get(url).send().await?;
|
||||||
|
let data = response.bytes().await?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches and downloads a random track from the [List].
|
||||||
|
pub async fn random(&self, client: &Client) -> reqwest::Result<Track> {
|
||||||
|
let name = self.random_name();
|
||||||
|
let data = self.download(&name, client).await?;
|
||||||
|
|
||||||
|
Ok(Track { name, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses text into a [List].
|
||||||
|
pub fn new(text: &str) -> eyre::Result<Self> {
|
||||||
|
let lines: Vec<String> = text
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.map(|x| x.to_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Self { lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a [List] from the filesystem using the CLI argument provided.
|
||||||
|
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()
|
||||||
|
.unwrap()
|
||||||
|
.join("lowfi")
|
||||||
|
.join(arg)
|
||||||
|
.join(".txt");
|
||||||
|
|
||||||
|
let raw = if name.exists() {
|
||||||
|
fs::read_to_string(name).await?
|
||||||
|
} else {
|
||||||
|
fs::read_to_string(arg).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
List::new(&raw)
|
||||||
|
} else {
|
||||||
|
List::new(include_str!("../../data/lofigirl.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user