feat: add duration to tracks when possible

This commit is contained in:
Tal 2024-09-25 22:32:16 +02:00
parent 681889a268
commit 2102564d04
7 changed files with 308 additions and 142 deletions

107
Cargo.lock generated
View File

@ -110,6 +110,18 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -179,6 +191,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytemuck"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -909,6 +927,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.158"
@ -951,6 +975,7 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
name = "lowifi"
version = "0.1.0"
dependencies = [
"arc-swap",
"bytes",
"clap",
"crossterm",
@ -1010,26 +1035,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minimp3-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e21c73734c69dc95696c9ed8926a2b393171d98b3f5f5935686a26a487ab9b90"
dependencies = [
"cc",
]
[[package]]
name = "minimp3_fixed"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b0f14e7e75da97ae396c2656b10262a3d4afa2ec98f35795630eff0c8b951b"
dependencies = [
"minimp3-sys",
"slice-ring-buffer",
"thiserror",
]
[[package]]
name = "miniz_oxide"
version = "0.8.0"
@ -1570,7 +1575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
dependencies = [
"cpal",
"minimp3_fixed",
"symphonia",
"thiserror",
]
@ -1831,17 +1836,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "slice-ring-buffer"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84ae312bda09b2368f79f985fdb4df4a0b5cbc75546b511303972d195f8c27d6"
dependencies = [
"libc",
"mach2",
"winapi",
]
[[package]]
name = "smallvec"
version = "1.13.2"
@ -1908,6 +1902,55 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symphonia"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "syn"
version = "2.0.77"

View File

@ -4,13 +4,22 @@ version = "0.1.0"
edition = "2021"
[dependencies]
# Basics
clap = { version = "4.5.18", features = ["derive", "cargo"] }
reqwest = { version = "0.12.7", features = ["blocking"] }
tokio = { version = "1.40.0", features = ["full"] }
scraper = "0.20.0"
rodio = { version = "0.19.0", features = ["minimp3"], default-features = false }
eyre = "0.6.12"
futures = "0.3.30"
bytes = "1.7.2"
rand = "0.8.5"
# Async
tokio = { version = "1.40.0", features = ["full"] }
futures = "0.3.30"
arc-swap = "1.7.1"
# Data
reqwest = { version = "0.12.7", features = ["blocking"] }
bytes = "1.7.2"
# Misc
scraper = "0.20.0"
rodio = { version = "0.19.0", features = ["mp3"], default-features = false }
crossterm = "0.28.1"

View File

@ -1,5 +1,6 @@
use clap::{Parser, Subcommand};
mod play;
mod player;
mod scrape;
mod tracks;
@ -26,6 +27,6 @@ async fn main() -> eyre::Result<()> {
match cli.command {
Commands::Scrape => scrape::scrape().await,
Commands::Play => player::play().await,
Commands::Play => play::play().await,
}
}

24
src/play.rs Normal file
View File

@ -0,0 +1,24 @@
use std::sync::Arc;
use tokio::{
sync::mpsc::{self},
task::{self},
};
use crate::player::Player;
use crate::player::{ui, Messages};
pub async fn play() -> eyre::Result<()> {
let (tx, rx) = mpsc::channel(8);
let player = Arc::new(Player::new().await?);
let audio = task::spawn(Player::play(player.clone(), rx));
tx.send(Messages::Init).await?;
ui::start(player.clone(), tx.clone()).await?;
audio.abort();
player.sink.stop();
Ok(())
}

View File

@ -1,11 +1,8 @@
use std::{collections::VecDeque, io::stderr, sync::Arc};
use std::{collections::VecDeque, sync::Arc};
use crossterm::{
cursor::{MoveDown, MoveToColumn, MoveToNextLine},
style::Print,
};
use arc_swap::ArcSwapOption;
use reqwest::Client;
use rodio::{Decoder, OutputStream, Sink};
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
use tokio::{
select,
sync::{
@ -15,56 +12,69 @@ use tokio::{
task,
};
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
use crate::tracks::{Track, TrackInfo};
use crate::tracks::Track;
pub mod ui;
/// Handles communication between the frontend & audio player.
pub enum Messages {
Skip,
Die,
Next,
Init,
Pause,
}
/// Main struct responsible for queuing up tracks.
///
/// Internally tracks are stored in an [Arc],
/// so it's fine to clone this struct.
#[derive(Debug, Clone)]
pub struct Queue {
tracks: Arc<RwLock<VecDeque<Track>>>,
/// The amount of songs to buffer up.
const BUFFER_SIZE: usize = 5;
/// Main struct responsible for queuing up & playing tracks.
pub struct Player {
pub sink: Sink,
pub current: ArcSwapOption<TrackInfo>,
tracks: RwLock<VecDeque<Track>>,
client: Client,
_handle: OutputStreamHandle,
_stream: OutputStream,
}
impl Queue {
pub async fn new() -> Self {
Self {
tracks: Arc::new(RwLock::new(VecDeque::with_capacity(5))),
unsafe impl Send for Player {}
unsafe impl Sync for Player {}
impl Player {
/// Initializes the entire player, including audio devices & sink.
pub async fn new() -> eyre::Result<Self> {
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
Ok(Self {
tracks: RwLock::new(VecDeque::with_capacity(5)),
current: ArcSwapOption::new(None),
client: Client::builder().build()?,
sink,
_handle: handle,
_stream,
})
}
async fn set_current(&self, info: TrackInfo) -> eyre::Result<()> {
self.current.store(Some(Arc::new(info)));
Ok(())
}
/// This will play the next track, as well as refilling the buffer in the background.
pub async fn next(&self, client: &Client) -> eyre::Result<Track> {
// This refills the queue in the background.
task::spawn({
let client = client.clone();
let tracks = self.tracks.clone();
pub async fn next(queue: Arc<Player>) -> eyre::Result<Track> {
queue.current.store(None);
async move {
while tracks.read().await.len() < BUFFER_SIZE {
let track = Track::random(&client).await.unwrap();
tracks.write().await.push_back(track);
}
}
});
let track = self.tracks.write().await.pop_front();
let track = queue.tracks.write().await.pop_front();
let track = match track {
Some(x) => x,
// If the queue is completely empty, then fallback to simply getting a new track.
// This is relevant particularly at the first song.
None => Track::random(client).await?,
None => Track::random(&queue.client).await?,
};
queue.set_current(track.info).await?;
Ok(track)
}
@ -72,78 +82,54 @@ impl Queue {
///
/// `rx` is used to communicate with it, for example when to
/// skip tracks or pause.
pub async fn play(
self,
sink: Sink,
client: Client,
mut rx: Receiver<Messages>,
) -> eyre::Result<()> {
let sink = Arc::new(sink);
pub async fn play(queue: Arc<Player>, mut rx: Receiver<Messages>) -> eyre::Result<()> {
// This is an internal channel which serves pretty much only one purpose,
// which is to notify the buffer refiller to get back to work.
// This channel is useful to prevent needing to check with some infinite loop.
let (itx, mut irx) = mpsc::channel(8);
// This refills the queue in the background.
task::spawn({
let queue = queue.clone();
async move {
while let Some(()) = irx.recv().await {
while queue.tracks.read().await.len() < BUFFER_SIZE {
let track = Track::random(&queue.client).await.unwrap();
queue.tracks.write().await.push_back(track);
}
}
}
});
itx.send(()).await?;
loop {
let clone = sink.clone();
let clone = Arc::clone(&queue);
let msg = select! {
Some(x) = rx.recv() => x,
// This future will finish only at the end of the current track.
Ok(()) = task::spawn_blocking(move || clone.sleep_until_end()) => Messages::Skip,
Ok(()) = task::spawn_blocking(move || clone.sink.sleep_until_end()) => Messages::Next,
};
match msg {
Messages::Skip => {
sink.stop();
Messages::Next | Messages::Init => {
itx.send(()).await?;
let track = self.next(&client).await?;
sink.append(Decoder::new(track.data)?);
}
Messages::Die => break,
}
}
queue.sink.stop();
Ok(())
let track = Player::next(queue.clone()).await?;
queue.sink.append(track.data);
}
Messages::Pause => {
if queue.sink.is_paused() {
queue.sink.play();
} else {
queue.sink.pause();
}
}
}
}
}
}
pub async fn gui() -> eyre::Result<()> {
crossterm::execute!(stderr(), MoveToColumn(0), Print("hello!\r\n"))?;
crossterm::execute!(stderr(), Print("next line!\r\n"))?;
Ok(())
}
pub async fn play() -> eyre::Result<()> {
let queue = Queue::new().await;
let (tx, rx) = mpsc::channel(8);
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
let client = Client::builder().build()?;
let audio = task::spawn(queue.clone().play(sink, client.clone(), rx));
tx.send(Messages::Skip).await?; // This is responsible for the initial track being played.
crossterm::terminal::enable_raw_mode()?;
gui().await?;
'a: loop {
match crossterm::event::read()? {
crossterm::event::Event::Key(event) => match event.code {
crossterm::event::KeyCode::Char(x) => {
if x == 'q' {
tx.send(Messages::Die).await?;
break 'a;
} else if x == 's' {
tx.send(Messages::Skip).await?;
}
}
_ => (),
},
_ => (),
}
}
audio.abort();
crossterm::terminal::disable_raw_mode()?;
Ok(())
}

84
src/player/ui.rs Normal file
View File

@ -0,0 +1,84 @@
use std::{io::stderr, sync::Arc, time::Duration};
use super::Player;
use crossterm::{
cursor::{MoveTo, MoveToColumn, MoveUp},
style::Print,
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use tokio::{
sync::mpsc::Sender,
task::{self},
time::sleep,
};
use super::Messages;
async fn interface(queue: Arc<Player>) -> eyre::Result<()> {
const WIDTH: usize = 25;
loop {
// We can get away with only redrawing every 0.25 seconds
// since it's just an audio player.
sleep(Duration::from_secs_f32(1.0 / 60.0)).await;
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?;
let main = match queue.current.load().as_ref() {
Some(x) => {
if queue.sink.is_paused() {
format!("paused {}\r\n", x.format_name())
} else {
format!("playing {}\r\n", x.format_name())
}
}
None => "loading...\r\n".to_owned(),
};
let bar = ["[s]kip", "[p]ause", "[q]uit"];
crossterm::execute!(stderr(), MoveToColumn(0), Print(main))?;
crossterm::execute!(stderr(), Print(bar.join(" ")))?;
crossterm::execute!(stderr(), MoveToColumn(0), MoveUp(1))?;
}
}
pub async fn start(queue: Arc<Player>, sender: Sender<Messages>) -> eyre::Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?;
task::spawn(interface(queue.clone()));
loop {
let crossterm::event::Event::Key(event) = crossterm::event::read()? else {
continue;
};
let crossterm::event::KeyCode::Char(code) = event.code else {
continue;
};
match code {
'q' => {
break;
}
's' => {
if !queue.current.load().is_none() {
sender.send(Messages::Next).await?
}
}
'p' => {
sender.send(Messages::Pause).await?;
}
_ => {}
}
}
crossterm::execute!(
stderr(),
Clear(ClearType::FromCursorDown),
LeaveAlternateScreen
)?;
crossterm::terminal::disable_raw_mode()?;
Ok(())
}

View File

@ -1,17 +1,19 @@
use std::io::Cursor;
use std::{io::Cursor, time::Duration};
use bytes::Bytes;
use rand::Rng;
use reqwest::Client;
use rodio::{Decoder, Source};
pub type Data = Cursor<Bytes>;
pub type Data = Decoder<Cursor<Bytes>>;
async fn download(track: &str, client: &Client) -> eyre::Result<Data> {
let url = format!("https://lofigirl.com/wp-content/uploads/{}", track);
let response = client.get(url).send().await?;
let file = Cursor::new(response.bytes().await?);
let source = Decoder::new(file)?;
Ok(file)
Ok(source)
}
async fn random() -> eyre::Result<&'static str> {
@ -24,9 +26,20 @@ async fn random() -> eyre::Result<&'static str> {
Ok(track)
}
#[derive(Debug, PartialEq)]
pub struct Track {
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TrackInfo {
pub name: &'static str,
pub duration: Option<Duration>,
}
impl TrackInfo {
pub fn format_name(&self) -> &'static str {
self.name.split("/").nth(2).unwrap()
}
}
pub struct Track {
pub info: TrackInfo,
pub data: Data,
}
@ -35,6 +48,12 @@ impl Track {
let name = random().await?;
let data = download(&name, client).await?;
Ok(Self { name, data })
Ok(Self {
info: TrackInfo {
name,
duration: data.total_duration(),
},
data,
})
}
}