From 2102564d04125042bbd6b714d10fe4bc9e10d5aa Mon Sep 17 00:00:00 2001 From: Tal <83217276+talwat@users.noreply.github.com> Date: Wed, 25 Sep 2024 22:32:16 +0200 Subject: [PATCH] feat: add duration to tracks when possible --- Cargo.lock | 107 +++++++++++++++++++--------- Cargo.toml | 21 ++++-- src/main.rs | 3 +- src/play.rs | 24 +++++++ src/player.rs | 180 ++++++++++++++++++++++------------------------- src/player/ui.rs | 84 ++++++++++++++++++++++ src/tracks.rs | 31 ++++++-- 7 files changed, 308 insertions(+), 142 deletions(-) create mode 100644 src/play.rs create mode 100644 src/player/ui.rs diff --git a/Cargo.lock b/Cargo.lock index eaf49b4..67a1d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 006b0fb..1ac437c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/main.rs b/src/main.rs index 732f87c..d81dad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, } } diff --git a/src/play.rs b/src/play.rs new file mode 100644 index 0000000..8350058 --- /dev/null +++ b/src/play.rs @@ -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(()) +} diff --git a/src/player.rs b/src/player.rs index 2577811..ef65bdb 100644 --- a/src/player.rs +++ b/src/player.rs @@ -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>>, +/// 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, + tracks: RwLock>, + 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 { + 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 { - // This refills the queue in the background. - task::spawn({ - let client = client.clone(); - let tracks = self.tracks.clone(); + pub async fn next(queue: Arc) -> eyre::Result { + 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, - ) -> eyre::Result<()> { - let sink = Arc::new(sink); + pub async fn play(queue: Arc, mut rx: Receiver) -> 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)?); + queue.sink.stop(); + + let track = Player::next(queue.clone()).await?; + queue.sink.append(track.data); } - Messages::Die => break, - } - } - - Ok(()) - } -} - -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?; + Messages::Pause => { + if queue.sink.is_paused() { + queue.sink.play(); + } else { + queue.sink.pause(); } } - _ => (), - }, - _ => (), + } } } - - audio.abort(); - crossterm::terminal::disable_raw_mode()?; - Ok(()) } diff --git a/src/player/ui.rs b/src/player/ui.rs new file mode 100644 index 0000000..9af5b62 --- /dev/null +++ b/src/player/ui.rs @@ -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) -> 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, sender: Sender) -> 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(()) +} diff --git a/src/tracks.rs b/src/tracks.rs index 3c4bd0d..59751f9 100644 --- a/src/tracks.rs +++ b/src/tracks.rs @@ -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; +pub type Data = Decoder>; async fn download(track: &str, client: &Client) -> eyre::Result { 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, +} + +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, + }) } }