mirror of
https://github.com/ivabus/lonelyradio
synced 2024-11-21 23:55:09 +03:00
v0.2.1: Refactor monoclient into monoclient and monolib
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
This commit is contained in:
parent
6e3327aa1f
commit
9165c43dae
15 changed files with 428 additions and 367 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
@ -597,7 +597,7 @@ checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
|||
|
||||
[[package]]
|
||||
name = "lonelyradio"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
@ -653,18 +653,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "monoclient"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"clap",
|
||||
"rmp-serde",
|
||||
"rodio",
|
||||
"serde",
|
||||
"monolib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "monolib"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"rmp-serde",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
[workspace]
|
||||
members = ["monoclient", "platform/swiftui/monolib"]
|
||||
members = [ "monoclient", "monolib"]
|
||||
|
||||
[package]
|
||||
name = "lonelyradio"
|
||||
description = "TCP radio for lonely ones"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
|
||||
|
|
|
@ -22,7 +22,7 @@ All files (recursively) will be shuffled and played back. Public log will be dis
|
|||
|
||||
### Clients
|
||||
|
||||
[monoclient](./monoclient) is a recommended client for lonelyradio
|
||||
[monoclient](./monoclient) is a recommended CLI client for lonelyradio that uses [monolib](./monolib)
|
||||
|
||||
```shell
|
||||
monoclient <SERVER>:<PORT>
|
||||
|
@ -32,6 +32,8 @@ monoclient <SERVER>:<PORT>
|
|||
|
||||
SwiftUI client is availible in [platform](./platform) directory.
|
||||
|
||||
[monolib](./monolib) provides lonelyradio-compatible C API for creating custom clients.
|
||||
|
||||
## License
|
||||
|
||||
lonelyradio and monoclient are licensed under the terms of the [MIT license](./LICENSE).
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
[package]
|
||||
name = "monoclient"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
monolib = { path="../monolib" }
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
rodio = { version = "0.17.3", default-features = false }
|
||||
byteorder = "1.5.0"
|
||||
rmp-serde = "1.1.2"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
|
|
|
@ -1,25 +1,6 @@
|
|||
use byteorder::ByteOrder;
|
||||
use clap::Parser;
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::{OutputStream, Sink};
|
||||
use serde::Deserialize;
|
||||
use std::io::{IsTerminal, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
|
||||
// How many samples to cache before playing in samples (both channels) SHOULD BE EVEN
|
||||
const BUFFER_SIZE: usize = 4800;
|
||||
// How many buffers to cache
|
||||
const CACHE_SIZE: usize = 10;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct SentMetadata {
|
||||
// In bytes, we need to read next track metadata
|
||||
lenght: u64,
|
||||
sample_rate: u32,
|
||||
title: String,
|
||||
album: String,
|
||||
artist: String,
|
||||
}
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
|
@ -31,84 +12,98 @@ struct Args {
|
|||
no_backspace: bool,
|
||||
}
|
||||
|
||||
fn delete_chars(n: usize) {
|
||||
fn delete_chars(n: usize, nb: bool) {
|
||||
if !nb {
|
||||
print!("{}{}{}", "\u{8}".repeat(n), " ".repeat(n), "\u{8}".repeat(n));
|
||||
std::io::stdout().flush().expect("Failed to flush stdout")
|
||||
} else {
|
||||
println!()
|
||||
}
|
||||
}
|
||||
|
||||
fn flush() {
|
||||
std::io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = Args::parse();
|
||||
args.no_backspace |= !std::io::stdout().is_terminal();
|
||||
let mut stream = TcpStream::connect(&args.address)
|
||||
.unwrap_or_else(|err| panic!("Failed to connect to {}: {}", args.address, err.to_string()));
|
||||
println!("Connected to {} from {}", stream.peer_addr().unwrap(), stream.local_addr().unwrap());
|
||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
||||
let sink = Sink::try_new(&stream_handle).unwrap();
|
||||
let mut buffer = [0u8; 2];
|
||||
let mut samples = [0f32; BUFFER_SIZE];
|
||||
let mut latest_msg_len = 0;
|
||||
print!("Playing: ");
|
||||
std::thread::spawn(move || monolib::run(&args.address));
|
||||
while monolib::get_metadata().is_none() {}
|
||||
let mut md = monolib::get_metadata().unwrap();
|
||||
let seconds = md.length / md.sample_rate as u64 / 2;
|
||||
let mut track_start = Instant::now();
|
||||
let mut seconds_past = 0;
|
||||
let mut msg_len = format!(
|
||||
"Playing: {} - {} - {} ({}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
)
|
||||
.len();
|
||||
print!(
|
||||
"Playing: {} - {} - {} ({}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
);
|
||||
flush();
|
||||
loop {
|
||||
let mut index = 0usize;
|
||||
|
||||
let md: SentMetadata =
|
||||
rmp_serde::from_read(&stream).expect("Failed to parse track metadata");
|
||||
let seconds = md.lenght / (2 * md.sample_rate as u64);
|
||||
let total_lenght = format!("{}:{:02}", seconds / 60, seconds % 60);
|
||||
let message = format!("{} - {} - {} ", md.artist, md.album, md.title);
|
||||
if latest_msg_len != 0 {
|
||||
if args.no_backspace {
|
||||
print!("\nPlaying: ");
|
||||
} else {
|
||||
delete_chars(latest_msg_len)
|
||||
if monolib::get_metadata().unwrap() != md {
|
||||
md = monolib::get_metadata().unwrap();
|
||||
let seconds = md.length / md.sample_rate as u64 / 2;
|
||||
delete_chars(msg_len, args.no_backspace);
|
||||
msg_len = format!(
|
||||
"Playing: {} - {} - {} ({}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
)
|
||||
.len();
|
||||
print!(
|
||||
"Playing: {} - {} - {} (0:00 / {}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
);
|
||||
flush();
|
||||
track_start = Instant::now();
|
||||
seconds_past = 0;
|
||||
}
|
||||
if (Instant::now() - track_start).as_secs() > seconds_past && !args.no_backspace {
|
||||
seconds_past = (Instant::now() - track_start).as_secs();
|
||||
msg_len = format!(
|
||||
"Playing: {} - {} - {} ({}:{:02} / {}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds_past / 60,
|
||||
seconds_past % 60,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
)
|
||||
.len();
|
||||
delete_chars(msg_len, args.no_backspace);
|
||||
print!(
|
||||
"Playing: {} - {} - {} ({}:{:02} / {}:{:02})",
|
||||
md.artist,
|
||||
md.album,
|
||||
md.title,
|
||||
seconds_past / 60,
|
||||
seconds_past % 60,
|
||||
seconds / 60,
|
||||
seconds % 60
|
||||
);
|
||||
flush();
|
||||
}
|
||||
print!("{}", message);
|
||||
let mut prev_timestamp_len = 0;
|
||||
if args.no_backspace {
|
||||
print!("({})", &total_lenght)
|
||||
} else {
|
||||
print!("(0:00 / {})", &total_lenght);
|
||||
// (0:00/ + :00 + minutes len
|
||||
prev_timestamp_len = 12 + format!("{}", seconds / 60).len();
|
||||
}
|
||||
std::io::stdout().flush().expect("Failed to flush stdout");
|
||||
latest_msg_len = message.chars().count();
|
||||
let mut second = 0;
|
||||
for sample_index in 0..md.lenght {
|
||||
if (sample_index / (md.sample_rate as u64 * 2)) > second {
|
||||
second += 1;
|
||||
if !args.no_backspace {
|
||||
delete_chars(prev_timestamp_len);
|
||||
let current_timestamp =
|
||||
format!("({}:{:02} / {})", second / 60, second % 60, &total_lenght);
|
||||
print!("{}", ¤t_timestamp);
|
||||
std::io::stdout().flush().expect("Failed to flush stdout");
|
||||
prev_timestamp_len = current_timestamp.len()
|
||||
}
|
||||
}
|
||||
if stream.read_exact(&mut buffer).is_err() {
|
||||
return;
|
||||
};
|
||||
|
||||
samples[index] = byteorder::LittleEndian::read_i16(&buffer[..2]) as f32 / 32768.0;
|
||||
index += 1;
|
||||
|
||||
if index == BUFFER_SIZE {
|
||||
// Sink's thread is detached from main thread, so we need to synchronize with it
|
||||
// Why we should synchronize with it?
|
||||
// Let's say, that if we don't synchronize with it, we would have
|
||||
// a lot (no upper limit, actualy) of buffered sound, waiting for playing in sink
|
||||
while sink.len() >= CACHE_SIZE {
|
||||
// Sleeping exactly one buffer
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(
|
||||
BUFFER_SIZE as f32 / md.sample_rate as f32 / 2.0,
|
||||
))
|
||||
}
|
||||
sink.append(SamplesBuffer::new(2, md.sample_rate, samples.as_slice()));
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
sink.sleep_until_end()
|
||||
std::thread::sleep(Duration::from_secs_f32(0.05))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
[package]
|
||||
name = "monolib"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "A library implementing the lonely radio audio streaming protocol"
|
||||
repository = "https://github.com/ivabus/lonelyradio"
|
||||
authors = [ "Ivan Bushchik <ivabus@ivabus.dev>"]
|
||||
|
||||
[lib]
|
||||
name = "monolib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
rodio = { version = "0.17.3", default-features = false }
|
||||
byteorder = "1.5.0"
|
||||
rmp-serde = "1.1.2"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
|
||||
|
||||
[lib]
|
||||
name = "monolib"
|
||||
crate-type = ["staticlib", "cdylib"]
|
75
monolib/src/c.rs
Normal file
75
monolib/src/c.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use crate::*;
|
||||
|
||||
use std::ffi::{c_char, c_float, c_ushort};
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||
pub extern "C" fn c_start(server: *const c_char) {
|
||||
let serv = unsafe { CStr::from_ptr(server) };
|
||||
run(match serv.to_str() {
|
||||
Ok(s) => s,
|
||||
_ => "",
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_toggle() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_stop() {
|
||||
stop()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_state() -> c_ushort {
|
||||
let state = STATE.read().unwrap();
|
||||
*state as c_ushort
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_metadata_artist() -> *mut c_char {
|
||||
let md = MD.read().unwrap();
|
||||
let md = md.clone();
|
||||
CString::new(match md {
|
||||
Some(md) => md.artist,
|
||||
None => "".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into_raw()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_metadata_album() -> *mut c_char {
|
||||
let md = MD.read().unwrap();
|
||||
let md = md.clone();
|
||||
CString::new(match md {
|
||||
Some(md) => md.album,
|
||||
None => "".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into_raw()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_metadata_title() -> *mut c_char {
|
||||
let md = MD.read().unwrap();
|
||||
let md = md.clone();
|
||||
CString::new(match md {
|
||||
Some(md) => md.title,
|
||||
None => "".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into_raw()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_metadata_length() -> *mut c_float {
|
||||
let md = MD.read().unwrap();
|
||||
match md.as_ref() {
|
||||
Some(md) => &mut (md.length as c_float / md.sample_rate as c_float),
|
||||
None => &mut 0.0,
|
||||
}
|
||||
}
|
186
monolib/src/lib.rs
Normal file
186
monolib/src/lib.rs
Normal file
|
@ -0,0 +1,186 @@
|
|||
/// Functions, providing C-like API
|
||||
pub mod c;
|
||||
|
||||
use byteorder::ByteOrder;
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::{OutputStream, Sink};
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Instant;
|
||||
|
||||
// How many samples to cache before playing in samples (both channels) SHOULD BE EVEN
|
||||
const BUFFER_SIZE: usize = 4800;
|
||||
// How many buffers to cache
|
||||
const CACHE_SIZE: usize = 160;
|
||||
|
||||
static SINK: RwLock<Option<Sink>> = RwLock::new(None);
|
||||
static MD: RwLock<Option<Metadata>> = RwLock::new(None);
|
||||
static STATE: RwLock<State> = RwLock::new(State::NotStarted);
|
||||
|
||||
/// Player state
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum State {
|
||||
NotStarted = 0,
|
||||
Resetting = 1,
|
||||
Playing = 2,
|
||||
Paused = 3,
|
||||
}
|
||||
|
||||
/// Track metadata
|
||||
#[derive(Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct Metadata {
|
||||
/// In samples, length / (sample_rate * 2 (channels)) = length in seconds
|
||||
pub length: u64,
|
||||
pub sample_rate: u32,
|
||||
pub title: String,
|
||||
pub album: String,
|
||||
pub artist: String,
|
||||
}
|
||||
|
||||
/// Play/pauses playback
|
||||
pub fn toggle() {
|
||||
let mut state = crate::STATE.write().unwrap();
|
||||
if *state == State::Playing {
|
||||
*state = State::Paused;
|
||||
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
sink.pause()
|
||||
}
|
||||
} else if *state == State::Paused {
|
||||
*state = State::Playing;
|
||||
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
sink.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback
|
||||
pub fn stop() {
|
||||
let mut state = STATE.write().unwrap();
|
||||
*state = State::Resetting;
|
||||
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
sink.pause()
|
||||
}
|
||||
drop(sink);
|
||||
drop(state);
|
||||
// Blocking main thread
|
||||
while *STATE.read().unwrap() == State::Resetting {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.01))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_state() -> State {
|
||||
*STATE.read().unwrap()
|
||||
}
|
||||
|
||||
pub fn get_metadata() -> Option<Metadata> {
|
||||
MD.read().unwrap().clone()
|
||||
}
|
||||
|
||||
fn _stop() {
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
sink.pause();
|
||||
sink.clear();
|
||||
}
|
||||
|
||||
let mut md = MD.write().unwrap();
|
||||
if md.is_some() {
|
||||
*md = None;
|
||||
}
|
||||
|
||||
*STATE.write().unwrap() = State::NotStarted;
|
||||
}
|
||||
|
||||
// Reset - true, not - false
|
||||
fn watching_sleep(dur: f32) -> bool {
|
||||
let start = Instant::now();
|
||||
while Instant::now() < start + std::time::Duration::from_secs_f32(dur) {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.0001));
|
||||
if *STATE.read().unwrap() == State::Resetting {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Starts playing at "server:port"
|
||||
pub fn run(server: &str) {
|
||||
let mut state = STATE.write().unwrap();
|
||||
if *state == State::Playing || *state == State::Paused {
|
||||
return;
|
||||
}
|
||||
*state = State::Playing;
|
||||
drop(state);
|
||||
|
||||
let mut stream = TcpStream::connect(server).unwrap();
|
||||
let mut sink = SINK.write().unwrap();
|
||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
||||
|
||||
// Can't reuse old sink for some reason
|
||||
let audio_sink = Sink::try_new(&stream_handle).unwrap();
|
||||
*sink = Some(audio_sink);
|
||||
drop(sink);
|
||||
|
||||
let mut buffer = [0u8; 2];
|
||||
let mut samples = [0f32; BUFFER_SIZE];
|
||||
loop {
|
||||
let mut index = 0usize;
|
||||
let recv_md: Metadata =
|
||||
rmp_serde::from_read(&stream).expect("Failed to parse track metadata");
|
||||
|
||||
let mut md = MD.write().unwrap();
|
||||
*md = Some(recv_md.clone());
|
||||
drop(md);
|
||||
for _ in 0..recv_md.length {
|
||||
while *STATE.read().unwrap() == State::Paused {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.25))
|
||||
}
|
||||
if *STATE.read().unwrap() == State::Resetting {
|
||||
_stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if stream.read_exact(&mut buffer).is_err() {
|
||||
return;
|
||||
};
|
||||
|
||||
samples[index] = byteorder::LittleEndian::read_i16(&buffer[..2]) as f32 / 32768.0;
|
||||
index += 1;
|
||||
|
||||
if index == BUFFER_SIZE {
|
||||
// Sink's thread is detached from main thread, so we need to synchronize with it
|
||||
// Why we should synchronize with it?
|
||||
// Let's say, that if we don't synchronize with it, we would have
|
||||
// a lot (no upper limit, actualy) of buffered sound, waiting for playing in sink
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
while sink.len() >= CACHE_SIZE {
|
||||
// Sleeping exactly one buffer and watching for reset signal
|
||||
if watching_sleep(
|
||||
if sink.len() > 2 {
|
||||
sink.len() as f32 - 2.0
|
||||
} else {
|
||||
0.5
|
||||
} * BUFFER_SIZE as f32 / recv_md.sample_rate as f32
|
||||
/ 2.0,
|
||||
) {
|
||||
_stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sink.append(SamplesBuffer::new(2, recv_md.sample_rate, samples.as_slice()));
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
monolib/src/monolib.h
Normal file
20
monolib/src/monolib.h
Normal file
|
@ -0,0 +1,20 @@
|
|||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
void c_start(const char *server);
|
||||
|
||||
void c_toggle(void);
|
||||
|
||||
void c_stop(void);
|
||||
|
||||
unsigned short c_get_state(void);
|
||||
|
||||
char *c_get_metadata_artist(void);
|
||||
|
||||
char *c_get_metadata_album(void);
|
||||
|
||||
char *c_get_metadata_title(void);
|
||||
|
||||
float *c_get_metadata_length(void);
|
|
@ -4,16 +4,14 @@
|
|||
|
||||
### Build `monolib`
|
||||
|
||||
Run in `monolib` directory
|
||||
|
||||
```
|
||||
cargo lipo --release --targets aarch64-apple-ios
|
||||
cargo lipo --release --targets aarch64-apple-ios -p monolib
|
||||
```
|
||||
|
||||
For running in simulator
|
||||
|
||||
```
|
||||
cargo lipo --release --targets aarch64-apple-ios-sim,x86_64-apple-ios
|
||||
cargo lipo --release --targets aarch64-apple-ios-sim,x86_64-apple-ios -p monolib
|
||||
```
|
||||
|
||||
### Build and run app
|
||||
|
|
|
@ -20,14 +20,14 @@ class MonoLib {
|
|||
} catch {
|
||||
print("Failed to set the audio session configuration")
|
||||
}
|
||||
start(server)
|
||||
c_start(server)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
|
||||
@State private var server: String = ""
|
||||
@State private var port: String = ""
|
||||
@State private var server: String = "ivabus.dev"
|
||||
@State private var port: String = "5894"
|
||||
@State private var playing: Bool = true
|
||||
@State private var running: Bool = false
|
||||
|
||||
|
@ -62,7 +62,7 @@ struct ContentView: View {
|
|||
Button(action: {
|
||||
if running {
|
||||
playing = !playing
|
||||
toggle()
|
||||
c_toggle()
|
||||
}
|
||||
running = true
|
||||
let a = MonoLib()
|
||||
|
@ -76,23 +76,35 @@ struct ContentView: View {
|
|||
).font(.largeTitle)
|
||||
}.buttonStyle(
|
||||
.borderedProminent)
|
||||
HStack{
|
||||
Button(action: {
|
||||
reset()
|
||||
c_stop()
|
||||
running = false
|
||||
playing = true
|
||||
}) { Image(systemName: "stop").font(.title3) }.buttonStyle(
|
||||
.bordered
|
||||
).disabled(!running)
|
||||
Button(action: {
|
||||
c_stop()
|
||||
playing = true
|
||||
let a = MonoLib()
|
||||
Task.init {
|
||||
await a.run(server: server + ":" + port)
|
||||
}
|
||||
}) {Image(systemName: "forward").font(.title3)}.buttonStyle(.bordered).disabled(!running)
|
||||
}
|
||||
}.frame(width: 300)
|
||||
Text(now_playing_artist).font(.title2).onReceive(timer) { _ in
|
||||
now_playing_artist = String(cString: get_metadata_artist()!)
|
||||
VStack(spacing: 10) {
|
||||
Text(now_playing_artist).onReceive(timer) { _ in
|
||||
now_playing_artist = String(cString: c_get_metadata_artist()!)
|
||||
}
|
||||
Text(now_playing_album).onReceive(timer) { _ in
|
||||
now_playing_album = String(cString: get_metadata_album()!)
|
||||
}
|
||||
Text(now_playing_title).font(.title).bold().onReceive(timer) { _ in
|
||||
now_playing_title = String(cString: get_metadata_title()!)
|
||||
now_playing_album = String(cString: c_get_metadata_album()!)
|
||||
}
|
||||
Text(now_playing_title).onReceive(timer) { _ in
|
||||
now_playing_title = String(cString: c_get_metadata_title()!)
|
||||
}.bold()
|
||||
}.frame(minHeight: 100)
|
||||
|
||||
}.padding()
|
||||
|
||||
|
|
|
@ -1,212 +0,0 @@
|
|||
use byteorder::ByteOrder;
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::{OutputStream, Sink};
|
||||
use serde::Deserialize;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use std::os::raw::c_char;
|
||||
use std::time::Instant;
|
||||
|
||||
// How many samples to cache before playing in samples (both channels) SHOULD BE EVEN
|
||||
const BUFFER_SIZE: usize = 2400;
|
||||
// How many buffers to cache
|
||||
const CACHE_SIZE: usize = 40;
|
||||
|
||||
static mut SINK: Option<Box<Sink>> = None;
|
||||
static mut MD: Option<SentMetadata> = None;
|
||||
static mut STATE: State = State::NotStarted;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum State {
|
||||
NotStarted,
|
||||
Resetting,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn start(server: *const c_char) {
|
||||
let serv = unsafe { CStr::from_ptr(server) };
|
||||
unsafe {
|
||||
run(match serv.to_str() {
|
||||
Ok(s) => s,
|
||||
_ => "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn toggle() {
|
||||
unsafe {
|
||||
if STATE == State::Playing {
|
||||
STATE = State::Paused;
|
||||
if let Some(sink) = &SINK {
|
||||
sink.pause();
|
||||
}
|
||||
} else if STATE == State::Paused {
|
||||
STATE = State::Playing;
|
||||
if let Some(sink) = &SINK {
|
||||
sink.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn reset() {
|
||||
unsafe {
|
||||
STATE = State::Resetting;
|
||||
if let Some(sink) = &SINK {
|
||||
sink.pause();
|
||||
}
|
||||
// Blocking main thread
|
||||
while STATE == State::Resetting {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.01))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_metadata_artist() -> *mut c_char {
|
||||
unsafe {
|
||||
match &MD {
|
||||
Some(md) => CString::new(md.artist.clone()).unwrap().into_raw(),
|
||||
_ => CString::new("").unwrap().into_raw(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_metadata_album() -> *mut c_char {
|
||||
unsafe {
|
||||
match &MD {
|
||||
Some(md) => CString::new(md.album.clone()).unwrap().into_raw(),
|
||||
_ => CString::new("").unwrap().into_raw(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_metadata_title() -> *mut c_char {
|
||||
unsafe {
|
||||
match &MD {
|
||||
Some(md) => CString::new(md.title.clone()).unwrap().into_raw(),
|
||||
_ => CString::new("").unwrap().into_raw(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn _reset() {
|
||||
if let Some(sink) = &SINK {
|
||||
sink.pause();
|
||||
sink.clear();
|
||||
}
|
||||
SINK = None;
|
||||
MD = None;
|
||||
STATE = State::NotStarted;
|
||||
}
|
||||
|
||||
// Reset - true, not - false
|
||||
unsafe fn watching_sleep(dur: f32) -> bool {
|
||||
let start = Instant::now();
|
||||
while Instant::now() < start + std::time::Duration::from_secs_f32(dur) {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.0001));
|
||||
if STATE == State::Resetting {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct SentMetadata {
|
||||
// In bytes, we need to read next track metadata
|
||||
lenght: u64,
|
||||
sample_rate: u32,
|
||||
title: String,
|
||||
album: String,
|
||||
artist: String,
|
||||
}
|
||||
|
||||
unsafe fn run(server: &str) {
|
||||
if STATE == State::Playing || STATE == State::Paused {
|
||||
return;
|
||||
}
|
||||
STATE = State::Playing;
|
||||
let mut stream = TcpStream::connect(server).unwrap();
|
||||
println!("Connected to {} from {}", stream.peer_addr().unwrap(), stream.local_addr().unwrap());
|
||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
||||
match &SINK {
|
||||
None => {
|
||||
let sink = Sink::try_new(&stream_handle).unwrap();
|
||||
SINK = Some(Box::new(sink));
|
||||
}
|
||||
Some(s) => {
|
||||
if s.is_paused() {
|
||||
s.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
match &SINK {
|
||||
None => {
|
||||
let sink = Sink::try_new(&stream_handle).unwrap();
|
||||
SINK = Some(Box::new(sink));
|
||||
}
|
||||
Some(s) => {
|
||||
if s.is_paused() {
|
||||
s.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut buffer = [0u8; 2];
|
||||
let mut samples = [0f32; BUFFER_SIZE];
|
||||
loop {
|
||||
let mut index = 0usize;
|
||||
|
||||
let md: SentMetadata =
|
||||
rmp_serde::from_read(&stream).expect("Failed to parse track metadata");
|
||||
MD = Some(md.clone());
|
||||
for _ in 0..md.lenght {
|
||||
while STATE == State::Paused {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(0.25))
|
||||
}
|
||||
if STATE == State::Resetting {
|
||||
_reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if stream.read_exact(&mut buffer).is_err() {
|
||||
return;
|
||||
};
|
||||
|
||||
samples[index] = byteorder::LittleEndian::read_i16(&buffer[..2]) as f32 / 32768.0;
|
||||
index += 1;
|
||||
|
||||
if index == BUFFER_SIZE {
|
||||
// Sink's thread is detached from main thread, so we need to synchronize with it
|
||||
// Why we should synchronize with it?
|
||||
// Let's say, that if we don't synchronize with it, we would have
|
||||
// a lot (no upper limit, actualy) of buffered sound, waiting for playing in sink
|
||||
if let Some(sink) = &SINK {
|
||||
while sink.len() >= CACHE_SIZE {
|
||||
// Sleeping exactly one buffer and watching for reset signal
|
||||
if watching_sleep(
|
||||
if sink.len() > 2 {
|
||||
sink.len() as f32 - 2.0
|
||||
} else {
|
||||
0.5
|
||||
} * BUFFER_SIZE as f32 / md.sample_rate as f32
|
||||
/ 2.0,
|
||||
) {
|
||||
_reset();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sink.append(SamplesBuffer::new(2, md.sample_rate, samples.as_slice()));
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
#include <stdint.h>
|
||||
|
||||
void start(const char *server);
|
||||
|
||||
void toggle();
|
||||
|
||||
void reset();
|
||||
|
||||
const char *get_metadata_artist();
|
||||
const char *get_metadata_album();
|
||||
const char *get_metadata_title();
|
12
src/main.rs
12
src/main.rs
|
@ -32,7 +32,7 @@ struct Args {
|
|||
#[derive(Serialize)]
|
||||
struct SentMetadata {
|
||||
// In bytes, we need to read next track metadata
|
||||
lenght: u64,
|
||||
length: u64,
|
||||
// Yep, no more interpolation
|
||||
sample_rate: u32,
|
||||
title: String,
|
||||
|
@ -41,7 +41,7 @@ struct SentMetadata {
|
|||
}
|
||||
|
||||
async fn stream_samples(
|
||||
track_samples: Vec<f32>,
|
||||
track_samples: Vec<i16>,
|
||||
war: bool,
|
||||
md: SentMetadata,
|
||||
s: &mut TcpStream,
|
||||
|
@ -53,9 +53,9 @@ async fn stream_samples(
|
|||
for sample in track_samples {
|
||||
if s.write_all(
|
||||
&(if war {
|
||||
sample.signum() as i16 * 32767
|
||||
sample.signum() * 32767
|
||||
} else {
|
||||
(sample * 32768_f32) as i16
|
||||
sample
|
||||
}
|
||||
.to_le_bytes()),
|
||||
)
|
||||
|
@ -191,7 +191,7 @@ async fn stream(mut s: TcpStream, tracklist: Arc<Vec<PathBuf>>) {
|
|||
Ok(decoded) => {
|
||||
sample_rate = decoded.spec().rate;
|
||||
let mut byte_buf =
|
||||
SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
|
||||
SampleBuffer::<i16>::new(decoded.capacity() as u64, *decoded.spec());
|
||||
byte_buf.copy_interleaved_ref(decoded);
|
||||
samples.append(&mut byte_buf.samples_mut().to_vec());
|
||||
continue;
|
||||
|
@ -203,7 +203,7 @@ async fn stream(mut s: TcpStream, tracklist: Arc<Vec<PathBuf>>) {
|
|||
}
|
||||
}
|
||||
let md = SentMetadata {
|
||||
lenght: samples.len() as u64,
|
||||
length: samples.len() as u64,
|
||||
sample_rate,
|
||||
title,
|
||||
album,
|
||||
|
|
Loading…
Reference in a new issue