Starting structure

This commit is contained in:
2025-03-23 20:08:20 +01:00
parent d453a3aafa
commit 75e6c6ac9a
13 changed files with 739 additions and 337 deletions

View File

@@ -1,2 +1,2 @@
[codespell]
ignore-words-list = ratatui,crate
ignore-words-list = ratatui,crate,ser

222
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

16
src/action.rs Normal file
View File

@@ -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,
}

View File

@@ -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<Action>,
/// Sender end of an asynchronous channel for dispatching actions from
/// various parts of the app to be handled by the event loop.
tx: UnboundedSender<Action>,
/// 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<CrosstermBackend<Stdout>>,
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<Option<Action>> {
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<Action> {
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<CrosstermBackend<Stdout>>) -> 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
}

123
src/config.rs Normal file
View File

@@ -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<Config> = 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::<Config>()?;
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> {
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
}

View File

@@ -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<StreamName, Pin<Box<dyn Stream<Item = Event>>>>,
}
@@ -44,13 +46,13 @@ impl Events {
}
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
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<Box<dyn Stream<Item = Event>>> {
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))
}

View File

@@ -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<ResponseBody> {
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)
}

34
src/logging.rs Normal file
View File

@@ -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(())
}

View File

@@ -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?;

View File

@@ -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<Action>,
sink: Sink,
_handle: OutputStreamHandle,
_stream: OutputStream,
}
impl Player {
pub fn new() -> Self {
pub fn new(tx: UnboundedSender<Action>) -> 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(())
}

37
src/subsonic_helper.rs Normal file
View File

@@ -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<R: SubsonicRequest>(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()
)
}

View File

@@ -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<Mutex<Vec<Child>>>, 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)),