diff --git a/.codespellrc b/.codespellrc index 615ecac..ad6e3c5 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,2 +1,2 @@ [codespell] -ignore-words-list = ratatui,crate +ignore-words-list = ratatui,crate,ser diff --git a/Cargo.lock b/Cargo.lock index 88f4e6b..c96778d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,10 +33,11 @@ dependencies = [ "color-eyre", "cpal", "crossterm", + "directories", "dotenv", + "figment", "futures", "itertools 0.14.0", - "log", "md5", "rand", "ratatui", @@ -47,6 +48,10 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "toml", + "tracing", + "tracing-error", + "tracing-subscriber", ] [[package]] @@ -83,6 +88,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -426,6 +440,27 @@ dependencies = [ "serde", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -490,6 +525,19 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1052,7 +1100,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -1125,6 +1173,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.8.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1171,6 +1229,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "md5" version = "0.7.0" @@ -1244,7 +1311,7 @@ dependencies = [ "log", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1272,6 +1339,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1410,6 +1487,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "3.9.2" @@ -1419,6 +1502,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "3.5.0" @@ -1591,6 +1680,17 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "regex" version = "1.11.1" @@ -1599,8 +1699,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1611,9 +1720,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -1849,6 +1964,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2135,7 +2259,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -2149,6 +2282,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -2286,11 +2430,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -2299,6 +2458,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] @@ -2337,9 +2498,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "tracing-core" version = "0.1.33" @@ -2360,15 +2533,35 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "serde", + "serde_json", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -2377,6 +2570,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.17" @@ -2453,6 +2655,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d405ac5..4f9090b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,25 @@ version = "0.1.0" edition = "2024" [dependencies] -log = "0.4" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = [ + "serde", + "env-filter", + "serde_json", +] } +tracing-error = "0.2" + color-eyre = "0.6" -dotenv = "0.15" -serde = { version = "1", features = ["derive"]} itertools = "0.14" +dotenv = "0.15" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +figment = { version = "0.10", features = ["toml"] } +directories = "6" + ratatui = "0.29" -crossterm = { version = "0.28.1", features = ["event-stream","serde"] } +crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } tokio = { version = "1.43", features = ["macros", "rt"] } tokio-stream = "0.1" diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..3385644 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + TryPlaySong(String), + AddToSink(String), + ScrollUp, + ScrollDown, + Quit, + Init, + RandomQueue, + ClosePopUp, +} diff --git a/src/app.rs b/src/app.rs index 15b2091..57a642e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,16 +1,49 @@ use color_eyre::eyre::Result; use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent}; -use ratatui::{Terminal, prelude::CrosstermBackend}; +use ratatui::{ + Terminal, + buffer::Buffer, + layout::{Constraint, Flex, Layout, Rect}, + prelude::CrosstermBackend, + widgets::{Block, Clear, Paragraph, StatefulWidget, Widget, Wrap}, +}; use reqwest::Client; +use serde::{Deserialize, Serialize}; use std::io::Stdout; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tracing::{debug, info}; use crate::{ + action::Action, event::{Event, Events}, player::Player, widgets::{Queue, QueueWidget}, }; +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + #[default] + Queue, + Popup, + Help, + Quit, +} + pub struct App { + /// Receiver end of an asynchronous channel for actions that the app needs + /// to process. + rx: UnboundedReceiver, + + /// Sender end of an asynchronous channel for dispatching actions from + /// various parts of the app to be handled by the event loop. + tx: UnboundedSender, + + /// The active mode of the application, which could change how user inputs + /// and commands are interpreted. + mode: Mode, + + popup: Option<(String, String)>, + client: Client, player: Player, queue: Queue, @@ -18,10 +51,15 @@ pub struct App { impl App { pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); Self { - player: Player::new(), + player: Player::new(tx.clone()), client: Client::default(), queue: Queue::default(), + rx, + tx, + mode: Mode::default(), + popup: None, } } @@ -30,34 +68,153 @@ impl App { mut terminal: Terminal>, mut events: Events, ) -> Result<()> { - self.queue.request_random(self.client.clone()); - loop { - if let Some(evt) = events.next().await { - match evt { - Event::Render => { - terminal.draw(|frame| { - frame.render_stateful_widget( - QueueWidget, - frame.area(), - &mut self.queue, - ); - })?; - } - Event::Crossterm(CrosstermEvent::Key(KeyEvent { code, .. })) => match code { - KeyCode::Up => self.queue.up(), - KeyCode::Down => self.queue.down(), - KeyCode::Enter => self - .player - .play(self.queue.get_select_url(), self.client.clone()), + self.tx.send(Action::Init)?; - KeyCode::Char('q') => break, - _ => {} - }, - Event::Quit => break, - _ => {} + loop { + if let Some(e) = events.next().await { + self.handle_event(e)?.map(|action| self.tx.send(action)); + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action.clone())?; + if matches!(action, Action::Resize(_, _) | Action::Render) { + self.draw(&mut terminal)?; } - }; + } + if self.should_quit() { + break; + } } Ok(()) } + + /// Handles an event by producing an optional `Action` that the application + /// should perform in response. + /// + /// This method maps incoming events from the terminal user interface to + /// specific `Action` that represents tasks or operations the + /// application needs to carry out. + fn handle_event(&mut self, e: Event) -> Result> { + let maybe_action = match e { + Event::Quit => Some(Action::Quit), + Event::Tick => Some(Action::Tick), + Event::Render => Some(Action::Render), + Event::Crossterm(CrosstermEvent::Resize(x, y)) => Some(Action::Resize(x, y)), + Event::Crossterm(CrosstermEvent::Key(key)) => self.handle_key_event(key), + _ => None, + }; + Ok(maybe_action) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Option { + debug!("Received key {:?}", key); + match self.mode { + Mode::Queue => { + match key.code { + KeyCode::Esc => Some(Action::ClosePopUp), + KeyCode::Enter => match self.mode { + Mode::Queue => Some(Action::TryPlaySong(self.queue.get_selected_id())), + _ => None, + }, + KeyCode::Up => Some(Action::ScrollUp), + KeyCode::Down => Some(Action::ScrollDown), + KeyCode::Char(char) => match char { + 'q' | 'Q' => Some(Action::Quit), + 'r' | 'R' => Some(Action::RandomQueue), + _ => None, + }, + _ => { + // key not handled + None + } + } + } + Mode::Popup => todo!(), + Mode::Help => todo!(), + Mode::Quit => None, + } + } + + /// Performs the `Action` by calling on a respective app method. + /// + /// Upon receiving an action, this function updates the application state, performs necessary + /// operations like drawing or resizing the view, or changing the mode. Actions that affect the + /// navigation within the application, are also handled. Certain actions generate a follow-up + /// action which will be to be processed in the next iteration of the main event loop. + fn handle_action(&mut self, action: Action) -> Result<()> { + if action != Action::Tick && action != Action::Render { + info!("{action:?}"); + } + match action { + Action::Render | Action::Resize(_, _) => { // should be handled in main loop + } + Action::Tick => {} + Action::Quit => self.mode = Mode::Quit, + Action::Init => self.queue.request_random(self.client.clone()), + Action::TryPlaySong(id) => self.player.try_play(id, self.client.clone())?, + Action::AddToSink(id) => { + self.player.add_to_sink(id)?; + } + Action::ScrollUp => match self.mode { + Mode::Queue => self.queue.scroll_up(), + _ => {} + }, + Action::ScrollDown => match self.mode { + Mode::Queue => self.queue.scroll_down(), + _ => {} + }, + Action::RandomQueue => self.queue.request_random(self.client.clone()), + Action::ClosePopUp => self.popup = None, + } + Ok(()) + } + + fn draw(&mut self, terminal: &mut Terminal>) -> Result<()> { + terminal.draw(|frame| { + frame.render_stateful_widget(AppWidget, frame.area(), self); + })?; + Ok(()) + } + + fn should_quit(&self) -> bool { + self.mode == Mode::Quit + } +} + +struct AppWidget; + +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + match state.mode { + Mode::Help => {} + Mode::Popup => {} + Mode::Quit => {} + Mode::Queue => QueueWidget.render(area, buf, &mut state.queue), + }; + + //if state.loading() { + // Line::from(state.spinner()) + // .right_aligned() + // .render(main, buf); + //} + + if let Some((title, message)) = &mut state.popup { + let block = Block::bordered().title(title.as_str()); + let area = popup_area(area, 60, 20); + Clear.render(area, buf); + Paragraph::new(message.clone()) + .block(block) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + } +} + +fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..68f0c62 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,123 @@ +use color_eyre::eyre::{Result, eyre}; +use directories::ProjectDirs; +use figment::{ + Figment, + providers::{Format, Serialized, Toml}, +}; +use serde::{Deserialize, Serialize}; +use std::{env, fs::File, io::Write, path::PathBuf, sync::OnceLock}; + +static CONFIG: OnceLock = OnceLock::new(); + +/// Application configuration. +/// +/// This is the main configuration struct for the application. +//#[serde_as] +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct Config { + pub general: GeneralOption, + pub subsonic: SubSonicOption, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct GeneralOption { + /// The directory to use for storing application data (logs etc.). + pub data_dir: PathBuf, + pub tick_rate: f64, + pub frame_rate: f64, +} + +impl Default for GeneralOption { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + tick_rate: 1.0, + frame_rate: 15.0, + } + } +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SubSonicOption { + pub password: String, + pub username: String, + pub base_url: String, +} + +impl SubSonicOption { + pub fn is_any_empty(&self) -> bool { + self.password.is_empty() || self.base_url.is_empty() || self.username.is_empty() + } +} + +/// Initialize the application configuration. +/// +/// This function should be called before any other function in the application. +/// It will initialize the application config from the following sources: +/// - default values +/// - a configuration file +/// +/// If application config does not exist create it with default values +pub fn init() -> Result<()> { + let config = Figment::new() + .merge(Serialized::defaults(Config::default())) + .merge(Toml::file(default_config_file())) + .extract::()?; + + if !default_config_file().exists() { + let serialized_config = toml::to_string(&config)?; + std::fs::create_dir_all(default_config_file().parent().expect("fail if is root dir"))?; + let mut config_file = File::create(default_config_file())?; + config_file.write_all(serialized_config.as_bytes())?; + } + + if config.subsonic.is_any_empty() { + return Err(eyre!("No subsonic configuration filed can be empty !")); + } + + CONFIG + .set(config) + .map_err(|config| eyre!("failed to set config {config:?}")) +} + +/// Get the application configuration. +/// +/// This function should only be called after [`init()`] has been called. +/// +/// # Panics +/// +/// This function will panic if [`init()`] has not been called. +pub fn get() -> &'static Config { + CONFIG.get().expect("config not initialized") +} + +/// Returns the path to the default configuration file. +pub fn default_config_file() -> PathBuf { + default_config_dir().join("config.toml") +} + +/// Returns the directory to use for storing config files. +fn default_config_dir() -> PathBuf { + project_dirs() + .map(|dirs| dirs.config_local_dir().to_path_buf()) + .unwrap_or(PathBuf::from(".").join(".config")) +} + +/// Returns the directory to use for storing data files. +pub fn default_data_dir() -> PathBuf { + project_dirs() + .map(|dirs| dirs.data_local_dir().to_path_buf()) + .unwrap_or(PathBuf::from(".").join(".data")) +} + +/// Returns the project directories. +fn project_dirs() -> Result { + ProjectDirs::from("", "", "akula").ok_or_else(|| eyre!("user home directory not found")) +} + +/// Returns temporary directory +pub fn temp_dir() -> PathBuf{ + let mut path = env::temp_dir(); + path.push("akula"); + path +} diff --git a/src/event.rs b/src/event.rs index 4be7862..b18df40 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,6 +5,8 @@ use std::{pin::Pin, time::Duration}; use tokio::time::interval; use tokio_stream::{StreamMap, wrappers::IntervalStream}; +use crate::config; + pub struct Events { streams: StreamMap>>>, } @@ -44,13 +46,13 @@ impl Events { } fn tick_stream() -> Pin>> { - let tick_delay = Duration::from_secs_f64(1.0); + let tick_delay = Duration::from_secs_f64(1. / config::get().general.tick_rate); let tick_interval = interval(tick_delay); Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick)) } fn render_stream() -> Pin>> { - let render_delay = Duration::from_secs_f64(1.0 / 15.); + let render_delay = Duration::from_secs_f64(1. / config::get().general.frame_rate); let render_interval = interval(render_delay); Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render)) } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index f254eb1..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,165 +0,0 @@ -use color_eyre::Result; -use cpal::traits::HostTrait; -use crossterm::event::{self, Event}; -use futures::StreamExt; -use rand::{Rng, distr::Alphanumeric, rng}; -use ratatui::{ - DefaultTerminal, Frame, - style::Style, - text::{Line, Span}, - widgets::{List, ListItem, ListState}, -}; -use std::{ - env, - fs::File, - io::{BufReader, ErrorKind}, - time::Duration, -}; -use subsonic_types::{ - common::Version, - request::{self, Authentication, Request, SubsonicRequest}, - response::{Child, Response, ResponseBody}, -}; -use tokio::task::{JoinSet, spawn_blocking}; - -const SALT_SIZE: usize = 36; - -async fn run(mut terminal: DefaultTerminal) -> Result<()> { - let res = test_subsonic().await?; - - let song_list = if let ResponseBody::RandomSongs(songs) = res { - songs.song - } else { - return Err(std::io::Error::new(ErrorKind::Other, "h").into()); - }; - let items: Vec<_> = song_list - .iter() - .map(|song| { - ListItem::new(Line::from(vec![Span::styled( - song.title.clone(), - Style::default(), - )])) - }) - .collect(); - - let list = List::new(items); - - // This should be stored outside of the function in your application state. - let mut state = ListState::default(); - - *state.offset_mut() = 1; // display the second item and onwards - state.select(Some(0)); // select the forth item (0-indexed) - loop { - terminal.draw(|frame| { - frame.render_stateful_widget(&list, frame.area(), &mut state); - })?; - if matches!(event::read()?, Event::Key(_)) { - break Ok(()); - } - } -} - -pub async fn async_audio_dl() -> color_eyre::Result<()> { - let url = "https://music.domain.com/rest/download?u=jika&t=f61772aa3ecf7c4015df08735c9f26fd&s=0fea81&f=json&v=1.8.0&c=NavidromeUI&id=adc722f5dea2ea434c03c8c14f54865c&format=raw&bitrate=0"; - let client = reqwest::Client::new(); - - let mut path = env::temp_dir(); - path.push("ro.mp3"); - let file = File::create(path)?; - - let mut set = JoinSet::new(); - - let fut = async move { - let mut tmp_file = tokio::fs::File::from(file); - let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream(); - - while let Some(item) = byte_stream.next().await { - tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file) - .await - .unwrap(); - } - }; - - set.spawn(fut); - let device = cpal::host_from_id(cpal::available_hosts() - .into_iter() - .find(|id| *id == cpal::HostId::Jack) - .expect( - "make sure --features jack is specified. only works on OSes where jack is available", - )).expect("jack host unavailable").default_output_device().unwrap(); - - let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap(); - set.spawn(async { - let _ = test_audio(handle).await; - }); - - while let Some(res) = set.join_next().await { - res?; - } - Ok(()) -} - -pub async fn test_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> { - let sink = rodio::Sink::try_new(&handle).unwrap(); - - //let response = reqwest::blocking::get(url)?; - //sink.append(rodio::Decoder::new(Cursor::new(response.bytes()?))?); - - //let file = std::fs::File::open("/home/jika/Downloads/Let It Be - Proket Remix.mp3").unwrap(); - let file = std::fs::File::open("/tmp/ro.mp3").unwrap(); - - let a = loop { - if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) { - break o; - } - tokio::time::sleep(Duration::from_millis(100)).await; - }; - - spawn_blocking(move || { - sink.append(a); - sink.sleep_until_end(); - }) - .await?; - - Ok(()) -} - -pub async fn test_subsonic() -> color_eyre::Result { - let base_url = "http://192.168.0.166:4533"; - - let salt: String = rng() - .sample_iter(Alphanumeric) - .take(SALT_SIZE) - .map(char::from) - .collect(); - - let pre_t = "password".to_string() + &salt; - let token = format!("{:x}", md5::compute(pre_t.as_bytes())); - - let random = Request { - username: "bob".into(), - authentication: Authentication::Token { token, salt }, - version: Version::LATEST, - client: "ping-example".into(), - format: None, - body: request::lists::GetRandomSongs { - size: Some(5), - genre: None, - to_year: None, - from_year: None, - music_folder_id: None, - }, - }; - let request_url = format!( - "{}{}?{}", - base_url, - request::lists::GetRandomSongs::PATH, - random.to_query() - ); - - let client = reqwest::Client::new(); - let response_contents = client.get(&request_url).send().await?.text().await?; - let response = Response::from_xml(&response_contents)?; - - Ok(response.body) -} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..956ec59 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,34 @@ +use color_eyre::eyre::Result; +use tracing::level_filters::LevelFilter; +use tracing_error::ErrorLayer; +use tracing_subscriber::{ + self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, +}; + +use crate::config; + +pub fn init() -> Result<()> { + let directory = config::default_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_file = format!("{}.log", env!("CARGO_PKG_NAME")); + let log_path = directory.join(log_file); + let log_file = std::fs::File::create(log_path)?; + let file_subscriber = tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false); + tracing_subscriber::registry() + .with(file_subscriber) + .with(ErrorLayer::default()) + .with( + tracing_subscriber::filter::EnvFilter::from_default_env() + .add_directive("tokio_util=off".parse().unwrap()) + .add_directive("hyper=off".parse().unwrap()) + .add_directive("reqwest=off".parse().unwrap()) + //.add_directive(config.log_level.unwrap_or(LevelFilter::OFF).into()), + ) + .init(); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index c3c4314..339df94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ mod app; +mod config; mod event; mod player; +mod subsonic_helper; mod widgets; +mod action; +mod logging; use color_eyre::eyre::Result; use dotenv::dotenv; @@ -10,10 +14,11 @@ use dotenv::dotenv; async fn main() -> Result<()> { color_eyre::install()?; dotenv().ok(); - - let terminal = ratatui::init(); + config::init()?; + logging::init()?; set_panic_hook(); + let terminal = ratatui::init(); let events = event::Events::new(); app::App::new().run(terminal, events).await?; diff --git a/src/player.rs b/src/player.rs index f91e3d0..dec0b05 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,19 +1,27 @@ -use std::{env, fs::File, time::Duration}; - +use color_eyre::eyre::Result; use cpal::traits::HostTrait; use futures::StreamExt; use reqwest::Client; -use rodio::{OutputStream, OutputStreamHandle}; +use rodio::{OutputStream, OutputStreamHandle, Sink}; +use subsonic_types::request; +use std::fs::{self, File}; use std::io::BufReader; -use tokio::task::spawn_blocking; +use std::time::Duration; +use tokio::sync::mpsc::UnboundedSender; + +use crate::action::Action; +use crate::config; +use crate::subsonic_helper::make_request_url; pub struct Player { - handle: OutputStreamHandle, + tx: UnboundedSender, + sink: Sink, + _handle: OutputStreamHandle, _stream: OutputStream, } impl Player { - pub fn new() -> Self { + pub fn new(tx: UnboundedSender) -> Self { let device = cpal::host_from_id(cpal::available_hosts() .into_iter() .find(|id| *id == cpal::HostId::Jack) @@ -21,56 +29,83 @@ impl Player { "make sure --features jack is specified. only works on OSes where jack is available", )).expect("jack host unavailable").default_output_device().unwrap(); - let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap(); + let (_stream, _handle) = rodio::OutputStream::try_from_device(&device).unwrap(); + let sink = rodio::Sink::try_new(&_handle).unwrap(); - Self { _stream, handle } + Self { + tx, + _stream, + _handle, + sink, + } } - pub fn play(&self, url: String, client: Client) { - let client = client.clone(); - tokio::spawn(async move { async_audio_dl(url, client).await }); + pub fn try_play(&self, id: String, client: Client) -> Result<()> { + let mut path = config::temp_dir(); + path.push(format!("{id}.mp3")); - let handle = self.handle.clone(); + if !path.exists() { + let client = client.clone(); + let id = id.clone(); + tokio::spawn(async move { async_audio_dl(id, client).await }); + } + + let tx = self.tx.clone(); tokio::spawn(async move { - play_audio(handle).await; + let file = loop { + if let Ok(file) = std::fs::File::open(&path) { + break file; + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + + loop { + if rodio::Decoder::new(BufReader::new(file.try_clone().unwrap())).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + tx.send(Action::AddToSink(id)) }); + + Ok(()) + } + + pub fn add_to_sink(&self, id: String) -> Result<()> { + let mut path = config::temp_dir(); + path.push(format!("{id}.mp3")); + let file = std::fs::File::open(&path)?; + let decoder = rodio::Decoder::new(BufReader::new(file))?; + self.sink.clear(); + self.sink.append(decoder); + self.sink.play(); + Ok(()) } } -async fn async_audio_dl(url: String, client: Client) -> color_eyre::Result<()> { - let mut path = env::temp_dir(); - path.push("ro.mp3"); +async fn async_audio_dl(id: String, client: Client) -> Result<()> { + let request = request::retrieval::Stream { + id: id.clone(), + max_bit_rate: None, + format: Some("mp3".to_string()), + time_offset: None, + size: None, + estimate_content_length: None, + converted: None, + }; + let url = make_request_url(request); + + let mut path = config::temp_dir(); + fs::create_dir_all(path.clone())?; + path.push(format!("{id}.mp3")); let file = File::create(path)?; let mut tmp_file = tokio::fs::File::from(file); - let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream(); + let mut byte_stream = client.get(url).send().await?.bytes_stream(); while let Some(item) = byte_stream.next().await { - tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file) - .await - .unwrap(); + tokio::io::copy(&mut item?.as_ref(), &mut tmp_file).await?; } Ok(()) } - -async fn play_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> { - let sink = rodio::Sink::try_new(&handle).unwrap(); - - let file = std::fs::File::open("/tmp/ro.mp3").unwrap(); - - let a = loop { - if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) { - break o; - } - tokio::time::sleep(Duration::from_millis(100)).await; - }; - - spawn_blocking(move || { - sink.append(a); - sink.sleep_until_end(); - }) - .await?; - - Ok(()) -} diff --git a/src/subsonic_helper.rs b/src/subsonic_helper.rs new file mode 100644 index 0000000..ef65710 --- /dev/null +++ b/src/subsonic_helper.rs @@ -0,0 +1,37 @@ +use rand::{Rng, distr::Alphanumeric, rng}; +use subsonic_types::{ + common::Version, + request::{Authentication, Request, SubsonicRequest}, +}; + +use crate::config; + +const SALT_SIZE: usize = 36; + +pub fn make_request_url(request_body: R) -> String { + let salt: String = rng() + .sample_iter(Alphanumeric) + .take(SALT_SIZE) + .map(char::from) + .collect(); + + let pre_t = config::get().subsonic.password.clone() + &salt; + let token = format!("{:x}", md5::compute(pre_t.as_bytes())); + + let request = Request { + username: config::get().subsonic.username.clone(), + authentication: Authentication::Token { token, salt }, + version: Version::LATEST, + client: "akula".to_string(), + format: None, + body: request_body, + }; + format!( + "{}{}?{}", + config::get().subsonic.base_url, + R::PATH, + request.to_query() + ) +} + + diff --git a/src/widgets/queue.rs b/src/widgets/queue.rs index 2a5d0bb..800b0c9 100644 --- a/src/widgets/queue.rs +++ b/src/widgets/queue.rs @@ -1,25 +1,19 @@ -use color_eyre::eyre::Result; +use color_eyre::eyre::{Result, eyre}; use itertools::Itertools; -use rand::{Rng, distr::Alphanumeric, rng}; use ratatui::{layout::Constraint::*, prelude::*, widgets::*}; use ratatui::{ prelude::{Buffer, Rect}, widgets::{StatefulWidget, Table, TableState}, }; use reqwest::Client; -use std::env; -use std::{ - io::{ErrorKind, Stdout}, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; use subsonic_types::{ - common::Version, - request::{self, Authentication, Request, SubsonicRequest}, + request::{self}, response::{Child, Response, ResponseBody}, }; -const SALT_SIZE: usize = 36; +use crate::subsonic_helper::make_request_url; #[derive(Debug, Default, Clone)] pub struct Queue { @@ -31,98 +25,40 @@ impl Queue { pub fn request_random(&self, client: Client) { let songs_list = self.songs_list.clone(); tokio::spawn(async move { - get_random(songs_list, client).await; + if let Err(e) = get_random(songs_list, client).await { + // send error + } }); } - pub fn down(&mut self) { + pub fn scroll_down(&mut self) { self.table_state.select_next(); } - pub fn up(&mut self) { + pub fn scroll_up(&mut self) { self.table_state.select_previous(); } - pub fn get_select_url(&self) -> String { - let id = self.table_state.selected().unwrap(); - let song = self.songs_list.lock().unwrap()[id].clone(); - let base_url = get_base_url(); - - let salt: String = rng() - .sample_iter(Alphanumeric) - .take(SALT_SIZE) - .map(char::from) - .collect(); - - let pre_t = get_password() + &salt; - let token = format!("{:x}", md5::compute(pre_t.as_bytes())); - - let download = Request { - username: get_username(), - authentication: Authentication::Token { token, salt }, - version: Version::LATEST, - client: "ping-example".into(), - format: None, - body: request::retrieval::Download { id: song.id }, - }; - format!( - "{}{}?{}", - base_url, - request::retrieval::Download::PATH, - download.to_query() - ) + pub fn get_selected_id(&self) -> String { + let selected = self.table_state.selected().unwrap(); + self.songs_list.lock().unwrap()[selected].clone().id } } -fn get_base_url() -> String { - env::var("URL").expect("Failed to find 'URL' environment variable") -} - -fn get_username() -> String { - env::var("USERNAME").expect("Failed to find 'USERNAME' environment variable") -} - -fn get_password() -> String { - env::var("PASSWORD").expect("Failed to find 'PASSWORD' environment variable") -} - async fn get_random(songs_list: Arc>>, client: Client) -> Result<()> { - let base_url = get_base_url(); - - let salt: String = rng() - .sample_iter(Alphanumeric) - .take(SALT_SIZE) - .map(char::from) - .collect(); - - let pre_t = get_password() + &salt; - let token = format!("{:x}", md5::compute(pre_t.as_bytes())); - - let random = Request { - username: get_username(), - authentication: Authentication::Token { token, salt }, - version: Version::LATEST, - client: "ping-example".into(), - format: None, - body: request::lists::GetRandomSongs { - size: Some(5), - genre: None, - to_year: None, - from_year: None, - music_folder_id: None, - }, + let request = request::lists::GetRandomSongs { + size: Some(55), + genre: None, + to_year: None, + from_year: None, + music_folder_id: None, }; - let request_url = format!( - "{}{}?{}", - base_url, - request::lists::GetRandomSongs::PATH, - random.to_query() - ); + let request_url = make_request_url(request); let response_contents = client.get(&request_url).send().await?.text().await?; let response = Response::from_xml(&response_contents)?; let song_list = if let ResponseBody::RandomSongs(songs) = response.body { songs.song } else { - return Err(std::io::Error::new(ErrorKind::Other, "h").into()); + return Err(eyre!("Received incorrect response")); }; let mut a = songs_list.lock().unwrap(); *a = song_list; @@ -138,18 +74,18 @@ impl StatefulWidget for QueueWidget { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { if !state.songs_list.lock().unwrap().is_empty() { let vertical_pad = |line| Text::from(vec!["".into(), line, "".into()]); - let header_cells = ["Title", "Artist", "Duration"] + let header_cells = ["#", "Title", "Artist", "Duration"] .map(|h| h.bold().into()) .map(vertical_pad); let header = Row::new(header_cells).height(3); - let column_widths = [Max(20), Max(20), Max(6)]; + let column_widths = [Max(4), Max(20), Max(20), Max(6)]; let list = state.songs_list.lock().unwrap(); let rows = list .iter() .enumerate() - .map(|(index, song)| row_from_song(song)) + .map(|(index, song)| row_from_song(index, song)) .collect_vec(); let table = Table::new(rows, column_widths) @@ -164,13 +100,16 @@ impl StatefulWidget for QueueWidget { .highlight_symbol(">>"); StatefulWidget::render(table, area, buf, &mut state.table_state); + } else { + Text::render(Text::from("Empty queue").centered(), area, buf); } } } -fn row_from_song(song: &Child) -> Row { +fn row_from_song(index: usize, song: &Child) -> Row { let duration = song.duration.unwrap().to_duration().as_secs(); Row::new([ + Text::from(index.to_string()), Text::from(song.title.clone()), Text::from(song.artist.clone().unwrap()), Text::from(format!("{:0>2}:{:0>2}", duration / 60, duration % 60)),