0.6.0: artwork support, new SwiftUI client, new protocol iteration
Drop of the "XOR encryption" Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
1125
Cargo.lock
generated
|
@ -5,19 +5,19 @@ members = [
|
|||
"monoclient-s",
|
||||
"monolib",
|
||||
"monoloader",
|
||||
"microserve",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "lonelyradio"
|
||||
description = "TCP radio for lonely ones"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
|
||||
repository = "https://github.com/ivabus/lonelyradio"
|
||||
|
||||
[dependencies]
|
||||
lonelyradio_types = { version = "0.6.0", path = "./lonelyradio_types" }
|
||||
rand = "0.8.5"
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
tokio = { version = "1.35.1", features = [
|
||||
|
@ -43,9 +43,12 @@ async-stream = "0.3.5"
|
|||
tokio-stream = { version = "0.1.15", features = ["sync"] }
|
||||
futures-util = "0.3.30"
|
||||
samplerate = "0.2.4"
|
||||
lonelyradio_types = { version = "0.5.0", path = "./lonelyradio_types" }
|
||||
once_cell = "1.19.0"
|
||||
flacenc = { version = "0.4.0", default-features = false }
|
||||
image = "0.25.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1.0.98"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
|
78
README.md
|
@ -1,76 +1,90 @@
|
|||
# lonelyradio
|
||||
|
||||
Broadcast audio over the internet.
|
||||
Broadcast lossless audio over the internet.
|
||||
|
||||
Decodes audio streams using [symphonia](https://github.com/pdeljanov/Symphonia).
|
||||
|
||||
Optionally transcodes audio into and from FLAC using [flacenc-rs](https://github.com/yotarok/flacenc-rs/) and [claxon](https://github.com/ruuda/claxon).
|
||||
|
||||
## Installation
|
||||
|
||||
### Install music server
|
||||
## Install server
|
||||
|
||||
```shell
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.5.0 lonelyradio
|
||||
```
|
||||
|
||||
### Install CLI client
|
||||
|
||||
```shell
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.5.0 monoclient
|
||||
```
|
||||
|
||||
### Install GUI (Slint) client
|
||||
|
||||
```shell
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.5.0 monoclient-s
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.6.0 lonelyradio
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
lonelyradio [-a <ADDRESS:PORT>] [-p|--public-log] [-w|--war] [-m|--max-samplerate M] [--xor-key-file FILE] [--no-resampling] [-f|--flac] <MUSIC_FOLDER>
|
||||
lonelyradio <MUSIC_FOLDER>
|
||||
```
|
||||
|
||||
All files (recursively) will be shuffled and played back. Public log will be displayed to stdout, private to stderr.
|
||||
|
||||
`-m|--max-samplerate M` will resample tracks which samplerate exceeds M to M
|
||||
|
||||
`--xor-key-file FILE` will XOR all outgoing bytes looping through FILE
|
||||
|
||||
`-f|--flac` will enable (experimental) FLAC compression
|
||||
Look into `--help` for detailed info
|
||||
|
||||
### Clients
|
||||
|
||||
[monoclient](./monoclient) is a recommended CLI player for lonelyradio that uses [monolib](./monolib)
|
||||
#### monoclient-x
|
||||
|
||||
[monoclient-x](./monoclient-x) is a SwiftUI player for lonelyradio for iOS/iPadOS/macOS
|
||||
|
||||
##### Build
|
||||
|
||||
1. Build monolib with [xcframework](https://github.com/Binlogo/cargo-xcframework)
|
||||
2. Build monoclient-x using Xcode or `xcodebuild`
|
||||
|
||||
#### monoclient-s
|
||||
|
||||
[monoclient-s](./monoclient-s) is a GUI player for lonelyradio built with [Slint](https://slint.dev)
|
||||
|
||||
|
||||
##### Install
|
||||
|
||||
```shell
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.6.0 monoclient-s
|
||||
```
|
||||
|
||||
You may need to install some dependencies for Slint.
|
||||
|
||||
Desktop integration will be added later.
|
||||
|
||||
##### Build
|
||||
|
||||
```
|
||||
cargo build -p monoclient-s
|
||||
```
|
||||
|
||||
You may need to install some dependencies for Slint.
|
||||
|
||||
#### monoclient
|
||||
|
||||
[monoclient](./monoclient) is a CLI player for lonelyradio that uses [monolib](./monolib)
|
||||
|
||||
```shell
|
||||
monoclient <SERVER>:<PORT>
|
||||
```
|
||||
|
||||
[monoclient-s](./monoclient-s) is a experimental GUI player for lonelyradio built with [Slint](https://slint.dev)
|
||||
##### Install monoclient
|
||||
|
||||
```shell
|
||||
monoclient-s
|
||||
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.6.0 monoclient
|
||||
```
|
||||
|
||||
Desktop integration will be added later.
|
||||
|
||||
### Other clients
|
||||
|
||||
SwiftUI client is availible in [platform](./platform) directory.
|
||||
# Other things
|
||||
|
||||
[monoloader](./monoloader) is a tool, that allows you to download individual audio tracks from lonelyradio-compatible servers.
|
||||
|
||||
[monolib](./monolib) provides a C API compatible with lonelyradio for creating custom clients.
|
||||
|
||||
The full protocol specification will be available later. If you would like to learn more about it now, please refer to the monolib.
|
||||
|
||||
#### monolib API stability
|
||||
|
||||
As lonelyradio has not yet reached its first major release, the API may (and will) break at any point.
|
||||
|
||||
### Microphone server
|
||||
|
||||
Experimental server (lonelyradio-compatible) for streaming audio from your microphone is available in the [microserve](./microserve) crate.
|
||||
Experimental (and uncompatible with versions 0.6+) server (lonelyradio-compatible) for streaming audio from your microphone is available in the [microserve](./microserve) crate.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
name = "lonelyradio_types"
|
||||
description = "Shared types for lonelyradio"
|
||||
license = "MIT"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
|
||||
repository = "https://github.com/ivabus/lonelyradio"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_bytes = "0.11.15"
|
||||
|
|
|
@ -1,25 +1,63 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const HELLO_MAGIC: u64 = 0x104e1374d10;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub enum Message {
|
||||
T(TrackMetadata),
|
||||
F(FragmentMetadata),
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
#[serde(rename = "e")]
|
||||
pub encoder: Encoder,
|
||||
|
||||
#[serde(rename = "co")]
|
||||
pub cover: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(rename = "e")]
|
||||
pub encoders: Vec<Encoder>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct TrackMetadata {
|
||||
#[serde(rename = "tls")]
|
||||
pub track_length_secs: u64,
|
||||
#[serde(rename = "tlf")]
|
||||
pub track_length_frac: f32,
|
||||
#[serde(rename = "c")]
|
||||
pub channels: u16,
|
||||
#[serde(rename = "sr")]
|
||||
pub sample_rate: u32,
|
||||
pub flac: bool,
|
||||
#[serde(rename = "e")]
|
||||
pub encoder: Encoder,
|
||||
#[serde(rename = "mt")]
|
||||
pub title: String,
|
||||
#[serde(rename = "mal")]
|
||||
pub album: String,
|
||||
#[serde(rename = "mar")]
|
||||
pub artist: String,
|
||||
#[serde(rename = "co")]
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub cover: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Encoder {
|
||||
Pcm16 = 0,
|
||||
PcmFloat = 1,
|
||||
Flac = 2,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct FragmentMetadata {
|
||||
// In samples or bytes (if FLAC)
|
||||
// In bytes
|
||||
#[serde(rename = "l")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
|
|
@ -39,7 +39,8 @@ async fn update_start() {
|
|||
async fn stream(mut s: std::net::TcpStream) {
|
||||
println!("Playing for {}", s.peer_addr().unwrap());
|
||||
let md = lonelyradio_types::Message::T(TrackMetadata {
|
||||
flac: false,
|
||||
cover: None,
|
||||
encoder: lonelyradio_types::Encoder::Pcm,
|
||||
track_length_secs: 0,
|
||||
track_length_frac: 0.0,
|
||||
channels: 1,
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
[package]
|
||||
name = "monoclient-s"
|
||||
description = "Client for lonelyradio built with Slint"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
slint = { version = "1.6.0", features = ["backend-android-activity-06"] }
|
||||
monolib = { path = "../monolib" }
|
||||
slint = { version = "1.6", features = ["backend-android-activity-06"] }
|
||||
monolib = { path = "../monolib", version = "0.6.0" }
|
||||
lonelyradio_types = { version = "0.6.0", path = "../lonelyradio_types" }
|
||||
zune-jpeg = "0.4.11"
|
||||
|
||||
[lib]
|
||||
crate-type = [ "cdylib" ]
|
||||
|
||||
# TODO: Set up cargo-bundle
|
||||
#[package.metadata.bundle]
|
||||
#name = "monoclient-s"
|
||||
#identifier = "dev.ivabus.monoclient-s"
|
||||
#icon = ["lonelyradio.png", "lonelyradio.icns"]
|
||||
#version = "0.5.0"
|
||||
#copyright = "Copyright (c) 2024 Ivan Bushchik."
|
||||
#category = "Music"
|
||||
[package.metadata.bundle]
|
||||
name = "monoclient-s"
|
||||
identifier = "dev.ivabus.monoclient-s"
|
||||
icon = ["lonelyradio.png", "lonelyradio.icns"]
|
||||
version = "0.5.0"
|
||||
copyright = "Copyright (c) 2024 Ivan Bushchik."
|
||||
category = "Music"
|
||||
|
|
BIN
monoclient-s/lonelyradio.icns
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
monoclient-s/lonelyradio.png
Normal file
After Width: | Height: | Size: 94 KiB |
227
monoclient-s/src/app.rs
Normal file
|
@ -0,0 +1,227 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use monolib::State;
|
||||
use slint::{Image, Rgb8Pixel, Rgba8Pixel, SharedPixelBuffer, Weak};
|
||||
|
||||
slint::slint! {
|
||||
import { AboutSlint, Button, VerticalBox, GroupBox, Slider } from "std-widgets.slint";
|
||||
export component MainWindow inherits Window {
|
||||
max-height: self.preferred-height;
|
||||
callback play;
|
||||
callback stop;
|
||||
callback next;
|
||||
callback change_volume(float);
|
||||
callback text_edited;
|
||||
|
||||
in-out property <string> addr: address.text;
|
||||
in-out property <string> mtitle: "";
|
||||
in-out property <string> malbum: "";
|
||||
in-out property <string> martist: "";
|
||||
in-out property <float> volume: svolume.value;
|
||||
in-out property <bool> start_enabled: false;
|
||||
in-out property <bool> playing: false;
|
||||
in-out property <bool> paused: false;
|
||||
in property <image> cover: @image-url("lonelyradio.png");
|
||||
|
||||
title: "monoclient-s";
|
||||
min-width: 192px;
|
||||
max-width: 768px;
|
||||
VerticalBox {
|
||||
alignment: center;
|
||||
spacing: 0px;
|
||||
|
||||
Image {
|
||||
source: cover;
|
||||
max-height: 192px;
|
||||
max-width: 192px;
|
||||
min-height: 192px;
|
||||
min-width: 192px;
|
||||
}
|
||||
|
||||
GroupBox{
|
||||
max-width: 768px;
|
||||
address := TextInput {
|
||||
text: "";
|
||||
horizontal-alignment: center;
|
||||
height: 1.25rem;
|
||||
|
||||
accepted => {
|
||||
self.clear_focus()
|
||||
}
|
||||
|
||||
edited => {
|
||||
text_edited()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
max-width: 512px;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
Button {
|
||||
max-width: 256px;
|
||||
text: playing ? (paused ? "Play" : "Pause") : "Start";
|
||||
enabled: start_enabled || playing;
|
||||
clicked => {
|
||||
play()
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
max-width: 256px;
|
||||
Button {
|
||||
text: "Stop";
|
||||
enabled: playing && !paused;
|
||||
clicked => {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
text: "Next";
|
||||
enabled: playing && !paused;
|
||||
clicked => {
|
||||
next()
|
||||
}
|
||||
}
|
||||
}
|
||||
svolume := Slider {
|
||||
value: 255;
|
||||
maximum: 255;
|
||||
changed(f) => {
|
||||
change_volume(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
padding: 4px;
|
||||
tartist := Text {
|
||||
height: 1.25rem;
|
||||
font-weight: 600;
|
||||
text: martist;
|
||||
overflow: elide;
|
||||
}
|
||||
talbum := Text {
|
||||
height: 1.25rem;
|
||||
text: malbum;
|
||||
overflow: elide;
|
||||
}
|
||||
ttitle := Text {
|
||||
height: 1.25rem;
|
||||
text: mtitle;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_playback(window_weak: Weak<MainWindow>) {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
let addr = window.get_addr().to_string();
|
||||
let handle = std::thread::spawn(move || {
|
||||
monolib::run(
|
||||
&addr,
|
||||
lonelyradio_types::Settings {
|
||||
encoder: lonelyradio_types::Encoder::Flac,
|
||||
cover: 512,
|
||||
},
|
||||
)
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(166));
|
||||
if handle.is_finished() {
|
||||
window.set_playing(false);
|
||||
return;
|
||||
}
|
||||
window.set_playing(true);
|
||||
window.set_paused(false);
|
||||
while monolib::get_metadata().is_none() {}
|
||||
monolib::set_volume(window.get_volume() as u8);
|
||||
}
|
||||
|
||||
pub fn _main() {
|
||||
let window = MainWindow::new().unwrap();
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_text_edited(move || {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
let addr = window.get_addr().to_string();
|
||||
window.set_start_enabled(addr.contains(':'));
|
||||
});
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_play(move || match monolib::get_state() {
|
||||
State::NotStarted => start_playback(window_weak.clone()),
|
||||
State::Paused => {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_paused(false);
|
||||
monolib::toggle();
|
||||
}
|
||||
State::Resetting => {}
|
||||
State::Playing => {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_paused(true);
|
||||
monolib::toggle()
|
||||
}
|
||||
});
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_next(move || {
|
||||
monolib::stop();
|
||||
start_playback(window_weak.clone())
|
||||
});
|
||||
let window_weak = window.as_weak();
|
||||
window.on_stop(move || {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_playing(false);
|
||||
window.set_martist("".into());
|
||||
window.set_malbum("".into());
|
||||
window.set_mtitle("".into());
|
||||
window.set_cover(Image::from_rgba8(SharedPixelBuffer::<Rgba8Pixel>::new(1, 1)));
|
||||
monolib::stop();
|
||||
});
|
||||
window.on_change_volume(move |vol| monolib::set_volume(vol as u8));
|
||||
let window_weak = window.as_weak();
|
||||
std::thread::spawn(move || loop {
|
||||
let window = window_weak.clone();
|
||||
while monolib::get_metadata().is_none() {
|
||||
std::thread::sleep(Duration::from_millis(25))
|
||||
}
|
||||
let md = monolib::get_metadata().unwrap();
|
||||
let _md = md.clone();
|
||||
if let Some(jpeg) = md.cover {
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(jpeg);
|
||||
decoder.decode_headers().unwrap();
|
||||
let (w, h) = decoder.dimensions().unwrap();
|
||||
let decoded = decoder.decode().unwrap();
|
||||
let mut pixel_buffer = SharedPixelBuffer::<Rgb8Pixel>::new(w as u32, h as u32);
|
||||
pixel_buffer.make_mut_bytes().copy_from_slice(&decoded);
|
||||
window
|
||||
.upgrade_in_event_loop(|win| {
|
||||
let image = Image::from_rgb8(pixel_buffer);
|
||||
win.set_cover(image);
|
||||
})
|
||||
.unwrap();
|
||||
} else {
|
||||
window
|
||||
.upgrade_in_event_loop(|win| {
|
||||
win.set_cover(Image::from_rgba8(SharedPixelBuffer::<Rgba8Pixel>::new(1, 1)));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
slint::invoke_from_event_loop(move || {
|
||||
let window = window.unwrap();
|
||||
window.set_martist(md.artist.clone().into());
|
||||
window.set_malbum(md.album.clone().into());
|
||||
window.set_mtitle(md.title.clone().into());
|
||||
})
|
||||
.unwrap();
|
||||
while monolib::get_metadata() == Some(_md.clone()) {
|
||||
std::thread::sleep(Duration::from_millis(100))
|
||||
}
|
||||
});
|
||||
window.run().unwrap();
|
||||
}
|
7
monoclient-s/src/lib.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
mod app;
|
||||
#[cfg(target_os = "andoid")]
|
||||
#[no_mangle]
|
||||
fn android_main(app: slint::android::AndroidApp) {
|
||||
slint::android::init(app).unwrap();
|
||||
app::_main();
|
||||
}
|
|
@ -1,181 +1,4 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use monolib::State;
|
||||
use slint::Weak;
|
||||
|
||||
slint::slint! {
|
||||
import { AboutSlint, Button, VerticalBox, GroupBox, Slider } from "std-widgets.slint";
|
||||
export component MainWindow inherits Window {
|
||||
max-height: self.preferred-height;
|
||||
callback play;
|
||||
callback stop;
|
||||
callback next;
|
||||
callback change_volume(float);
|
||||
callback text_edited;
|
||||
|
||||
in-out property <string> addr: address.text;
|
||||
in-out property <string> mtitle: "";
|
||||
in-out property <string> malbum: "";
|
||||
in-out property <string> martist: "";
|
||||
in-out property <float> volume: svolume.value;
|
||||
in-out property <bool> start_enabled: false;
|
||||
in-out property <bool> playing: false;
|
||||
in-out property <bool> paused: false;
|
||||
|
||||
title: "monoclient-s";
|
||||
min-width: 192px;
|
||||
max-width: 768px;
|
||||
VerticalBox {
|
||||
alignment: center;
|
||||
GroupBox{
|
||||
max-width: 768px;
|
||||
address := TextInput {
|
||||
text: "";
|
||||
horizontal-alignment: center;
|
||||
height: 1.25rem;
|
||||
|
||||
accepted => {
|
||||
self.clear_focus()
|
||||
}
|
||||
|
||||
edited => {
|
||||
text_edited()
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
max-width: 512px;
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
Button {
|
||||
max-width: 256px;
|
||||
text: playing ? (paused ? "Play" : "Pause") : "Start";
|
||||
enabled: start_enabled || playing;
|
||||
clicked => {
|
||||
play()
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
max-width: 256px;
|
||||
Button {
|
||||
text: "Stop";
|
||||
enabled: playing && !paused;
|
||||
clicked => {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
text: "Next";
|
||||
enabled: playing && !paused;
|
||||
clicked => {
|
||||
next()
|
||||
}
|
||||
}
|
||||
}
|
||||
svolume := Slider {
|
||||
value: 255;
|
||||
maximum: 255;
|
||||
changed(f) => {
|
||||
change_volume(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
tartist := Text {
|
||||
height: 1.25rem;
|
||||
font-weight: 600;
|
||||
text: martist;
|
||||
overflow: elide;
|
||||
}
|
||||
talbum := Text {
|
||||
height: 1.25rem;
|
||||
text: malbum;
|
||||
overflow: elide;
|
||||
}
|
||||
ttitle := Text {
|
||||
height: 1.25rem;
|
||||
text: mtitle;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_playback(window_weak: Weak<MainWindow>) {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
let addr = window.get_addr().to_string();
|
||||
let handle = std::thread::spawn(move || monolib::run(&addr, None));
|
||||
std::thread::sleep(Duration::from_millis(166));
|
||||
if handle.is_finished() {
|
||||
window.set_playing(false);
|
||||
return;
|
||||
}
|
||||
window.set_playing(true);
|
||||
window.set_paused(false);
|
||||
while monolib::get_metadata().is_none() {}
|
||||
monolib::set_volume(window.get_volume() as u8);
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let window = MainWindow::new().unwrap();
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_text_edited(move || {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
let addr = window.get_addr().to_string();
|
||||
window.set_start_enabled(addr.contains(':'));
|
||||
});
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_play(move || match monolib::get_state() {
|
||||
State::NotStarted => start_playback(window_weak.clone()),
|
||||
State::Paused => {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_paused(false);
|
||||
monolib::toggle();
|
||||
}
|
||||
State::Resetting => {}
|
||||
State::Playing => {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_paused(true);
|
||||
monolib::toggle()
|
||||
}
|
||||
});
|
||||
|
||||
let window_weak = window.as_weak();
|
||||
window.on_next(move || {
|
||||
monolib::stop();
|
||||
start_playback(window_weak.clone())
|
||||
});
|
||||
let window_weak = window.as_weak();
|
||||
window.on_stop(move || {
|
||||
let window = window_weak.upgrade().unwrap();
|
||||
window.set_playing(false);
|
||||
window.set_martist("".into());
|
||||
window.set_malbum("".into());
|
||||
window.set_mtitle("".into());
|
||||
monolib::stop();
|
||||
});
|
||||
window.on_change_volume(move |vol| monolib::set_volume(vol as u8));
|
||||
let window_weak = window.as_weak();
|
||||
std::thread::spawn(move || loop {
|
||||
let window = window_weak.clone();
|
||||
while monolib::get_metadata().is_none() {
|
||||
std::thread::sleep(Duration::from_millis(25))
|
||||
}
|
||||
let md = monolib::get_metadata().unwrap();
|
||||
let _md = md.clone();
|
||||
slint::invoke_from_event_loop(move || {
|
||||
let window = window.unwrap();
|
||||
window.set_martist(md.artist.clone().into());
|
||||
window.set_malbum(md.album.clone().into());
|
||||
window.set_mtitle(md.title.clone().into());
|
||||
})
|
||||
.unwrap();
|
||||
while monolib::get_metadata() == Some(_md.clone()) {
|
||||
std::thread::sleep(Duration::from_millis(100))
|
||||
}
|
||||
});
|
||||
window.run().unwrap();
|
||||
mod app;
|
||||
fn main() {
|
||||
app::_main()
|
||||
}
|
||||
|
|
453
monoclient-x/monoclient-x.xcodeproj/project.pbxproj
Normal file
|
@ -0,0 +1,453 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
4F79BFA42C19977F00074B09 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F79BFA32C19975000074B09 /* libresolv.tbd */; };
|
||||
4F92D0562C4176A200CF3363 /* MonoLib.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F92D0552C4176A200CF3363 /* MonoLib.xcframework */; };
|
||||
4F92D0572C4176A200CF3363 /* MonoLib.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4F92D0552C4176A200CF3363 /* MonoLib.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
4FAD30F72C1980D900074B09 /* monoclient_xApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAD30F62C1980D900074B09 /* monoclient_xApp.swift */; };
|
||||
4FAD30F92C1980D900074B09 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAD30F82C1980D900074B09 /* ContentView.swift */; };
|
||||
4FAD30FB2C1980D900074B09 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAD30FA2C1980D900074B09 /* Metadata.swift */; };
|
||||
4FAD30FD2C1980DC00074B09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4FAD30FC2C1980DC00074B09 /* Assets.xcassets */; };
|
||||
4FAD31012C1980DC00074B09 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4FAD31002C1980DC00074B09 /* Preview Assets.xcassets */; };
|
||||
4FAE6E662C1B5EB100074B09 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAE6E652C1B5EB100074B09 /* Player.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
4F15BE3A2C3AF1840026AC81 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4FF5DF8A2C41575B0039B22C /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
4F92D0572C4176A200CF3363 /* MonoLib.xcframework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
4F15BE242C3AF1810026AC81 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
4F79BF922C19903C00074B09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
4F79BFA32C19975000074B09 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/usr/lib/libresolv.tbd; sourceTree = DEVELOPER_DIR; };
|
||||
4F92D0552C4176A200CF3363 /* MonoLib.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MonoLib.xcframework; path = ../target/MonoLib.xcframework; sourceTree = "<group>"; };
|
||||
4FAD30F32C1980D900074B09 /* monoclient-x.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "monoclient-x.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4FAD30F62C1980D900074B09 /* monoclient_xApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = monoclient_xApp.swift; sourceTree = "<group>"; };
|
||||
4FAD30F82C1980D900074B09 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
4FAD30FA2C1980D900074B09 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
4FAD30FC2C1980DC00074B09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
4FAD30FE2C1980DC00074B09 /* monoclient_x.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = monoclient_x.entitlements; sourceTree = "<group>"; };
|
||||
4FAD31002C1980DC00074B09 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
4FAE6E652C1B5EB100074B09 /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
4FAD30F02C1980D900074B09 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4F92D0562C4176A200CF3363 /* MonoLib.xcframework in Frameworks */,
|
||||
4F79BFA42C19977F00074B09 /* libresolv.tbd in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4F79BF942C1992ED00074B09 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4F92D0552C4176A200CF3363 /* MonoLib.xcframework */,
|
||||
4F79BFA32C19975000074B09 /* libresolv.tbd */,
|
||||
4F15BE242C3AF1810026AC81 /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FAD30EA2C1980D900074B09 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FAD30F52C1980D900074B09 /* monoclient-x */,
|
||||
4FAD30F42C1980D900074B09 /* Products */,
|
||||
4F79BF942C1992ED00074B09 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FAD30F42C1980D900074B09 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FAD30F32C1980D900074B09 /* monoclient-x.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FAD30F52C1980D900074B09 /* monoclient-x */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4F79BF922C19903C00074B09 /* Info.plist */,
|
||||
4FAD30F62C1980D900074B09 /* monoclient_xApp.swift */,
|
||||
4FAD30F82C1980D900074B09 /* ContentView.swift */,
|
||||
4FAD30FA2C1980D900074B09 /* Metadata.swift */,
|
||||
4FAD30FC2C1980DC00074B09 /* Assets.xcassets */,
|
||||
4FAD30FE2C1980DC00074B09 /* monoclient_x.entitlements */,
|
||||
4FAD30FF2C1980DC00074B09 /* Preview Content */,
|
||||
4FAE6E652C1B5EB100074B09 /* Player.swift */,
|
||||
);
|
||||
path = "monoclient-x";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FAD30FF2C1980DC00074B09 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FAD31002C1980DC00074B09 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
4FAD30F22C1980D900074B09 /* monoclient-x */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4FAD31042C1980DC00074B09 /* Build configuration list for PBXNativeTarget "monoclient-x" */;
|
||||
buildPhases = (
|
||||
4FAD30EF2C1980D900074B09 /* Sources */,
|
||||
4FAD30F02C1980D900074B09 /* Frameworks */,
|
||||
4FAD30F12C1980D900074B09 /* Resources */,
|
||||
4F15BE3A2C3AF1840026AC81 /* Embed Foundation Extensions */,
|
||||
4FF5DF8A2C41575B0039B22C /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "monoclient-x";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "monoclient-x";
|
||||
productReference = 4FAD30F32C1980D900074B09 /* monoclient-x.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
4FAD30EB2C1980D900074B09 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
4FAD30F22C1980D900074B09 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 4FAD30EE2C1980D900074B09 /* Build configuration list for PBXProject "monoclient-x" */;
|
||||
compatibilityVersion = "Xcode 15.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 4FAD30EA2C1980D900074B09;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 4FAD30F42C1980D900074B09 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
4FAD30F22C1980D900074B09 /* monoclient-x */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
4FAD30F12C1980D900074B09 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4FAD31012C1980DC00074B09 /* Preview Assets.xcassets in Resources */,
|
||||
4FAD30FD2C1980DC00074B09 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
4FAD30EF2C1980D900074B09 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4FAE6E662C1B5EB100074B09 /* Player.swift in Sources */,
|
||||
4FAD30F92C1980D900074B09 /* ContentView.swift in Sources */,
|
||||
4FAD30FB2C1980D900074B09 /* Metadata.swift in Sources */,
|
||||
4FAD30F72C1980D900074B09 /* monoclient_xApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
4FAD31022C1980DC00074B09 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
4FAD31032C1980DC00074B09 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
4FAD31052C1980DC00074B09 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_USE_OPTIMIZATION_PROFILE = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "monoclient-x/monoclient_x.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"monoclient-x/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = F5PQ7AR4DP;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = "${SRCROOT}/../target/**";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "";
|
||||
INFOPLIST_FILE = "monoclient-x/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "monoclient-x";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = "";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ivabus.monoclient-x";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 2.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
4FAD31062C1980DC00074B09 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_USE_OPTIMIZATION_PROFILE = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "monoclient-x/monoclient_x.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"monoclient-x/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = F5PQ7AR4DP;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = "${SRCROOT}/../target/**";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "";
|
||||
INFOPLIST_FILE = "monoclient-x/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "monoclient-x";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = "${PROJECT_DIR/../target/aarch64-apple-darwin/release}";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ivabus.monoclient-x";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 2.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
4FAD30EE2C1980D900074B09 /* Build configuration list for PBXProject "monoclient-x" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4FAD31022C1980DC00074B09 /* Debug */,
|
||||
4FAD31032C1980DC00074B09 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
4FAD31042C1980DC00074B09 /* Build configuration list for PBXNativeTarget "monoclient-x" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4FAD31052C1980DC00074B09 /* Debug */,
|
||||
4FAD31062C1980DC00074B09 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 4FAD30EB2C1980D900074B09 /* Project object */;
|
||||
}
|
7
monoclient-x/monoclient-x.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
|
@ -2,9 +2,7 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4FAD30F22C1980D900074B09"
|
||||
BuildableName = "monoclient-x.app"
|
||||
BlueprintName = "monoclient-x"
|
||||
ReferencedContainer = "container:monoclient-x.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4FAD30F22C1980D900074B09"
|
||||
BuildableName = "monoclient-x.app"
|
||||
BlueprintName = "monoclient-x"
|
||||
ReferencedContainer = "container:monoclient-x.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4FAD30F22C1980D900074B09"
|
||||
BuildableName = "monoclient-x.app"
|
||||
BlueprintName = "monoclient-x"
|
||||
ReferencedContainer = "container:monoclient-x.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -2,8 +2,8 @@
|
|||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"platform" : "ios",
|
||||
"reference" : "systemPinkColor"
|
||||
"platform" : "universal",
|
||||
"reference" : "systemPurpleColor"
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
|
@ -1,7 +1,31 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024@1x.png",
|
||||
"filename" : "monoclient-x-ios.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "monoclient-x-ios 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "monoclient-x-ios 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
@ -49,19 +73,19 @@
|
|||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256@2x.png",
|
||||
"filename" : "Icon-macOS-512x512@1x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512@1x.png",
|
||||
"filename" : "Icon-macOS-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512@2x.png",
|
||||
"filename" : "Icon-macOS-512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 72 KiB |
After Width: | Height: | Size: 8.3 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 928 B |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 93 KiB |
71
monoclient-x/monoclient-x/ContentView.swift
Normal file
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// monoclient-x
|
||||
//
|
||||
// Created by ivabus on 12.06.2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AppIntents
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
Player()
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayIntent: AudioPlaybackIntent {
|
||||
static var title: LocalizedStringResource = "Start lonelyradio client"
|
||||
static var description = IntentDescription("Plays from setted up server")
|
||||
|
||||
static var openAppWhenRun: Bool = false
|
||||
static var isDiscoverable: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
Player().play()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct StopIntent: AudioPlaybackIntent {
|
||||
static var title: LocalizedStringResource = "Stop lonelyradio client"
|
||||
static var description = IntentDescription("Stops monoclient")
|
||||
|
||||
static var openAppWhenRun: Bool = false
|
||||
static var isDiscoverable: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
Player().stop()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryAppShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: PlayIntent(),
|
||||
phrases: [
|
||||
"Start playback \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Start monoclient",
|
||||
systemImageName: "infinity.circle"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: StopIntent(),
|
||||
phrases: [
|
||||
"Stop playback in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Stop monoclient",
|
||||
systemImageName: "stop.fill"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
57
monoclient-x/monoclient-x/Metadata.swift
Normal file
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Item.swift
|
||||
// monoclient-x
|
||||
//
|
||||
// Created by ivabus on 12.06.2024.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import CoreFoundation
|
||||
import SwiftUI
|
||||
import MonoLib
|
||||
|
||||
#if os(macOS)
|
||||
typealias PlatformImage = NSImage
|
||||
#else
|
||||
typealias PlatformImage = UIImage
|
||||
#endif
|
||||
|
||||
struct Metadata {
|
||||
public var title: String
|
||||
public var album: String
|
||||
public var artist: String
|
||||
|
||||
mutating func update() {
|
||||
self.title = String(cString: c_get_metadata_title())
|
||||
self.album = String(cString: c_get_metadata_album())
|
||||
self.artist = String(cString: c_get_metadata_artist())
|
||||
}
|
||||
}
|
||||
|
||||
extension Metadata: Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
(lhs.album == rhs.album) && (lhs.artist == rhs.artist) && (lhs.title == rhs.title)
|
||||
}
|
||||
}
|
||||
|
||||
struct Cover {
|
||||
public var cover: PlatformImage
|
||||
|
||||
mutating func update() {
|
||||
let cov = c_get_cover_jpeg()
|
||||
if cov.length != 0 {
|
||||
let data = CFDataCreate(kCFAllocatorDefault, cov.bytes, Int(cov.length))!
|
||||
#if os(macOS)
|
||||
self.cover = PlatformImage(cgImage: CGImage(jpegDataProviderSource: CGDataProvider(data: data)!, decode: nil, shouldInterpolate: false, intent: CGColorRenderingIntent.absoluteColorimetric)!, size: NSSize.init(width: 768, height:768))
|
||||
#else
|
||||
self.cover = PlatformImage(cgImage: CGImage(jpegDataProviderSource: CGDataProvider(data: data)!, decode: nil, shouldInterpolate: false, intent: CGColorRenderingIntent.absoluteColorimetric)!).preparingForDisplay()!
|
||||
#endif
|
||||
// deallocating memory
|
||||
c_drop(cov.bytes, Int(cov.length))
|
||||
print(self.cover.size)
|
||||
|
||||
} else {
|
||||
self.cover = PlatformImage()
|
||||
}
|
||||
}
|
||||
}
|
271
monoclient-x/monoclient-x/Player.swift
Normal file
|
@ -0,0 +1,271 @@
|
|||
//
|
||||
// Player.swift
|
||||
// monoclient-x
|
||||
//
|
||||
// Created by ivabus on 13.06.2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AVFAudio
|
||||
import MediaPlayer
|
||||
import MonoLib
|
||||
|
||||
|
||||
enum PlayerState {
|
||||
case NotStarted
|
||||
case Playing
|
||||
case Paused
|
||||
|
||||
mutating func update() {
|
||||
self = switch c_get_state() {
|
||||
case 2: PlayerState.Playing
|
||||
case 3: PlayerState.Paused
|
||||
default: PlayerState.NotStarted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum EncoderType: UInt8 {
|
||||
case PCM16 = 0
|
||||
case PCMFloat = 1
|
||||
case FLAC = 2
|
||||
}
|
||||
|
||||
enum CoverSize: Int32 {
|
||||
case Full = 0
|
||||
case High = 768
|
||||
case Medium = 512
|
||||
case Low = 256
|
||||
case Min = 128
|
||||
case NoCover = -1
|
||||
}
|
||||
|
||||
|
||||
struct Settings {
|
||||
var encoder: EncoderType = EncoderType.FLAC
|
||||
var cover_size: CoverSize = CoverSize.High/*
|
||||
init(enc: EncoderType, cov: CoverSize) {
|
||||
encoder = enc
|
||||
cover_size = cov
|
||||
}*/
|
||||
}
|
||||
|
||||
struct Player: View {
|
||||
|
||||
let timer_state = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
|
||||
let timer_meta = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
|
||||
@State var metadata: Metadata = Metadata(title: "", album: "", artist: "")
|
||||
@State var prev_meta: Metadata = Metadata(title: "", album: "", artist: "")
|
||||
@State var cover: Cover = Cover(cover: PlatformImage())
|
||||
@State var state: PlayerState = PlayerState.NotStarted
|
||||
@State var settings: Settings = Settings.init()
|
||||
@AppStorage("ContentView.server") var server: String = ""
|
||||
|
||||
var body: some View {
|
||||
|
||||
VStack(alignment: .center) {
|
||||
#if os(macOS)
|
||||
Image(nsImage: cover.cover)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(minWidth: 256, maxWidth: 256, minHeight: 256, maxHeight: 256)
|
||||
.frame(width: 256.0, height: 256.0)
|
||||
.clipShape(.rect(cornerRadius: 24))
|
||||
.shadow(radius: 16)
|
||||
.padding(16)
|
||||
#else
|
||||
Image(uiImage: cover.cover)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(minWidth: 256, maxWidth: 256, minHeight: 256, maxHeight: 256)
|
||||
.frame(width: 256.0, height: 256.0)
|
||||
.clipShape(.rect(cornerRadius: 24))
|
||||
.shadow(radius: 16)
|
||||
.padding(16)
|
||||
#endif
|
||||
|
||||
VStack(alignment: .center){
|
||||
Text(metadata.title).bold()
|
||||
|
||||
Text(metadata.album)
|
||||
|
||||
Text(metadata.artist)
|
||||
}.frame(minHeight: 64)
|
||||
|
||||
TextField(
|
||||
"Server",
|
||||
text: $server,
|
||||
onCommit: {
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
NSApp.keyWindow?.makeFirstResponder(nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
)
|
||||
.disableAutocorrection(true)
|
||||
.frame(width: 256)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(16)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: stop){
|
||||
Image(systemName: "stop.fill").padding(4).frame(width: 32, height: 24)
|
||||
}
|
||||
.disabled(state == PlayerState.NotStarted)
|
||||
.buttonStyle(.bordered)
|
||||
.font(.system(size: 20))
|
||||
.buttonBorderShape(.capsule)
|
||||
|
||||
Button(action: play){
|
||||
Image(systemName: state == PlayerState.NotStarted ? "infinity.circle" : (state == PlayerState.Playing) ? "pause.circle.fill" : "play.circle" )
|
||||
.font(.system(size: 30))
|
||||
.padding(4)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
|
||||
Button(action: next){
|
||||
Image(systemName: "forward.end.fill").padding(4).frame(width: 32, height: 24)
|
||||
}.disabled(state == PlayerState.NotStarted)
|
||||
.buttonStyle(.bordered)
|
||||
.font(.system(size: 20))
|
||||
.buttonBorderShape(.capsule)
|
||||
}
|
||||
Menu {
|
||||
Picker("Encoder", selection: $settings.encoder) {
|
||||
Text("PCM (s16)")
|
||||
.tag(EncoderType.PCM16)
|
||||
Text("PCM (f32)")
|
||||
.tag(EncoderType.PCMFloat)
|
||||
Text("FLAC (s24)")
|
||||
.tag(EncoderType.FLAC)
|
||||
}.pickerStyle(.menu)
|
||||
|
||||
Picker("Cover size", selection: $settings.cover_size) {
|
||||
Text("Original")
|
||||
.tag(CoverSize.Full)
|
||||
Text("High (768)")
|
||||
.tag(CoverSize.High)
|
||||
Text("Medium (512)")
|
||||
.tag(CoverSize.Medium)
|
||||
Text("Low (256)")
|
||||
.tag(CoverSize.Low)
|
||||
Text("Min (128)")
|
||||
.tag(CoverSize.Min)
|
||||
Text("No cover")
|
||||
.tag(CoverSize.NoCover)
|
||||
}.pickerStyle(.menu)
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.padding(16)
|
||||
}.frame(maxWidth: 128)
|
||||
}
|
||||
.padding(32)
|
||||
.onReceive(timer_state) { _ in
|
||||
state.update()
|
||||
|
||||
#if os(macOS)
|
||||
MPNowPlayingInfoCenter.default().playbackState = state == PlayerState.Playing ? .playing : .paused
|
||||
#endif
|
||||
|
||||
}
|
||||
.onReceive(timer_meta) { _ in
|
||||
metadata.update()
|
||||
if prev_meta != metadata || metadata.album == "" || cover.cover == PlatformImage() {
|
||||
prev_meta = metadata
|
||||
cover.update()
|
||||
}
|
||||
let image = cover.cover
|
||||
let mediaArtwork = MPMediaItemArtwork(boundsSize: image.size) { (size: CGSize) -> PlatformImage in
|
||||
return image
|
||||
}
|
||||
|
||||
let nowPlayingInfo: [String: Any] = [
|
||||
MPMediaItemPropertyArtist: metadata.artist,
|
||||
MPMediaItemPropertyAlbumTitle: metadata.album,
|
||||
MPMediaItemPropertyTitle: metadata.title,
|
||||
MPMediaItemPropertyArtwork: mediaArtwork,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: true,
|
||||
MPMediaItemPropertyPlaybackDuration: c_get_metadata_length(),
|
||||
|
||||
]
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
|
||||
}
|
||||
.onAppear() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = true
|
||||
MPRemoteCommandCenter.shared().skipForwardCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().skipBackwardCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget(handler: { _ in
|
||||
if state != PlayerState.Paused {
|
||||
play()
|
||||
}
|
||||
return MPRemoteCommandHandlerStatus.success
|
||||
})
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget(handler: { _ in
|
||||
if state != PlayerState.Playing {
|
||||
play()
|
||||
}
|
||||
return MPRemoteCommandHandlerStatus.success
|
||||
})
|
||||
|
||||
MPRemoteCommandCenter.shared().togglePlayPauseCommand.addTarget(handler: {_ in
|
||||
play()
|
||||
return MPRemoteCommandHandlerStatus.success
|
||||
})
|
||||
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.addTarget(handler: {_ in
|
||||
next()
|
||||
return MPRemoteCommandHandlerStatus.success
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
.animation(.spring, value: UUID())
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func play() {
|
||||
switch state {
|
||||
case PlayerState.NotStarted: do {
|
||||
#if os(iOS)
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback, mode: .default)
|
||||
try audioSession.setActive(true)
|
||||
|
||||
} catch {
|
||||
print("Failed to set the audio session configuration")
|
||||
}
|
||||
#endif
|
||||
Thread.detachNewThread {
|
||||
c_start(server, CSettings(encoder: settings.encoder.rawValue, cover: settings.cover_size.rawValue))
|
||||
}
|
||||
}
|
||||
default: do {
|
||||
c_toggle()
|
||||
state.update()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
func stop() {
|
||||
c_stop()
|
||||
state.update()
|
||||
cover = Cover(cover: PlatformImage())
|
||||
}
|
||||
func next() {
|
||||
c_stop()
|
||||
state.update()
|
||||
play()
|
||||
}
|
||||
}
|
10
monoclient-x/monoclient-x/monoclient_x.entitlements
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
35
monoclient-x/monoclient-x/monoclient_xApp.swift
Normal file
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// monoclient_xApp.swift
|
||||
// monoclient-x
|
||||
//
|
||||
// Created by ivabus on 12.06.2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct monoclient_xApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
WindowGroup {
|
||||
ContentView().onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
.containerBackground(.ultraThinMaterial, for: .window)
|
||||
.windowFullScreenBehavior(.disabled)
|
||||
.windowResizeBehavior(.disabled)
|
||||
}.defaultSize(width: 256, height: 512)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands {
|
||||
CommandGroup(replacing: CommandGroupPlacement.newItem) {
|
||||
}
|
||||
}
|
||||
#else
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.defaultSize(width: 256, height: 512)
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
[package]
|
||||
name = "monoclient"
|
||||
license = "MIT"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
|
||||
repository = "https://github.com/ivabus/lonelyradio"
|
||||
|
||||
[dependencies]
|
||||
monolib = { version = "0.5.0", path = "../monolib" }
|
||||
monolib = { version = "0.6.0", path = "../monolib" }
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
crossterm = "0.27.0"
|
||||
lonelyradio_types = { version = "0.6.0", path = "../lonelyradio_types" }
|
||||
|
||||
|
|
|
@ -3,10 +3,14 @@ use crossterm::cursor::MoveToColumn;
|
|||
use crossterm::event::{poll, read, Event};
|
||||
use crossterm::style::Print;
|
||||
use crossterm::terminal::{Clear, ClearType};
|
||||
use lonelyradio_types::{Encoder, Settings};
|
||||
use std::io::stdout;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
static VERBOSE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// Remote address
|
||||
|
@ -14,6 +18,9 @@ struct Args {
|
|||
|
||||
#[arg(long)]
|
||||
xor_key_file: Option<PathBuf>,
|
||||
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
const HELP: &str = r#"Keybinds:
|
||||
|
@ -22,16 +29,31 @@ const HELP: &str = r#"Keybinds:
|
|||
Q - Quit monoclient
|
||||
H - Show this help"#;
|
||||
|
||||
macro_rules! verbose {
|
||||
($($arg:tt)*) => {{
|
||||
if *VERBOSE.get().unwrap() {
|
||||
crossterm::execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap();
|
||||
println!("{}", format_args!($($arg)*));
|
||||
crossterm::execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap();
|
||||
}
|
||||
}};
|
||||
}
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
VERBOSE.set(args.verbose).unwrap();
|
||||
std::thread::spawn(move || {
|
||||
monolib::run(
|
||||
&args.address,
|
||||
args.xor_key_file.map(|key| std::fs::read(key).expect("Failed to read preshared key")),
|
||||
Settings {
|
||||
encoder: Encoder::PcmFloat,
|
||||
cover: -1,
|
||||
},
|
||||
)
|
||||
});
|
||||
while monolib::get_metadata().is_none() {}
|
||||
let mut md = monolib::get_metadata().unwrap();
|
||||
verbose!("md: {:?}", md);
|
||||
let mut track_start = Instant::now();
|
||||
let mut seconds_past = 0;
|
||||
crossterm::execute!(
|
||||
|
@ -101,9 +123,10 @@ fn main() {
|
|||
}
|
||||
}
|
||||
if monolib::get_metadata().unwrap() != md
|
||||
&& track_length <= (Instant::now() - track_start).as_secs_f64()
|
||||
//&& track_length <= (Instant::now() - track_start).as_secs_f64()
|
||||
{
|
||||
md = next_md.clone();
|
||||
verbose!("md: {:?}", md);
|
||||
crossterm::execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap();
|
||||
print!(
|
||||
"Playing: {} - {} - {} (0:00 / {}:{:02})",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "monolib"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "A library implementing the lonely radio audio streaming protocol"
|
||||
|
@ -9,11 +9,19 @@ authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
|
|||
|
||||
[lib]
|
||||
name = "monolib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
rodio = { version = "0.17.3", default-features = false }
|
||||
byteorder = "1.5.0"
|
||||
rmp-serde = "1.1.2"
|
||||
lonelyradio_types = { version = "0.5.0", path = "../lonelyradio_types" }
|
||||
lonelyradio_types = { version = "0.6.0", path = "../lonelyradio_types" }
|
||||
claxon = "0.4.3"
|
||||
|
||||
[package.metadata.xcframework]
|
||||
include-dir = "src"
|
||||
lib-type = "cdylib"
|
||||
zip = false
|
||||
macOS = true
|
||||
iOS = true
|
||||
simulators = true
|
||||
|
|
12
monolib/cbindgen.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
|
||||
language = "C"
|
||||
|
||||
|
||||
include_version = false
|
||||
|
||||
############################ Code Style Options ################################
|
||||
|
||||
braces = "SameLine"
|
||||
line_length = 100
|
||||
tab_width = 2
|
||||
line_endings = "LF"
|
|
@ -1,18 +1,46 @@
|
|||
use crate::*;
|
||||
|
||||
use std::ffi::{c_char, c_float, c_ushort};
|
||||
use std::ffi::{c_char, c_float};
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CTrackMetadata {
|
||||
pub title: *mut c_char,
|
||||
pub album: *mut c_char,
|
||||
pub artist: *mut c_char,
|
||||
}
|
||||
|
||||
pub const ENCODER_PCM16: u8 = 0;
|
||||
pub const ENCODER_PCMFLOAT: u8 = 1;
|
||||
pub const ENCODER_FLAC: u8 = 2;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CSettings {
|
||||
/// See lonelyradio_types -> Encoder
|
||||
pub encoder: u8,
|
||||
pub cover: i32,
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||
pub extern "C" fn c_start(server: *const c_char) {
|
||||
pub extern "C" fn c_start(server: *const c_char, settings: CSettings) {
|
||||
let serv = unsafe { CStr::from_ptr(server) };
|
||||
run(
|
||||
match serv.to_str() {
|
||||
Ok(s) => s,
|
||||
_ => "",
|
||||
},
|
||||
None,
|
||||
Settings {
|
||||
encoder: match settings.encoder {
|
||||
0 => Encoder::Pcm16,
|
||||
1 => Encoder::PcmFloat,
|
||||
2 => Encoder::Flac,
|
||||
_ => return,
|
||||
},
|
||||
cover: settings.cover,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -27,9 +55,9 @@ pub extern "C" fn c_stop() {
|
|||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_state() -> c_ushort {
|
||||
pub extern "C" fn c_get_state() -> c_char {
|
||||
let state = STATE.read().unwrap();
|
||||
*state as c_ushort
|
||||
*state as c_char
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
|
@ -69,10 +97,55 @@ pub extern "C" fn c_get_metadata_title() -> *mut c_char {
|
|||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn c_get_metadata_length() -> *mut c_float {
|
||||
pub extern "C" fn c_get_metadata_length() -> c_float {
|
||||
let md = MD.read().unwrap();
|
||||
match md.as_ref() {
|
||||
Some(md) => &mut (md.track_length_secs as c_float + md.track_length_frac as c_float),
|
||||
None => &mut 0.0,
|
||||
Some(md) => md.track_length_secs as c_float + md.track_length_frac as c_float,
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct CImageJpeg {
|
||||
pub length: u32,
|
||||
pub bytes: *mut u8,
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Manually deallocate returned memory after use
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn c_get_cover_jpeg() -> CImageJpeg {
|
||||
let md = MD.read().unwrap();
|
||||
if let Some(md) = md.as_ref() {
|
||||
if let Some(cov) = md.cover.as_ref() {
|
||||
//eprintln!("{} {:p}", *len, cov.as_ptr());
|
||||
let len = cov.len() as u32;
|
||||
//let b = Box::new(.as_slice());
|
||||
let clone = cov.clone();
|
||||
let ptr = clone.as_ptr() as *mut u8;
|
||||
std::mem::forget(clone);
|
||||
CImageJpeg {
|
||||
length: len,
|
||||
bytes: ptr,
|
||||
}
|
||||
} else {
|
||||
eprintln!("No cov");
|
||||
CImageJpeg {
|
||||
length: 0,
|
||||
bytes: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("No md");
|
||||
CImageJpeg {
|
||||
length: 0,
|
||||
bytes: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// # Safety
|
||||
/// None
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn c_drop(ptr: *mut u8, count: usize) {
|
||||
std::alloc::dealloc(ptr, std::alloc::Layout::from_size_align(count, 1).unwrap());
|
||||
}
|
||||
|
|
|
@ -16,20 +16,20 @@
|
|||
|
||||
/// Functions, providing C-like API
|
||||
pub mod c;
|
||||
mod reader;
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use lonelyradio_types::{Message, TrackMetadata};
|
||||
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
|
||||
use lonelyradio_types::{Encoder, Message, ServerCapabilities, Settings, TrackMetadata};
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::{OutputStream, Sink};
|
||||
use std::error::Error;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Instant;
|
||||
|
||||
const CACHE_SIZE: usize = 128;
|
||||
const CACHE_SIZE_PCM: usize = 32;
|
||||
const CACHE_SIZE_COMPRESSED: usize = 2;
|
||||
|
||||
static SINK: RwLock<Option<Sink>> = RwLock::new(None);
|
||||
static VOLUME: AtomicU8 = AtomicU8::new(255);
|
||||
|
@ -76,7 +76,8 @@ pub fn stop() {
|
|||
|
||||
let sink = SINK.read().unwrap();
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
sink.pause()
|
||||
sink.pause();
|
||||
sink.clear()
|
||||
}
|
||||
drop(sink);
|
||||
drop(state);
|
||||
|
@ -143,12 +144,16 @@ pub fn set_volume(volume: u8) {
|
|||
}
|
||||
|
||||
/// Download track as samples
|
||||
pub fn get_track(server: &str, xor_key: Option<Vec<u8>>) -> Option<(TrackMetadata, Vec<i16>)> {
|
||||
let mut stream = BufReader::new(match xor_key {
|
||||
Some(k) => reader::Reader::XorEncrypted(TcpStream::connect(server).unwrap(), k, 0),
|
||||
None => reader::Reader::Unencrypted(TcpStream::connect(server).unwrap()),
|
||||
});
|
||||
pub fn get_track(server: &str, mut settings: Settings) -> Option<(TrackMetadata, Vec<i16>)> {
|
||||
let mut connection = unwrap(TcpStream::connect(server))?;
|
||||
unwrap(connection.write_u64::<LittleEndian>(lonelyradio_types::HELLO_MAGIC))?;
|
||||
let capabilities: ServerCapabilities = unwrap(rmp_serde::from_read(&mut connection))?;
|
||||
if !capabilities.encoders.contains(&settings.encoder) {
|
||||
settings.encoder = Encoder::Pcm16
|
||||
}
|
||||
unwrap(connection.write_all(&rmp_serde::to_vec_named(&settings).unwrap()))?;
|
||||
|
||||
let mut stream = connection;
|
||||
let mut samples = vec![];
|
||||
let mut md: Option<TrackMetadata> = None;
|
||||
loop {
|
||||
|
@ -160,47 +165,59 @@ pub fn get_track(server: &str, xor_key: Option<Vec<u8>>) -> Option<(TrackMetadat
|
|||
}
|
||||
md = Some(tmd);
|
||||
}
|
||||
Message::F(fmd) => {
|
||||
if !md.clone().unwrap().flac {
|
||||
Message::F(fmd) => match md.as_ref().unwrap().encoder {
|
||||
Encoder::Pcm16 => {
|
||||
let mut buf = vec![0; fmd.length as usize];
|
||||
stream.read_i16_into::<LittleEndian>(&mut buf).unwrap();
|
||||
samples.append(&mut buf);
|
||||
} else {
|
||||
let take = stream.by_ref().take(fmd.length);
|
||||
}
|
||||
Encoder::PcmFloat => unimplemented!(),
|
||||
Encoder::Flac => {
|
||||
let take = std::io::Read::by_ref(&mut stream).take(fmd.length);
|
||||
let mut reader = claxon::FlacReader::new(take).unwrap();
|
||||
samples.append(
|
||||
&mut reader.samples().map(|x| x.unwrap_or(0) as i16).collect::<Vec<i16>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
md.map(|md| (md, samples))
|
||||
}
|
||||
|
||||
fn unwrap<T, E: Error>(thing: Result<T, E>) -> T {
|
||||
fn unwrap<T, E: Error>(thing: Result<T, E>) -> Option<T> {
|
||||
if thing.is_err() {
|
||||
*STATE.write().unwrap() = State::NotStarted;
|
||||
}
|
||||
thing.unwrap()
|
||||
thing.ok()
|
||||
}
|
||||
|
||||
/// Starts playing at "server:port"
|
||||
pub fn run(server: &str, xor_key: Option<Vec<u8>>) {
|
||||
pub fn run(server: &str, settings: Settings) {
|
||||
let _ = _run(server, settings);
|
||||
}
|
||||
|
||||
pub fn _run(server: &str, settings: Settings) -> Option<()> {
|
||||
let mut settings = settings;
|
||||
let mut state = STATE.write().unwrap();
|
||||
if *state == State::Playing || *state == State::Paused {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
*state = State::Playing;
|
||||
drop(state);
|
||||
|
||||
let mut stream = BufReader::new(match xor_key {
|
||||
Some(k) => reader::Reader::XorEncrypted(unwrap(TcpStream::connect(server)), k, 0),
|
||||
None => reader::Reader::Unencrypted(unwrap(TcpStream::connect(server))),
|
||||
});
|
||||
let mut connection = unwrap(TcpStream::connect(server))?;
|
||||
unwrap(connection.write_u64::<LittleEndian>(lonelyradio_types::HELLO_MAGIC))?;
|
||||
let capabilities: ServerCapabilities = unwrap(rmp_serde::from_read(&mut connection))?;
|
||||
if !capabilities.encoders.contains(&settings.encoder) {
|
||||
settings.encoder = Encoder::Pcm16
|
||||
}
|
||||
unwrap(connection.write_all(&rmp_serde::to_vec_named(&settings).unwrap()))?;
|
||||
|
||||
let mut stream = connection;
|
||||
|
||||
let mut sink = SINK.write().unwrap();
|
||||
let (_stream, stream_handle) = unwrap(OutputStream::try_default());
|
||||
let (_stream, stream_handle) = unwrap(OutputStream::try_default())?;
|
||||
|
||||
// Can't reuse old sink for some reason
|
||||
let audio_sink = Sink::try_new(&stream_handle).unwrap();
|
||||
|
@ -215,7 +232,7 @@ pub fn run(server: &str, xor_key: Option<Vec<u8>>) {
|
|||
// No metadata shift
|
||||
if watching_sleep_until_end() {
|
||||
_stop();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let mut md = MD.write().unwrap();
|
||||
*md = Some(tmd.clone());
|
||||
|
@ -227,49 +244,60 @@ pub fn run(server: &str, xor_key: Option<Vec<u8>>) {
|
|||
}
|
||||
if *STATE.read().unwrap() == State::Resetting {
|
||||
_stop();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
if !MD.read().unwrap().clone().unwrap().flac {
|
||||
let mut samples_i16 = vec![0; fmd.length as usize];
|
||||
match MD.read().unwrap().as_ref().unwrap().encoder {
|
||||
Encoder::Pcm16 => {
|
||||
let mut samples_i16 = vec![0; fmd.length as usize / 2];
|
||||
if stream.read_i16_into::<LittleEndian>(&mut samples_i16).is_err() {
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
samples.append(
|
||||
&mut samples_i16.iter().map(|sample| *sample as f32 / 32767.0).collect(),
|
||||
&mut samples_i16
|
||||
.iter()
|
||||
.map(|sample| *sample as f32 / 32767.0)
|
||||
.collect(),
|
||||
);
|
||||
} else {
|
||||
let take = stream.by_ref().take(fmd.length);
|
||||
}
|
||||
Encoder::PcmFloat => {
|
||||
let mut samples_f32 = vec![0f32; fmd.length as usize / 4];
|
||||
if stream.read_f32_into::<LittleEndian>(&mut samples_f32).is_err() {
|
||||
return None;
|
||||
};
|
||||
samples.append(&mut samples_f32);
|
||||
}
|
||||
Encoder::Flac => {
|
||||
let take = std::io::Read::by_ref(&mut stream).take(fmd.length);
|
||||
let mut reader = claxon::FlacReader::new(take).unwrap();
|
||||
samples.append(
|
||||
&mut reader
|
||||
.samples()
|
||||
.map(|x| x.unwrap_or(0) as f32 / 32767.0)
|
||||
.map(|x| x.unwrap_or(0) as f32 / 32768.0 / 256.0)
|
||||
.collect::<Vec<f32>>(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
// Synchronizing with sink
|
||||
let sink = SINK.read().unwrap();
|
||||
let _md = MD.read().unwrap();
|
||||
let md = _md.as_ref().unwrap().clone();
|
||||
drop(_md);
|
||||
if let Some(sink) = sink.as_ref() {
|
||||
while sink.len() >= CACHE_SIZE {
|
||||
while (sink.len() >= CACHE_SIZE_PCM && md.encoder != Encoder::Flac)
|
||||
|| (sink.len() >= CACHE_SIZE_COMPRESSED && md.encoder == Encoder::Flac)
|
||||
{
|
||||
// Sleeping exactly one buffer and watching for reset signal
|
||||
if watching_sleep(
|
||||
if sink.len() > 2 {
|
||||
sink.len() as f32 - 2.0
|
||||
} else {
|
||||
0.25
|
||||
} * fmd.length as f32 / md.sample_rate as f32
|
||||
} * samples.len() as f32 / md.sample_rate as f32
|
||||
/ 4.0,
|
||||
) {
|
||||
_stop();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
sink.append(SamplesBuffer::new(
|
||||
|
|
5
monolib/src/module.modulemap
Normal file
|
@ -0,0 +1,5 @@
|
|||
framework module MonoLib {
|
||||
// a header file in the same directory as the modulemap
|
||||
header "monolib.h"
|
||||
export *
|
||||
}
|
|
@ -1,20 +1,42 @@
|
|||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
void c_start(const char *server);
|
||||
typedef struct CImageJpeg {
|
||||
uint32_t length;
|
||||
uint8_t *bytes;
|
||||
} CImageJpeg;
|
||||
|
||||
void c_toggle(void);
|
||||
typedef struct CSettings {
|
||||
/**
|
||||
* See lonelyradio_types -> Encoder
|
||||
*/
|
||||
uint8_t encoder;
|
||||
int32_t cover;
|
||||
} CSettings;
|
||||
|
||||
void c_stop(void);
|
||||
void c_drop(uint8_t *ptr, size_t count);
|
||||
|
||||
unsigned short c_get_state(void);
|
||||
|
||||
char *c_get_metadata_artist(void);
|
||||
/**
|
||||
* # Safety
|
||||
* Manually deallocate returned memory after use
|
||||
*/
|
||||
struct CImageJpeg c_get_cover_jpeg(void);
|
||||
|
||||
char *c_get_metadata_album(void);
|
||||
|
||||
char *c_get_metadata_artist(void);
|
||||
|
||||
float c_get_metadata_length(void);
|
||||
|
||||
char *c_get_metadata_title(void);
|
||||
|
||||
float *c_get_metadata_length(void);
|
||||
char c_get_state(void);
|
||||
|
||||
void c_start(const char *server, struct CSettings settings);
|
||||
|
||||
void c_stop(void);
|
||||
|
||||
void c_toggle(void);
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
use std::{io, net::TcpStream};
|
||||
|
||||
pub(crate) enum Reader {
|
||||
Unencrypted(TcpStream),
|
||||
XorEncrypted(TcpStream, Vec<u8>, u64),
|
||||
}
|
||||
|
||||
impl io::Read for Reader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Self::Unencrypted(s) => s.read(buf),
|
||||
Self::XorEncrypted(s, key, n) => {
|
||||
let out = s.read(buf);
|
||||
if let Ok(i) = &out {
|
||||
for k in buf.iter_mut().take(*i) {
|
||||
*k ^= key[*n as usize];
|
||||
*n += 1;
|
||||
*n %= key.len() as u64;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
monolib/src/target
Symbolic link
|
@ -0,0 +1 @@
|
|||
../target
|
1
monolib/target
Symbolic link
|
@ -0,0 +1 @@
|
|||
../target
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "monoloader"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
@ -9,3 +9,4 @@ edition = "2021"
|
|||
monolib = { path = "../monolib" }
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
hound = "3.5.1"
|
||||
lonelyradio_types = { version = "0.6.0", path = "../lonelyradio_types" }
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use clap::Parser;
|
||||
use lonelyradio_types::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
@ -14,7 +15,10 @@ fn main() {
|
|||
let args = Args::parse();
|
||||
let (md, samples) = monolib::get_track(
|
||||
&args.address,
|
||||
args.xor_key_file.map(|key| std::fs::read(key).expect("Failed to read preshared key")),
|
||||
Settings {
|
||||
encoder: lonelyradio_types::Encoder::Pcm16,
|
||||
cover: -1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
println!(
|
||||
|
|
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 294 KiB |
Before Width: | Height: | Size: 277 KiB |
|
@ -1,13 +0,0 @@
|
|||
//
|
||||
// MonoLib-Bridging-Header.h
|
||||
// monoclient
|
||||
//
|
||||
// Created by ivabus on 03.03.2024.
|
||||
//
|
||||
|
||||
#ifndef MonoLib_Bridging_Header_h
|
||||
#define MonoLib_Bridging_Header_h
|
||||
|
||||
#import "monolib.h"
|
||||
|
||||
#endif /* MonoLib_Bridging_Header_h */
|
|
@ -1,21 +0,0 @@
|
|||
# Platform-specific player realizations
|
||||
|
||||
## Rust + SwiftUI (iOS/iPadOS/macOS (iOS mode))
|
||||
|
||||
### Build `monolib`
|
||||
|
||||
```
|
||||
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 -p monolib
|
||||
```
|
||||
|
||||
### Build and run app
|
||||
|
||||
Open Xcode and run.
|
||||
|
||||
[Screenshots (pre v0.2)](./screenshots/swiftui)
|
Before Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 673 B |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 51 KiB |
|
@ -1,116 +0,0 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// monoclient
|
||||
//
|
||||
// Created by ivabus on 03.03.2024.
|
||||
//
|
||||
|
||||
import AVFAudio
|
||||
import SwiftUI
|
||||
|
||||
class MonoLib {
|
||||
func run(server: String) async {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback, mode: .default,
|
||||
policy: .longFormAudio)
|
||||
try audioSession.setActive(true)
|
||||
|
||||
} catch {
|
||||
print("Failed to set the audio session configuration")
|
||||
}
|
||||
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 playing: Bool = true
|
||||
@State private var running: Bool = false
|
||||
|
||||
@State var now_playing_artist: String = ""
|
||||
@State var now_playing_album: String = ""
|
||||
@State var now_playing_title: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Monoclient").font(.largeTitle).fontWidth(.expanded).bold()
|
||||
VStack(alignment: .center) {
|
||||
HStack {
|
||||
Text("Server").frame(minWidth: 50, idealWidth: 60)
|
||||
TextField(
|
||||
"Required",
|
||||
text: $server
|
||||
)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Text("Port").frame(minWidth: 50, idealWidth: 60)
|
||||
TextField(
|
||||
"Required",
|
||||
text: $port
|
||||
)
|
||||
.disableAutocorrection(true).keyboardType(.numberPad).keyboardShortcut(.escape)
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button(action: {
|
||||
if running {
|
||||
playing = !playing
|
||||
c_toggle()
|
||||
}
|
||||
running = true
|
||||
let a = MonoLib()
|
||||
Task.init {
|
||||
await a.run(server: server + ":" + port)
|
||||
}
|
||||
}) {
|
||||
Image(
|
||||
systemName: running
|
||||
? (playing ? "pause.circle.fill" : "play.circle") : "infinity.circle"
|
||||
).font(.largeTitle)
|
||||
}.buttonStyle(
|
||||
.borderedProminent)
|
||||
HStack{
|
||||
Button(action: {
|
||||
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)
|
||||
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: 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()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
//
|
||||
// monoclientApp.swift
|
||||
// monoclient
|
||||
//
|
||||
// Created by ivabus on 03.03.2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct monoclientApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -65,19 +65,11 @@ pub async fn get_meta(file_path: &Path) -> (u16, u32, Time) {
|
|||
}
|
||||
let args = Args::parse();
|
||||
|
||||
(
|
||||
channels,
|
||||
if sample_rate > args.max_samplerate {
|
||||
args.max_samplerate
|
||||
} else {
|
||||
sample_rate
|
||||
},
|
||||
track_length,
|
||||
)
|
||||
(channels, get_resampling_rate(&sample_rate, &args.max_samplerate), track_length)
|
||||
}
|
||||
|
||||
/// Getting samples
|
||||
pub fn decode_file_stream(file_path: PathBuf) -> impl Stream<Item = Vec<i16>> {
|
||||
pub fn decode_file_stream(file_path: PathBuf) -> impl Stream<Item = Vec<f32>> {
|
||||
let args = Args::parse();
|
||||
let file = Box::new(std::fs::File::open(&file_path).unwrap());
|
||||
let mut hint = Hint::new();
|
||||
|
@ -128,7 +120,7 @@ pub fn decode_file_stream(file_path: PathBuf) -> impl Stream<Item = Vec<i16>> {
|
|||
// We are downsampling, not upsampling, so we should be fine
|
||||
yield (
|
||||
if output_rate == spec.rate {
|
||||
byte_buf.samples().iter().map(|x| (*x * 32768.0) as i16).collect()
|
||||
byte_buf.samples().to_vec()
|
||||
} else {
|
||||
samplerate::convert(
|
||||
spec.rate,
|
||||
|
@ -138,17 +130,14 @@ pub fn decode_file_stream(file_path: PathBuf) -> impl Stream<Item = Vec<i16>> {
|
|||
byte_buf.samples(),
|
||||
)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| (*x * 32768.0) as i16)
|
||||
.collect()
|
||||
}
|
||||
);
|
||||
|
||||
} else {
|
||||
let mut byte_buf =
|
||||
SampleBuffer::<i16>::new(decoded.capacity() as u64, *decoded.spec());
|
||||
SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
|
||||
byte_buf.copy_interleaved_ref(decoded);
|
||||
yield (byte_buf.samples().to_vec());
|
||||
yield byte_buf.samples().to_vec();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
52
src/encode.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use flacenc::{component::BitRepr, error::Verify, source::MemSource};
|
||||
use lonelyradio_types::Encoder;
|
||||
|
||||
pub fn encode(
|
||||
codec: Encoder,
|
||||
mut samples: Vec<f32>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
) -> Option<Vec<u8>> {
|
||||
match codec {
|
||||
Encoder::Pcm16 => {
|
||||
let mut samples = samples.iter_mut().map(|x| (*x * 32768.0) as i16).collect::<Vec<_>>();
|
||||
// Launching lonelyradio on the router moment
|
||||
if cfg!(target_endian = "big") {
|
||||
samples.iter_mut().for_each(|sample| {
|
||||
*sample = sample.to_le();
|
||||
});
|
||||
}
|
||||
// Sowwy about that
|
||||
let (_, samples, _) = unsafe { samples.align_to::<u8>() };
|
||||
Some(samples.to_vec())
|
||||
}
|
||||
Encoder::PcmFloat => {
|
||||
// Launching lonelyradio on the router moment
|
||||
// Sowwy about that
|
||||
let samples = samples.iter().map(|x| x.to_bits()).collect::<Vec<u32>>();
|
||||
let (_, samples, _) = unsafe { samples.align_to::<u8>() };
|
||||
Some(samples.to_vec())
|
||||
}
|
||||
Encoder::Flac => {
|
||||
let encoded = flacenc::encode_with_fixed_block_size(
|
||||
&flacenc::config::Encoder::default().into_verified().unwrap(),
|
||||
MemSource::from_samples(
|
||||
// I'm crying (It's just a burning memory)
|
||||
&samples
|
||||
.iter()
|
||||
.map(|x| (*x as f64 * 32768.0 * 256.0) as i32)
|
||||
.collect::<Vec<i32>>(),
|
||||
channels as usize,
|
||||
24,
|
||||
sample_rate as usize,
|
||||
),
|
||||
256,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut sink = flacenc::bitsink::ByteSink::new();
|
||||
encoded.write(&mut sink).unwrap();
|
||||
Some(sink.as_slice().to_vec())
|
||||
}
|
||||
}
|
||||
}
|
195
src/main.rs
|
@ -1,27 +1,30 @@
|
|||
mod decode;
|
||||
mod writer;
|
||||
mod encode;
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Local;
|
||||
use clap::Parser;
|
||||
use flacenc::component::BitRepr;
|
||||
use flacenc::error::Verify;
|
||||
use flacenc::source::MemSource;
|
||||
use encode::encode;
|
||||
use futures_util::pin_mut;
|
||||
use futures_util::StreamExt;
|
||||
use image::io::Reader as ImageReader;
|
||||
use lofty::Accessor;
|
||||
use lofty::TaggedFileExt;
|
||||
use lonelyradio_types::Encoder;
|
||||
use lonelyradio_types::ServerCapabilities;
|
||||
use lonelyradio_types::Settings;
|
||||
use lonelyradio_types::{FragmentMetadata, Message, TrackMetadata};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::prelude::*;
|
||||
use std::io::Write;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_stream::Stream;
|
||||
use walkdir::DirEntry;
|
||||
use writer::Writer;
|
||||
|
||||
use crate::decode::decode_file_stream;
|
||||
use crate::decode::get_meta;
|
||||
|
@ -51,48 +54,37 @@ struct Args {
|
|||
#[arg(long)]
|
||||
no_resampling: bool,
|
||||
|
||||
/// Use FLAC compression
|
||||
#[arg(short, long)]
|
||||
flac: bool,
|
||||
|
||||
/// Enable XOR "encryption"
|
||||
#[arg(long)]
|
||||
xor_key_file: Option<PathBuf>,
|
||||
/// Size of artwork (-1 for no artwork, 0 for original, N for NxN)
|
||||
#[arg(long, default_value = "96000")]
|
||||
artwork: i32,
|
||||
}
|
||||
|
||||
static KEY: Lazy<Option<Arc<Vec<u8>>>> = Lazy::new(|| {
|
||||
let args = Args::parse();
|
||||
if let Some(path) = args.xor_key_file {
|
||||
let key = std::fs::read(path).expect("Failed to read preshared key");
|
||||
Some(Arc::new(key))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
const SUPPORTED_ENCODERS: [Encoder; 3] = [Encoder::Pcm16, Encoder::PcmFloat, Encoder::Flac];
|
||||
|
||||
async fn stream_track(
|
||||
samples_stream: impl Stream<Item = Vec<i16>>,
|
||||
samples_stream: impl Stream<Item = Vec<f32>>,
|
||||
war: bool,
|
||||
md: TrackMetadata,
|
||||
s: &mut Writer,
|
||||
s: &mut TcpStream,
|
||||
) -> bool {
|
||||
pin_mut!(samples_stream);
|
||||
|
||||
let _md = md.clone();
|
||||
|
||||
if s.write_all(rmp_serde::to_vec(&Message::T(_md)).unwrap().as_slice()).is_err() {
|
||||
if s.write_all(rmp_serde::encode::to_vec_named(&Message::T(_md)).unwrap().as_slice()).is_err() {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Why chunks?
|
||||
// flacenc is broken on low amount of samples (Symphonia's AIFF decoder returns ~2304
|
||||
// samples per packet (on bo en's tracks), instead of usual ~8192 on any other lossless decoder)
|
||||
// flacenc is broken on low amount of samples (Symphonia's AIFF decoder returns
|
||||
// ~2304 samples per packet (on bo en's tracks), instead of usual ~8192 on any
|
||||
// other lossless decoder)
|
||||
while let Some(mut _samples) = samples_stream
|
||||
.as_mut()
|
||||
.chunks(if md.flac && md.track_length_secs > 1 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
.chunks(match md.encoder {
|
||||
Encoder::Pcm16 => 1,
|
||||
Encoder::PcmFloat => 1,
|
||||
Encoder::Flac => 16,
|
||||
})
|
||||
.next()
|
||||
.await
|
||||
|
@ -100,59 +92,53 @@ async fn stream_track(
|
|||
let mut _samples = _samples.concat();
|
||||
if war {
|
||||
_samples.iter_mut().for_each(|sample| {
|
||||
*sample = sample.signum() * 32767;
|
||||
*sample = sample.signum();
|
||||
});
|
||||
}
|
||||
if !md.flac {
|
||||
match md.encoder {
|
||||
Encoder::Pcm16 => {
|
||||
let _md = Message::F(FragmentMetadata {
|
||||
length: _samples.len() as u64,
|
||||
length: _samples.len() as u64 * 2,
|
||||
});
|
||||
if s.write_all(rmp_serde::to_vec(&_md).unwrap().as_slice()).is_err() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Launching lonelyradio on the router moment
|
||||
if cfg!(target_endian = "big") {
|
||||
_samples.iter_mut().for_each(|sample| {
|
||||
*sample = sample.to_le();
|
||||
});
|
||||
}
|
||||
|
||||
// Sowwy about that
|
||||
let (_, samples, _) = unsafe { _samples.align_to::<u8>() };
|
||||
|
||||
if s.write_all(samples).is_err() {
|
||||
if s.write_all(
|
||||
&encode(Encoder::Pcm16, _samples, md.sample_rate, md.channels).unwrap(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
let encoded = flacenc::encode_with_fixed_block_size(
|
||||
&flacenc::config::Encoder::default().into_verified().unwrap(),
|
||||
MemSource::from_samples(
|
||||
// I'm crying (It's just a burning memory)
|
||||
&_samples.iter().map(|x| *x as i32).collect::<Vec<i32>>(),
|
||||
md.channels as usize,
|
||||
16,
|
||||
md.sample_rate as usize,
|
||||
),
|
||||
256,
|
||||
);
|
||||
if encoded.is_err() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut sink = flacenc::bitsink::ByteSink::new();
|
||||
encoded.unwrap().write(&mut sink).unwrap();
|
||||
|
||||
Encoder::PcmFloat => {
|
||||
let _md = Message::F(FragmentMetadata {
|
||||
length: sink.as_slice().len() as u64,
|
||||
length: _samples.len() as u64 * 4,
|
||||
});
|
||||
if s.write_all(rmp_serde::to_vec(&_md).unwrap().as_slice()).is_err() {
|
||||
return true;
|
||||
}
|
||||
if s.write_all(sink.as_slice()).is_err() {
|
||||
if s.write_all(
|
||||
&encode(Encoder::PcmFloat, _samples, md.sample_rate, md.channels).unwrap(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Encoder::Flac => {
|
||||
let encoded = encode(Encoder::Flac, _samples, md.sample_rate, md.channels).unwrap();
|
||||
let _md = Message::F(FragmentMetadata {
|
||||
length: encoded.as_slice().len() as u64,
|
||||
});
|
||||
if s.write_all(rmp_serde::to_vec(&_md).unwrap().as_slice()).is_err() {
|
||||
return true;
|
||||
}
|
||||
if s.write_all(encoded.as_slice()).is_err() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
@ -160,9 +146,9 @@ async fn stream_track(
|
|||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
let listener = TcpListener::bind(Args::parse().address).await.unwrap();
|
||||
let listener = TcpListener::bind(args.address).await.unwrap();
|
||||
let tracklist = Arc::new(
|
||||
walkdir::WalkDir::new(Args::parse().dir)
|
||||
walkdir::WalkDir::new(args.dir)
|
||||
.into_iter()
|
||||
.filter_entry(is_not_hidden)
|
||||
.filter_map(|v| v.ok())
|
||||
|
@ -172,25 +158,36 @@ async fn main() {
|
|||
);
|
||||
loop {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let s = socket.into_std().unwrap();
|
||||
let mut s = socket.into_std().unwrap();
|
||||
s.set_nonblocking(false).unwrap();
|
||||
let s = if args.xor_key_file.is_some() {
|
||||
Writer::XorEncrypted(
|
||||
s,
|
||||
match &*KEY {
|
||||
Some(a) => a.clone(),
|
||||
_ => {
|
||||
unreachable!()
|
||||
let mut hello = [0u8; 8];
|
||||
if s.read_exact(&mut hello).is_err() {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
0,
|
||||
if hello != lonelyradio_types::HELLO_MAGIC.to_le_bytes() {
|
||||
continue;
|
||||
}
|
||||
if s.write_all(
|
||||
&rmp_serde::to_vec_named(&ServerCapabilities {
|
||||
encoders: SUPPORTED_ENCODERS.to_vec(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
} else {
|
||||
Writer::Unencrypted(s)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
tokio::spawn(stream(s, tracklist.clone()));
|
||||
let settings: Settings = match rmp_serde::from_read(&s) {
|
||||
Ok(s) => s,
|
||||
_ => continue,
|
||||
};
|
||||
if settings.cover < -1 {
|
||||
continue;
|
||||
}
|
||||
tokio::spawn(stream(s, tracklist.clone(), settings));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_not_hidden(entry: &DirEntry) -> bool {
|
||||
entry.file_name().to_str().map(|s| entry.depth() == 0 || !s.starts_with('.')).unwrap_or(false)
|
||||
}
|
||||
|
@ -207,7 +204,7 @@ fn track_valid(track: &Path) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
|
||||
async fn stream(mut s: TcpStream, tracklist: Arc<Vec<PathBuf>>, settings: Settings) {
|
||||
let args = Args::parse();
|
||||
|
||||
loop {
|
||||
|
@ -216,6 +213,7 @@ async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
|
|||
let mut title = String::new();
|
||||
let mut artist = String::new();
|
||||
let mut album = String::new();
|
||||
let mut cover = std::thread::spawn(|| None);
|
||||
let mut file = std::fs::File::open(&track).unwrap();
|
||||
let tagged = match lofty::read_from(&mut file) {
|
||||
Ok(f) => f,
|
||||
|
@ -224,12 +222,33 @@ async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
|
|||
if let Some(id3v2) = tagged.primary_tag() {
|
||||
title =
|
||||
id3v2.title().unwrap_or(track.file_stem().unwrap().to_string_lossy()).to_string();
|
||||
album = id3v2.album().unwrap_or("[No tag]".into()).to_string();
|
||||
artist = id3v2.artist().unwrap_or("[No tag]".into()).to_string();
|
||||
album = id3v2.album().unwrap_or("".into()).to_string();
|
||||
artist = id3v2.artist().unwrap_or("".into()).to_string();
|
||||
if !(id3v2.pictures().is_empty() || args.artwork == -1 || settings.cover == -1) {
|
||||
let pic = id3v2.pictures()[0].clone();
|
||||
cover = std::thread::spawn(move || {
|
||||
let dec = ImageReader::new(Cursor::new(pic.into_data()))
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.decode()
|
||||
.ok()?;
|
||||
let mut img = Vec::new();
|
||||
if args.artwork != 0 && settings.cover != 0 {
|
||||
let size = std::cmp::min(args.artwork as u32, settings.cover as u32);
|
||||
dec.resize(size, size, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
dec
|
||||
}
|
||||
.to_rgb8()
|
||||
.write_to(&mut Cursor::new(&mut img), image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
Some(img)
|
||||
});
|
||||
};
|
||||
};
|
||||
let track_message = format!("{} - {} - {}", &artist, &album, &title);
|
||||
eprintln!(
|
||||
"[{}] {} to {}:{}{}",
|
||||
"[{}] {} to {}:{}{} ({:?})",
|
||||
Local::now().to_rfc3339(),
|
||||
track_message,
|
||||
s.peer_addr().unwrap().ip(),
|
||||
|
@ -238,7 +257,8 @@ async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
|
|||
" with WAR.rs"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
},
|
||||
settings.encoder
|
||||
);
|
||||
|
||||
if args.public_log {
|
||||
|
@ -262,7 +282,8 @@ async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
|
|||
TrackMetadata {
|
||||
track_length_frac: time.frac as f32,
|
||||
track_length_secs: time.seconds,
|
||||
flac: args.flac,
|
||||
encoder: settings.encoder,
|
||||
cover: cover.join().unwrap(),
|
||||
album,
|
||||
artist,
|
||||
title,
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
use std::{
|
||||
borrow::BorrowMut,
|
||||
io,
|
||||
net::{SocketAddr, TcpStream},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub(crate) enum Writer {
|
||||
Unencrypted(TcpStream),
|
||||
XorEncrypted(TcpStream, Arc<Vec<u8>>, u64),
|
||||
}
|
||||
|
||||
impl io::Write for Writer {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Self::Unencrypted(s) => s.write(buf),
|
||||
Self::XorEncrypted(s, key, n) => {
|
||||
for mut k in buf.iter().copied() {
|
||||
k ^= key[*n as usize];
|
||||
*n += 1;
|
||||
*n %= key.len() as u64;
|
||||
s.write_all(&[k])?;
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||
match self {
|
||||
Self::Unencrypted(s) => s.write_all(buf),
|
||||
Self::XorEncrypted(s, key, n) => s.write_all(
|
||||
&buf.iter()
|
||||
.borrow_mut()
|
||||
.copied()
|
||||
.map(|mut k| {
|
||||
k ^= key[*n as usize];
|
||||
*n += 1;
|
||||
*n %= key.len() as u64;
|
||||
k
|
||||
})
|
||||
// I don't like it
|
||||
.collect::<Vec<u8>>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
Self::XorEncrypted(s, _, _) | Self::Unencrypted(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
pub fn peer_addr(&self) -> io::Result<SocketAddr> {
|
||||
match self {
|
||||
Self::XorEncrypted(s, _, _) | Self::Unencrypted(s) => s.peer_addr(),
|
||||
}
|
||||
}
|
||||
}
|