Starting structure
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
[codespell]
|
||||
ignore-words-list = ratatui,crate
|
||||
ignore-words-list = ratatui,crate,ser
|
||||
|
||||
222
Cargo.lock
generated
222
Cargo.lock
generated
@@ -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"
|
||||
|
||||
19
Cargo.toml
19
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"
|
||||
|
||||
16
src/action.rs
Normal file
16
src/action.rs
Normal 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,
|
||||
}
|
||||
211
src/app.rs
211
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<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
123
src/config.rs
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
165
src/lib.rs
165
src/lib.rs
@@ -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
34
src/logging.rs
Normal 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(())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
117
src/player.rs
117
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<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
37
src/subsonic_helper.rs
Normal 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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user