Compare commits

..

14 commits

Author SHA1 Message Date
0bfd885c70
0.7.1: Sea codec, Slint client redesign
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2025-02-21 17:57:43 +03:00
80cef97ca2
Clamp samples before encoding flac
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-09-01 14:42:14 +03:00
60791dc292
Fix monoclient-s playlist requests
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-09-01 11:00:22 +03:00
5d9179e5df
Enable CI builds of lonelyradio, monoclient and monoclient-s
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-09-01 10:59:26 +03:00
5794359202
monoclient-s: fix lonelyradio_types export
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-29 08:47:08 +03:00
bc3a43e870
Dockerize lonelyradio
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-27 13:20:53 +03:00
3804653512
Encode PCM the same way as any other encoder
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-27 12:38:50 +03:00
dfb86522fb
Add basic protocol documentation
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-27 12:31:27 +03:00
d43dcde7a2
Fix monolib documentation
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-26 20:37:30 +03:00
29338f32e3
0.7.0: Playlists, Vorbis, ALAC and more
- XSPF playlists support
- Modernization of error handling and server-client communications
- Vorbis and ALAC transcoding
- All transcoders are now optional but enabled on default
- lonelyradio_types crate is now exported through monolib

Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-08-26 20:18:06 +03:00
adf4b7bb1c
Bump lonelyradio and monoclient to 0.6.1
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-07-13 22:23:43 +03:00
eaa7262d37
monoclient: fix clippy issues
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-07-13 22:20:57 +03:00
7e8f797533
lonelyradio: Fix extension validation
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-07-13 22:19:26 +03:00
ec55bd2b1e
0.6.0: artwork support, new SwiftUI client, new protocol iteration
Drop of the "XOR encryption"

Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
2024-07-12 23:33:51 +03:00
97 changed files with 5982 additions and 3187 deletions

32
.dockerignore Normal file
View file

@ -0,0 +1,32 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/secrets.dev.yaml
**/values.dev.yaml
/bin
/target
LICENSE
README.md

70
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: Release
permissions:
contents: write
on:
push:
tags:
- "[0-9]+.*"
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/create-gh-release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
upload-assets:
needs: create-release
strategy:
matrix:
include:
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
- target: riscv64gc-unknown-linux-gnu
os: ubuntu-latest
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install cross-compilation tools
uses: taiki-e/setup-cross-toolchain-action@v1
with:
target: ${{ matrix.target }}
if: startsWith(matrix.os, 'ubuntu')
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: lonelyradio
target: ${{ matrix.target }}
token: ${{ secrets.GITHUB_TOKEN }}
profile: distribute
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: monoclient
target: ${{ matrix.target }}
token: ${{ secrets.GITHUB_TOKEN }}
profile: distribute
manifest-path: monoclient/Cargo.toml
if: startsWith(matrix.os , 'ubuntu') == false
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: monoclient-s
target: ${{ matrix.target }}
token: ${{ secrets.GITHUB_TOKEN }}
profile: distribute
manifest-path: monoclient-s/Cargo.toml
if: startsWith(matrix.os , 'ubuntu') == false

1
.gitignore vendored
View file

@ -50,7 +50,6 @@ fastlane/test_output
### SwiftPackageManager ###
Packages
xcuserdata
*.xcodeproj
### Xcode ###
# Xcode

3595
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,25 +5,22 @@ members = [
"monoclient-s",
"monolib",
"monoloader",
"microserve",
]
[package]
name = "lonelyradio"
description = "TCP radio for lonely ones"
version = "0.5.0"
version = "0.7.1"
edition = "2021"
license = "MIT"
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
repository = "https://github.com/ivabus/lonelyradio"
[dependencies]
lonelyradio_types = { version = "0.7.0", path = "./lonelyradio_types" }
rand = "0.8.5"
clap = { version = "4.4.18", features = ["derive"] }
tokio = { version = "1.35.1", features = [
"sync",
"fs",
"io-util",
"net",
"rt-multi-thread",
"rt",
@ -42,11 +39,29 @@ lofty = "0.18.2"
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 = { version = "0.25.1", default-features = false, features = ["png", "jpeg", "bmp"]}
xspf = "0.4.0"
url = "2.5.2"
samplerate = "0.2.4"
[profile.release]
# Optional encoders
flacenc = { version = "0.4.0", default-features = false, optional = true }
alac-encoder = { version = "0.3.0", optional = true }
vorbis_rs = {version = "0.5.4", optional = true }
sea-codec = { version = "0.5.2", optional = true }
[features]
default = ["all-lossless", "all-lossy"]
all-lossless = ["alac", "flac"]
all-lossy = ["vorbis", "sea"]
alac = ["dep:alac-encoder"]
flac = ["dep:flacenc"]
vorbis = ["dep:vorbis_rs"]
sea = ["dep:sea-codec"]
[profile.distribute]
inherits = "release"
opt-level = 3
strip = true
lto = "fat"

91
Dockerfile Normal file
View file

@ -0,0 +1,91 @@
# syntax=docker/dockerfile:1
# Autogenerated by docker init
# https://docs.docker.com/go/dockerfile-reference/
ARG RUST_VERSION=1.85
ARG APP_NAME=lonelyradio
################################################################################
# xx is a helper for cross-compilation.
# See https://github.com/tonistiigi/xx/ for more information.
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.3.0 AS xx
################################################################################
# Create a stage for building the application.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-alpine AS build
ARG APP_NAME
WORKDIR /app
# Copy cross compilation utilities from the xx stage.
COPY --from=xx / /
# Install host build dependencies.
RUN apk add --no-cache clang lld musl-dev git file cmake make
# This is the architecture youre building for, which is passed in by the builder.
# Placing it here allows the previous steps to be cached across architectures.
ARG TARGETPLATFORM
# Install cross compilation build dependencies.
RUN xx-apk add --no-cache musl-dev gcc
# Build the application.
# Leverage a cache mount to /usr/local/cargo/registry/
# for downloaded dependencies, a cache mount to /usr/local/cargo/git/db
# for git repository dependencies, and a cache mount to /app/target/ for
# compiled dependencies which will speed up subsequent builds.
# Leverage a bind mount to the src directory to avoid having to copy the
# source code into the container. Once built, copy the executable to an
# output directory before the cache mounted /app/target is unmounted.
RUN --mount=type=bind,source=src,target=src \
--mount=type=bind,source=lonelyradio_types,target=lonelyradio_types \
--mount=type=bind,source=monoclient,target=monoclient \
--mount=type=bind,source=monoclient-s,target=monoclient-s \
--mount=type=bind,source=monolib,target=monolib \
--mount=type=bind,source=monoloader,target=monoloader \
--mount=type=bind,source=Cargo.toml,target=Cargo.toml \
--mount=type=bind,source=Cargo.lock,target=Cargo.lock \
--mount=type=cache,target=/app/target/,id=rust-cache-${APP_NAME}-${TARGETPLATFORM} \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry/ \
<<EOF
set -e
xx-cargo build --locked --release --target-dir ./target
cp ./target/$(xx-cargo --print-target-triple)/release/$APP_NAME /bin/server
xx-verify /bin/server
EOF
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the build stage where the necessary files are copied from the build
# stage.
#
# The example below uses the alpine image as the foundation for running the app.
# By specifying the "3.18" tag, it will use version 3.18 of alpine. If
# reproducability is important, consider using a digest
# (e.g., alpine@sha256:664888ac9cfd28068e062c991ebcff4b4c7307dc8dd4df9e728bedde5c449d91).
FROM alpine:3.18 AS final
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
# Copy the executable from the "build" stage.
COPY --from=build /bin/server /bin/
# Expose the port that the application listens on.
EXPOSE 5894
# What the container should run when it is started.
CMD ["/bin/server", "/music"]

106
README.md
View file

@ -1,77 +1,99 @@
# lonelyradio
# lonelyradio Music Streamer
Broadcast audio over the internet.
Shuffles through your [XSPF playlists](https://www.xspf.org) or your entire library.
Decodes audio streams using [symphonia](https://github.com/pdeljanov/Symphonia).
Decodes audio streams using [symphonia](https://github.com/pdeljanov/Symphonia) (supported [decoders](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#codecs-decoders) and [demuxers](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#formats-demuxers))
Optionally transcodes audio into and from FLAC using [flacenc-rs](https://github.com/yotarok/flacenc-rs/) and [claxon](https://github.com/ruuda/claxon).
Streams music using [FLAC](https://crates.io/crates/flacenc), [ALAC](https://crates.io/crates/alac-encoder), [Vorbis](https://crates.io/crates/vorbis_rs), [Sea](https://github.com/Daninet/sea-codec) or raw PCM on clients requests.
## Installation
### Install music server
### Install server
```shell
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.5.0 lonelyradio
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.7.1 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
```
## Run
### 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.
All files (recursively) will be shuffled and played back. Log will be displayed to stdout.
`-m|--max-samplerate M` will resample tracks which samplerate exceeds M to M
Look into `--help` for detailed info
`--xor-key-file FILE` will XOR all outgoing bytes looping through FILE
#### Run in Docker
`-f|--flac` will enable (experimental) FLAC compression
```
docker run -d \
--name lonelyradio \
--restart=unless-stopped \
-v /path/to/music:/music \
-p 5894:5894 \
ivabuz/lonelyradio:latest
```
#### Playlists
Specify a directory with playlists with `--playlist-dir`. lonelyradio will scan them on startup and play them on clients requests.
Only the `<location>` and (playlist's) element would be used and only `file://` is supported.
### 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.7.1 monoclient-s
```
You may need to install some dependencies for Slint.
Desktop integration will be added later.
#### monoclient
[monoclient](./monoclient) is a CLI player for lonelyradio that uses [monolib](./monolib)
##### Install monoclient
```shell
cargo install --git https://github.com/ivabus/lonelyradio --tag 0.7.1 monoclient
```
#### Usage
```shell
monoclient <SERVER>:<PORT>
```
[monoclient-s](./monoclient-s) is a experimental GUI player for lonelyradio built with [Slint](https://slint.dev)
Look into `--help` for detailed info on usage.
```shell
monoclient-s
```
# Other things
Desktop integration will be added later.
### Other clients
SwiftUI client is availible in [platform](./platform) directory.
[monoloader](./monoloader) is a tool, that allows you to download individual audio tracks from lonelyradio-compatible servers.
[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.
[Protocol documentation] shortly describes the protocol used in lonelyradio. Please refer to monolib and verify custom clients with the reference lonelyradio server.
#### 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.
## License
lonelyradio, monolib and monoclient, as well as all other crates in this repository, are licensed under the terms of the [MIT license](./LICENSE).

65
docs/protocol.md Normal file
View file

@ -0,0 +1,65 @@
# lonelyradio protocol (0.7)
## Introduction
The lonelyradio protocol is designed to be minimal yet functional for music streaming purposes.
The lonelyradio protocol operates at the application layer, establishing communication between the server and client. In its reference implementation, it runs atop the TCP protocol, but it could also be implemented on top of any other transport protocol, such as UDP, WebSocket, and so on.
The lonelyradio protocol uses [MessagePack](https://msgpack.org) to encode messages. Structures used in communication are defined in the `lonelyradio_types` crate.
## Establishing connection
1. The client sends a «hello» packet («lonelyra», 8 bytes)
1. The server checks the hello packet
2. The server sends «ServerCapabilities» which informs the client about supported audio encoders (raw pcm s16le must be supported by all server implementations)
3. Then the client picks one of the requests:
1. Play (p) (see example 1.1)
2. ListPlaylist (lpl) (see example 1.2)
3. PlayPlayList (ppl) (see example 1.3)
4. The server responds with one of RequestResult
1. Ok -> The server begins sending PlayMessages
1. TrackMetadata indicates the start of the new track
2. FragmentMetadata indicates the start of a new fragment and defines the number of bytes in it
1. FragmentMetadata is always followed by a fragment
2. Playlist is only returned on ListPlaylist and shows available playlists
3. Error indicates an error
To get «next track» just reestablish the connection.
## Examples
Examples show JSON representation of MessagePack
### 1.1
```json
{
"p": { // e and co definicions could be found in lonelyradio_types crate
"e": "Pcm16",
"co": -1
}
}
```
### 1.2
Just string encoded to MessagePack
```json
"lpl"
```
### 1.3
```json
{
"ppl": [
"someplaylist",
{
"e": "Pcm16",
"co": -1
}
]
}
```

View file

@ -1,11 +1,13 @@
[package]
name = "lonelyradio_types"
edition = "2021"
version = "0.7.0"
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
description = "Shared types for lonelyradio"
license = "MIT"
version = "0.5.0"
edition = "2021"
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
repository = "https://github.com/ivabus/lonelyradio"
[dependencies]
serde = { version = "1.0.197", features = ["derive"] }
serde = {version = "1.0.209", features = ["derive"]}
serde_bytes = {version = "0.11.15"}

View file

@ -1,25 +1,140 @@
use std::fmt::Display;
use serde::{Deserialize, Serialize};
pub const HELLO_MAGIC: &[u8; 8] = b"lonelyra";
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub enum Message {
pub enum Request {
// Just play what server wants you to give
#[serde(rename = "p", alias = "Play")]
Play(Settings),
#[serde(rename = "lpl", alias = "ListPlaylist")]
ListPlaylist,
#[serde(rename = "ppl", alias = "PlayPlaylist")]
PlayPlaylist(String, Settings),
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct PlaylistResponce {
pub playlists: Vec<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub enum RequestResult {
Ok,
Playlist(PlaylistResponce),
Error(RequestError),
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub enum RequestError {
NoSuchPlaylist,
WrongCoverSize,
UnsupportedEncoder,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub enum PlayMessage {
T(TrackMetadata),
F(FragmentMetadata),
}
#[repr(C)]
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct Settings {
#[serde(rename = "e", alias = "encoder")]
pub encoder: Encoder,
#[serde(rename = "co", alias = "cover")]
pub cover: i32,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct ServerCapabilities {
#[serde(rename = "e")]
pub encoders: Vec<Encoder>,
// Will be used in the next updates
//#[serde(rename = "ar")]
//pub available_requests: Vec<Request>,
}
#[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",
skip_serializing_if = "Option::is_none",
with = "serde_bytes",
default = "none"
)]
pub cover: Option<Vec<u8>>,
pub id: u8,
}
// WavPack, Opus and Aac are currently unimplemented.
#[repr(u8)]
#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq)]
pub enum Encoder {
Pcm16 = 0,
PcmFloat = 1,
Flac = 2,
Alac = 3,
WavPack = 4,
Opus = 5,
Aac = 6,
Vorbis = 7,
Sea = 8,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct FragmentMetadata {
// In samples or bytes (if FLAC)
// In bytes
#[serde(rename = "le")]
pub length: u64,
#[serde(
rename = "mc",
skip_serializing_if = "Option::is_none",
with = "serde_bytes",
default = "none"
)]
pub magic_cookie: Option<Vec<u8>>,
}
fn none() -> Option<Vec<u8>> {
None
}
impl Display for Encoder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Pcm16 => "PCM s16",
Self::PcmFloat => "PCM f32",
Self::Flac => "FLAC",
Self::Alac => "ALAC",
Self::WavPack => "WavPack",
Self::Opus => "Opus",
Self::Aac => "AAC",
Self::Vorbis => "Vorbis",
Self::Sea => "Sea",
})
}
}

886
microserve/Cargo.lock generated
View file

@ -1,886 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "alsa"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37fe60779335388a88c01ac6c3be40304d1e349de3ada3b15f7808bb90fa9dce"
dependencies = [
"alsa-sys",
"bitflags 2.5.0",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "autocfg"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "bindgen"
version = "0.69.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0"
dependencies = [
"bitflags 2.5.0",
"cexpr",
"clang-sys",
"itertools",
"lazy_static",
"lazycell",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytes"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
[[package]]
name = "cc"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f"
dependencies = [
"jobserver",
"libc",
"once_cell",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clang-sys"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f01585027057ff5f0a5bf276174ae4c1594a2c5bde93d5f46a016d76270f5a9"
dependencies = [
"bindgen",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "either"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b"
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "glob"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "indexmap"
version = "2.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"log",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libloading"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19"
dependencies = [
"cfg-if",
"windows-targets 0.52.5",
]
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "lonelyradio_types"
version = "0.4.0"
dependencies = [
"serde",
]
[[package]]
name = "mach2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
[[package]]
name = "microserve"
version = "0.1.0"
dependencies = [
"cpal",
"lonelyradio_types",
]
[[package]]
name = "microserve-session"
version = "0.1.0"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.5.0",
"jni-sys",
"log",
"ndk-sys",
"num_enum",
"thiserror",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02339744ee7253741199f897151b38e72257d13802d4ee837285cc2990a90845"
dependencies = [
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "proc-macro-crate"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.202"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.202"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ad3dee41f36859875573074334c200d1add8e4a87bb37113ebd31d926b7b11f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml_datetime"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
[[package]]
name = "toml_edit"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1"
dependencies = [
"indexmap",
"toml_datetime",
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core",
"windows-targets 0.52.5",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.5",
]
[[package]]
name = "windows-result"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "749f0da9cc72d82e600d8d2e44cadd0b9eedb9038f71a1c58556ac1c5791813b"
dependencies = [
"windows-targets 0.52.5",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.5",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
dependencies = [
"windows_aarch64_gnullvm 0.52.5",
"windows_aarch64_msvc 0.52.5",
"windows_i686_gnu 0.52.5",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.5",
"windows_x86_64_gnu 0.52.5",
"windows_x86_64_gnullvm 0.52.5",
"windows_x86_64_msvc 0.52.5",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]

View file

@ -1,24 +0,0 @@
[package]
name = "microserve"
version = "0.5.0"
license = "MIT"
edition = "2021"
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
repository = "https://github.com/ivabus/lonelyradio"
[dependencies]
cpal = "0.15.3"
lonelyradio_types = { path = "../lonelyradio_types" }
once_cell = "1.19.0"
queues = "1.1.0"
rmp-serde = "1.3.0"
tokio = { version = "1.35.1", features = [
"sync",
"fs",
"io-util",
"net",
"rt-multi-thread",
"rt",
"macros",
] }

View file

@ -1,95 +0,0 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use lonelyradio_types::{FragmentMetadata, TrackMetadata};
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::io::Write;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::time::Duration;
use tokio::net::TcpListener;
static QUEUE: Lazy<Arc<RwLock<VecDeque<Vec<i16>>>>> =
Lazy::new(|| Arc::new(RwLock::new(VecDeque::new())));
static START_INDEX: Mutex<usize> = Mutex::new(0);
#[tokio::main]
async fn main() {
tokio::spawn(listen_mic());
println!("Started buffering");
let listener = TcpListener::bind("0.0.0.0:5894").await.unwrap();
std::thread::sleep(Duration::from_secs(5));
tokio::spawn(update_start());
println!("Accepting connections");
loop {
let (socket, _) = listener.accept().await.unwrap();
let socket = socket.into_std().unwrap();
tokio::spawn(stream(socket));
}
}
async fn update_start() {
loop {
std::thread::sleep(Duration::from_secs(1));
*START_INDEX.lock().unwrap() = QUEUE.read().unwrap().len() - 5;
}
}
async fn stream(mut s: std::net::TcpStream) {
println!("Playing for {}", s.peer_addr().unwrap());
let md = lonelyradio_types::Message::T(TrackMetadata {
flac: false,
track_length_secs: 0,
track_length_frac: 0.0,
channels: 1,
sample_rate: 44100,
title: "microserve instance".to_string(),
album: "".to_string(),
artist: "".to_string(),
});
s.write_all(rmp_serde::to_vec(&md).unwrap().as_slice()).unwrap();
let mut ind = *START_INDEX.lock().unwrap();
dbg!(ind);
loop {
let front = QUEUE.read().unwrap()[ind].clone();
ind += 1;
let md = lonelyradio_types::Message::F(FragmentMetadata {
length: front.len() as u64,
});
s.write_all(rmp_serde::to_vec(&md).unwrap().as_slice()).unwrap();
if s.write_all(unsafe { front.as_slice().align_to::<u8>().1 }).is_err() {
return;
};
while ind >= QUEUE.read().unwrap().len() - 5 {
std::thread::sleep(Duration::from_millis(100))
}
}
}
async fn listen_mic() {
let host = cpal::default_host();
let device = host.default_input_device().unwrap();
let config = device.default_input_config().unwrap();
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
move |data: &[f32], _: &_| {
let samples = data.iter().map(|x| (*x * 32767.0) as i16).collect();
QUEUE.write().unwrap().push_back(samples);
},
|e| eprintln!("Error while reading: {}", e),
None,
),
_ => {
unimplemented!()
}
}
.unwrap();
loop {
stream.play().unwrap();
std::thread::sleep(Duration::from_millis(100));
}
}

View file

@ -1,19 +1,22 @@
[package]
name = "monoclient-s"
description = "Client for lonelyradio built with Slint"
version = "0.5.0"
version = "0.7.0"
edition = "2021"
build = "build.rs"
[dependencies]
slint = { version = "1.6.0", features = ["backend-android-activity-06"] }
monolib = { path = "../monolib" }
slint = { version = "1.8" }
monolib = { path = "../monolib", version = "0.7.1" }
zune-jpeg = "0.4.13"
[build-dependencies]
slint-build = "1.8"
# 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.7.1"
copyright = "Copyright (c) 2024 Ivan Bushchik."
category = "Music"

3
monoclient-s/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/ui.slint").unwrap();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,111 +1,33 @@
use std::time::Duration;
use monolib::lonelyradio_types;
use monolib::State;
use slint::Weak;
use slint::{
Image, ModelRc, Rgb8Pixel, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel, 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;
}
}
}
}
}
slint::include_modules!();
#[allow(dead_code)]
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));
let playlist = window.get_selected_playlist();
let encoder = monolib::SUPPORTED_DECODERS[window.get_selected_encoder() as usize];
let handle = std::thread::spawn(move || {
monolib::run(
&addr,
lonelyradio_types::Settings {
encoder,
cover: 2048,
},
if playlist == "All tracks" {
""
} else {
&playlist
},
)
});
std::thread::sleep(Duration::from_millis(166));
if handle.is_finished() {
window.set_playing(false);
@ -124,25 +46,48 @@ pub fn main() {
window.on_text_edited(move || {
let window = window_weak.upgrade().unwrap();
let addr = window.get_addr().to_string();
window.set_start_enabled(addr.contains(':'));
if addr.contains(':') {
window.set_start_enabled(true);
} else {
window.set_start_enabled(false);
}
});
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()
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 = window_weak.upgrade().unwrap();
let playlists = match monolib::list_playlists(&window.get_addr()) {
Some(v) => [vec!["All tracks".to_string()], v].concat(),
None => vec!["All tracks".to_string()],
};
window.set_playlists(ModelRc::new(VecModel::from(
playlists.iter().map(SharedString::from).collect::<Vec<_>>(),
)));
});
window.set_supported_encoders(ModelRc::new(VecModel::from(
monolib::SUPPORTED_DECODERS
.iter()
.map(|x| x.to_string())
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
let window_weak = window.as_weak();
window.on_next(move || {
monolib::stop();
@ -155,9 +100,11 @@ pub fn main() {
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();
@ -166,6 +113,26 @@ pub fn main() {
}
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());

View file

@ -0,0 +1,20 @@
MIT License
Copyright (c) 2025 Ivan Bushchik
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,5 @@
# Monoclient Icons Set
Copyright (c) 2024 Ivan Bushchik
License: [MIT](./LICENSE)

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="eject.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.674439"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1041"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;stroke-width:0"
id="rect2"
width="7.5"
height="1.5"
x="1.25"
y="7.8000002"
rx="0.75"
ry="0.75" />
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-0.017735456"
inkscape:transform-center-y="-1.8312623"
transform="matrix(2.0174137,0,0,2.0174137,1.4570468,2.1451198)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="first.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="14.983016"
inkscape:cx="18.621084"
inkscape:cy="16.618817"
inkscape:window-width="1712"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g2"
transform="matrix(-1,0,0,1,9.9524816,0.592)">
<g
id="g1">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-opacity:1"
id="path1"
sodipodi:type="arc"
sodipodi:cx="5.2266302"
sodipodi:cy="5.0154533"
sodipodi:rx="3"
sodipodi:ry="3"
sodipodi:start="1.5707963"
sodipodi:end="4.712389"
sodipodi:arc-type="arc"
d="M 5.2266302,8.0154533 A 3,3 0 0 1 2.628554,6.5154534 a 3,3 0 0 1 0,-3.0000001 3,3 0 0 1 2.5980762,-1.5"
sodipodi:open="true" />
</g>
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-0.9156312"
inkscape:transform-center-y="0.008867619"
transform="matrix(0,1.0087068,-1.0087069,0,7.6059217,0.2785234)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1;stroke-opacity:1"
id="rect1"
width="2"
height="1"
x="4.7523584"
y="7.5159998"
rx="0.5"
ry="0.5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="gear.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.74641"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:1"
id="path1"
inkscape:flatsided="false"
sodipodi:sides="7"
sodipodi:cx="2.2279439"
sodipodi:cy="0.64743674"
sodipodi:r1="5.8304453"
sodipodi:r2="4.3145294"
sodipodi:arg1="0.77846987"
sodipodi:arg2="1.2272688"
inkscape:rounded="0.68"
inkscape:randomized="0"
d="M 6.3791557,4.7415218 C 5.0907843,6.0478706 5.4087092,4.0919025 3.6811232,4.7098786 1.953537,5.3278548 3.4399287,6.6383994 1.6152975,6.4456051 -0.20933355,6.2528109 1.5181269,5.2818485 -0.04215863,4.3164691 -1.6024442,3.3510897 -1.7003191,5.3303087 -2.6872255,3.7835494 -3.6741317,2.2367901 -1.8379488,2.9819879 -2.0560069,1.1602034 -2.274065,-0.66158125 -3.8825048,0.49591998 -3.2885257,-1.2400632 c 0.5939791,-1.735983 1.1562014,0.1642259 2.44457285,-1.1421229 1.28837157,-1.3063488 -0.61944515,-1.8421875 1.10814107,-2.4601637 1.72758608,-0.6179761 0.59248281,1.0063478 2.41711388,1.199142 1.8246312,0.1927943 1.0540625,-1.6328868 2.6143481,-0.6675074 1.5602855,0.9653794 -0.4173874,1.0906693 0.5695189,2.6374286 0.9869063,1.54675933 1.9338395,-0.1939891 2.1518976,1.62779553 0.2180582,1.82178457 -1.1129563,0.3536946 -1.7069354,2.08967757 -0.5939791,1.7359832 1.357396,1.3909864 0.069024,2.6973353 z"
inkscape:transform-center-x="0.47254043"
inkscape:transform-center-y="-0.49742099"
transform="matrix(0.6544318,0,0,0.6544318,3.4877435,4.5192689)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="last.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="14.983016"
inkscape:cx="18.621084"
inkscape:cy="16.618817"
inkscape:window-width="1712"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g2"
transform="translate(0.04706873,0.592)">
<g
id="g1">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-opacity:1"
id="path1"
sodipodi:type="arc"
sodipodi:cx="5.2266302"
sodipodi:cy="5.0154533"
sodipodi:rx="3"
sodipodi:ry="3"
sodipodi:start="1.5707963"
sodipodi:end="4.712389"
sodipodi:arc-type="arc"
d="M 5.2266302,8.0154533 A 3,3 0 0 1 2.628554,6.5154534 a 3,3 0 0 1 0,-3.0000001 3,3 0 0 1 2.5980762,-1.5"
sodipodi:open="true" />
</g>
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-0.9156312"
inkscape:transform-center-y="0.008867619"
transform="matrix(0,1.0087068,-1.0087069,0,7.6059217,0.2785234)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1;stroke-opacity:1"
id="rect1"
width="2"
height="1"
x="4.7523584"
y="7.5159998"
rx="0.5"
ry="0.5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="next.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.674439"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1041"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;stroke-width:0"
id="rect2"
width="7.5"
height="1.5"
x="1.25"
y="-9"
rx="0.75"
ry="0.75"
transform="rotate(90)" />
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-1.8312623"
inkscape:transform-center-y="0.017735426"
transform="matrix(0,2.0174137,-2.0174137,0,6.2118431,1.4570468)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="pause.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.674439"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;stroke-width:0"
id="rect2"
width="1.5"
height="7.5"
x="2.75"
y="1.25"
rx="0.75"
ry="0.75" />
<rect
style="fill:#000000;stroke-width:0"
id="rect2-9"
width="1.5"
height="7.5"
x="5.75"
y="1.25"
rx="0.75"
ry="0.75" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="play.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="8.3757958"
inkscape:cx="29.489735"
inkscape:cy="38.324717"
inkscape:window-width="1536"
inkscape:window-height="1041"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path2"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="3.4656906"
sodipodi:cy="3.2943101"
sodipodi:r1="3.8732622"
sodipodi:r2="3.8401258"
sodipodi:arg1="0.92139558"
sodipodi:arg2="1.9685931"
inkscape:rounded="0.04"
inkscape:randomized="0"
d="M 5.807888,6.3791555 C 5.5941637,6.5414277 -0.3432929,4.0465167 -0.37696257,3.7802899 -0.41063223,3.5140631 4.7187523,-0.38046969 4.9661464,-0.27651507 5.2135404,-0.17256045 6.0216124,6.2168833 5.807888,6.3791555 Z"
transform="matrix(0.55578837,-0.76724136,0.73199924,0.58254681,0.05250644,5.7392433)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="previous.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.674439"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;stroke-width:0"
id="rect2"
width="7.5"
height="1.5"
x="1.25"
y="-2.5"
rx="0.75"
ry="0.75"
transform="rotate(90)" />
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="1.8312623"
transform="matrix(0,-2.0174137,2.0174137,0,3.7881198,8.5429532)"
inkscape:transform-center-y="-0.017735441" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="random.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="12.005348"
inkscape:cx="13.993764"
inkscape:cy="26.987973"
inkscape:window-width="1536"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="10 : 5 : 1"
inkscape:persp3d-origin="5 : 3.3333333 : 1"
id="perspective1" />
</defs>
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g6"
transform="translate(-0.41149989)">
<rect
style="fill:#000000;stroke-width:0"
id="rect6"
width="10"
height="1"
x="2.0706227"
y="-0.50035352"
rx="0.5"
ry="0.5"
transform="rotate(45)" />
<rect
style="fill:#000000;stroke-width:0"
id="rect6-7"
width="10"
height="1"
x="-5"
y="6.5702691"
rx="0.5"
ry="0.5"
transform="rotate(-45)" />
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-0.0861241"
inkscape:transform-center-y="-0.087606847"
transform="matrix(0.63138587,0.63138587,-0.63138587,0.63138587,8.0200992,-0.32204334)" />
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3-6"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="-0.087607003"
inkscape:transform-center-y="0.086124089"
transform="matrix(-0.63138587,0.63138587,-0.63138587,-0.63138587,10.22831,8.1200992)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
sodipodi:docname="repeat-one.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.1285977"
inkscape:cx="9.4638289"
inkscape:cy="10.116507"
inkscape:window-width="1536"
inkscape:window-height="1041"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="0.75466106"
inkscape:transform-center-y="-0.0073088053"
transform="matrix(0,-0.83137379,0.83137379,0,3.7731711,3.93391)" />
<path
style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
id="path1"
sodipodi:type="arc"
sodipodi:cx="5.1258287"
sodipodi:cy="5.2289081"
sodipodi:rx="3"
sodipodi:ry="3"
sodipodi:start="4.712389"
sodipodi:end="3.8397244"
sodipodi:arc-type="arc"
d="M 5.1258287,2.2289081 A 3,3 0 0 1 8.0547168,4.5795893 3,3 0 0 1 6.3936834,7.9478315 3,3 0 0 1 2.7457687,7.0551923 3,3 0 0 1 2.8276955,3.3005451"
sodipodi:open="true" />
<g
id="g1"
transform="translate(-0.33961362,-0.08264361)">
<rect
style="fill:#000000;stroke-width:0.7;stroke-dasharray:none"
id="rect1"
width="0.5"
height="3"
x="5.347394"
y="4.0818858"
rx="0.25"
ry="0.25" />
<rect
style="fill:#000000;stroke-width:0.7;stroke-dasharray:none"
id="rect1-2"
width="0.5"
height="1.5"
x="6.767818"
y="-1.1417361"
rx="0.25"
ry="0.25"
transform="rotate(45)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
sodipodi:docname="repeat.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="21.325417"
inkscape:cx="18.897637"
inkscape:cy="18.897637"
inkscape:window-width="1536"
inkscape:window-height="1041"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#000000;stroke-width:0"
id="path3"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="1.7518876"
sodipodi:cy="1.4281693"
sodipodi:r1="1.9958065"
sodipodi:r2="3.9916129"
sodipodi:arg1="0.51914611"
sodipodi:arg2="1.5663437"
inkscape:rounded="0.07"
inkscape:randomized="0"
d="M 3.4847328,2.4183665 C 3.3646778,2.6284629 0.14985015,2.6427775 0.02792892,2.4337586 -0.09399232,2.2247398 1.5010247,-0.56653997 1.7430009,-0.56761742 1.9849772,-0.56869487 3.6047879,2.2082702 3.4847328,2.4183665 Z"
inkscape:transform-center-x="0.75466106"
inkscape:transform-center-y="-0.0073088053"
transform="matrix(0,-0.83137379,0.83137379,0,3.7731711,3.93391)" />
<path
style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:0.9;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
id="path1"
sodipodi:type="arc"
sodipodi:cx="5.1258287"
sodipodi:cy="5.2289081"
sodipodi:rx="3"
sodipodi:ry="3"
sodipodi:start="4.712389"
sodipodi:end="3.8397244"
sodipodi:arc-type="arc"
d="M 5.1258287,2.2289081 A 3,3 0 0 1 8.0547168,4.5795893 3,3 0 0 1 6.3936834,7.9478315 3,3 0 0 1 2.7457687,7.0551923 3,3 0 0 1 2.8276955,3.3005451"
sodipodi:open="true" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="stop.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff00"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="13.894537"
inkscape:cx="13.710424"
inkscape:cy="19.288156"
inkscape:window-width="1536"
inkscape:window-height="1013"
inkscape:window-x="0"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;stroke-width:0"
id="rect2"
width="7.5"
height="7.5"
x="1.25"
y="1.25"
rx="0.75"
ry="0.75" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

286
monoclient-s/ui/ui.slint Normal file
View file

@ -0,0 +1,286 @@
import { Palette, Slider, ComboBox } from "std-widgets.slint";
component Button {
in-out property icon <=> img.source;
in property <bool> wanted;
in property <bool> enabled: false;
callback clicked;
VerticalLayout {
alignment: center;
spacing: 8px;
img := Image {
// What the actual fuck
x: 7px;
colorize: enabled ? Palette.foreground : Palette.foreground.darker(10%);
width: 16px;
height: 16px;
horizontal-alignment: center;
opacity: 1;
}
rect := Rectangle {
touch := TouchArea {
clicked => {
if enabled {
clicked()
}
}
}
border-radius: root.height / 2;
border-width: 1px;
border-color: Palette.border;
background: enabled ? (touch.pressed ? Palette.control-background.darker(45%) : (touch.has-hover ? Palette.control-background.darker(35%) : Palette.control-background)) : Palette.control-background.darker(35%);
animate background { duration: 166ms; }
drop-shadow-blur: wanted ? pow(abs(mod(animation-tick() / 1s - 3, 6) - 3) / 3, 2) * 128.0 * 1px : 0px;
drop-shadow-color: self.border-color;
width: 32px;
height: 32px;
}
}
}
export component MainWindow inherits Window {
callback play;
callback stop;
callback next;
callback refreshp;
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");
in property <[string]> playlists: ["All tracks"];
in property <[string]> supported_encoders: [];
in-out property <string> selected_playlist: selected.current-value;
in-out property <int> selected_encoder: encoder.current-index;
property <bool> settings: false;
title: "monoclient-s";
min-width: 448px;
max-width: 448px * 3;
preferred-width: 448px;
height: main.height;
animate background { duration: 166ms; }
panels := HorizontalLayout {
width: root.width * 2;
x: settings ? -root.width : 0.0;
height: main.height;
animate x {
easing: ease-in-out-expo;
duration: 0.5s;
}
main := HorizontalLayout {
width: root.width;
height: rect.height + self.padding * 2;
spacing: 16px;
padding: 16px;
rect := Rectangle {
opacity: playing ? 1.0 : 0.0;
animate opacity {
duration: 0.25s;
easing: ease-in-out;
}
clip: true;
border-radius: 6px;
animate background { duration: 166ms; }
background: Palette.foreground;
//width: 240px;
height: img.height + 12px * 2 + 1.5rem * 3;
max-width: parent.width;
border-width: 0px;
VerticalLayout {
img := Image {
vertical-alignment: top;
source: cover;
min-width: 240px;
height: self.width;
image-fit: contain;
}
VerticalLayout {
padding: 12px;
tartist := Text {
color: Palette.background;
vertical-alignment: center;
height: 1.5rem;
font-weight: 600;
text: martist;
overflow: elide;
}
talbum := Text {
color: Palette.background;
vertical-alignment: center;
height: 1.5rem;
text: malbum;
overflow: elide;
}
ttitle := Text {
color: Palette.background;
vertical-alignment: center;
height: 1.5rem;
text: mtitle;
overflow: elide;
}
}
}
}
VerticalLayout {
alignment: center;
//max-width: 160px;
spacing: 16px;
VerticalLayout {
spacing: 16px;
HorizontalLayout {
padding: 8px;
alignment: center;
spacing: 16px;
//height: 96px;
Button {
icon: @image-url("icons/stop.svg");
enabled: playing && !paused;
clicked => {
stop()
}
}
Button {
icon: playing ? (paused ? @image-url("icons/play.svg") : @image-url("icons/pause.svg")) : @image-url("icons/random.svg");
enabled: start_enabled || playing;
wanted: start_enabled && !playing;
clicked => {
play()
}
}
Button {
icon: @image-url("icons/next.svg");
enabled: playing && !paused;
clicked => {
next()
}
}
}
Rectangle {
padding: 16px;
clip: true;
background: Palette.background;
border-color: Palette.border;
animate background, border-color { duration: 166ms; }
border-width: 1px;
border-radius: 4px;
drop-shadow-blur: !start_enabled ? pow(abs(mod(animation-tick() / 1s - 3, 6) - 3) / 3, 2) * 128.0 * 1px : 0px;
drop-shadow-color: Palette.border;
VerticalLayout {
alignment: center;
padding: 8px;
address := TextInput {
text: "";
accepted => {
self.clear_focus()
}
edited => {
text_edited();
}
}
}
}
svolume := Slider {
value: 255;
maximum: 255;
changed(f) => {
change_volume(f)
}
}
HorizontalLayout {
alignment: center;
Button {
icon: @image-url("icons/gear.svg");
enabled: true;
wanted: false;
clicked => {
settings = !settings;
}
}
}
}
}
}
VerticalLayout {
width: root.width;
height: root.height;
alignment: center;
VerticalLayout {
padding: 20px;
spacing: 20px;
HorizontalLayout {
alignment: center;
spacing: 8px;
Text {
horizontal-alignment: right;
vertical-alignment: center;
text: "Playlists";
}
selected := ComboBox {
model: playlists;
current-index: 0;
selected() => {
self.clear_focus()
}
}
}
HorizontalLayout {
alignment: center;
spacing: 8px;
Text {
horizontal-alignment: right;
vertical-alignment: center;
text: "Encoder";
}
encoder := ComboBox {
model: supported_encoders;
selected() => {
self.clear_focus()
}
}
}
}
HorizontalLayout {
alignment: center;
Button {
icon: @image-url("icons/first.svg");
enabled: true;
wanted: false;
clicked => {
settings = !settings;
}
}
}
}
}
}

View 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 = 17.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.7.1;
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 = 17.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.7.1;
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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -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>
<true/>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

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

View file

@ -2,8 +2,8 @@
"colors" : [
{
"color" : {
"platform" : "ios",
"reference" : "systemPinkColor"
"platform" : "universal",
"reference" : "systemPurpleColor"
},
"idiom" : "universal"
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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

View 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, UInt(Int(cov.length)))
print(self.cover.size)
} else {
self.cover = PlatformImage()
}
}
}

View file

@ -0,0 +1,328 @@
//
// Player.swift
// monoclient-x
//
// Created by ivabus on 13.06.2024.
//
import AVFAudio
import MediaPlayer
import MonoLib
import SwiftUI
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
case Alac = 3
//WavPack = 4,
//Opus = 5,
//Aac = 6,
case Vorbis = 7
case Sea = 8
}
enum CoverSize: Int32 {
case Full = 0
case High = 768
case Medium = 512
case Low = 256
case Min = 128
case NoCover = -1
}
struct PlayList: Identifiable, Hashable {
var id: Int
var name: String
}
struct Settings {
var encoder: EncoderType = EncoderType.FLAC
var cover_size: CoverSize = CoverSize
.High /*
init(enc: EncoderType, cov: CoverSize) {
encoder = enc
cover_size = cov
}*/
}
#if os(tvOS)
typealias MyStack = HStack
#else
typealias MyStack = VStack
#endif
struct Player: View {
let timer_state = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
let timer_playlists = Timer.publish(every: 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()
@State var playlists: [PlayList] = [PlayList(id: 0, name: "All tracks")]
@State var playlist: PlayList = PlayList(id: 0, name: "All tracks")
@AppStorage("ContentView.server") var server: String = ""
var body: some View {
MyStack(alignment: .center) {
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).onReceive(timer_state) { _ in
metadata.update()
if prev_meta != metadata {
prev_meta = metadata
cover.update()
}
let image = cover.cover
let mediaArtwork = MPMediaItemArtwork(boundsSize: image.size) {
(size: CGSize) -> PlatformImage in
return image
}
#if os(macOS)
MPNowPlayingInfoCenter.default().playbackState =
state == PlayerState.Playing ? .playing : .paused
#endif
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
}
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)
}.onReceive(timer_state) { _ in
state.update()
}
Menu {
Picker("Playlist", selection: $playlist) {
ForEach($playlists) { pl in
Text(pl.wrappedValue.name).tag(pl.wrappedValue)
}
}.pickerStyle(.menu)
Picker("Encoder", selection: $settings.encoder) {
Text("PCM (s16)")
.tag(EncoderType.PCM16)
Text("PCM (f32)")
.tag(EncoderType.PCMFloat)
Text("FLAC (s24)")
.tag(EncoderType.FLAC)
Text("ALAC (s16)")
.tag(EncoderType.Alac)
Text("Vorbis (lossy)")
.tag(EncoderType.Vorbis)
Text("Sea (lossy)")
.tag(EncoderType.Sea)
}.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_playlists) { _ in
var id = -1
playlists =
(["All tracks"]
+ String(cString: c_list_playlists(server)).components(separatedBy: "\n")).map({
elem in
if elem.isEmpty {
return PlayList(id: -1, name: elem)
}
id += 1
return PlayList(id: id, name: elem)
}).filter({ elem in elem.id != -1 })
}
.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(macOS)
#else
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),
playlist.name == "All tracks" ? "" : playlist.name)
}
}
default:
do {
c_toggle()
state.update()
}
}
}
func stop() {
c_stop()
state.update()
cover = Cover(cover: PlatformImage())
}
func next() {
c_stop()
state.update()
play()
}
}

View 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>

View file

@ -0,0 +1,36 @@
//
// 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) {
}
}
.defaultSize(width: 256, height: 512)
#else
WindowGroup {
ContentView()
}
#endif
}
}

View file

@ -1,12 +1,12 @@
[package]
name = "monoclient"
license = "MIT"
version = "0.5.0"
version = "0.7.0"
edition = "2021"
authors = ["Ivan Bushchik <ivabus@ivabus.dev>"]
repository = "https://github.com/ivabus/lonelyradio"
[dependencies]
monolib = { version = "0.5.0", path = "../monolib" }
clap = { version = "4.4.18", features = ["derive"] }
crossterm = "0.27.0"
monolib = { version = "0.7.1", path = "../monolib" }
clap = { version = "4.5.16", features = ["derive"] }
crossterm = "0.28.1"

View file

@ -3,17 +3,26 @@ use crossterm::cursor::MoveToColumn;
use crossterm::event::{poll, read, Event};
use crossterm::style::Print;
use crossterm::terminal::{Clear, ClearType};
use monolib::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
address: String,
#[arg(long)]
xor_key_file: Option<PathBuf>,
#[arg(short, long)]
verbose: bool,
#[arg(short, long, default_value = "")]
playlist: String,
#[arg(short, long)]
list: bool,
}
const HELP: &str = r#"Keybinds:
@ -22,16 +31,42 @@ 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();
if args.list {
println!(
"Available playlists: {}",
match monolib::list_playlists(&args.address) {
Some(s) => format!("{:?}", s),
None => String::from("None"),
}
);
return;
}
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::Sea,
cover: -1,
},
&args.playlist,
)
});
while monolib::get_metadata().is_none() {}
let mut md = monolib::get_metadata().unwrap();
let mut next_md = md.clone();
verbose!("md: {:?}", md);
let mut track_start = Instant::now();
let mut seconds_past = 0;
crossterm::execute!(
@ -46,8 +81,6 @@ fn main() {
))
)
.unwrap();
let mut track_length = md.track_length_secs as f64 + md.track_length_frac as f64;
let mut next_md = md.clone();
crossterm::terminal::enable_raw_mode().unwrap();
loop {
if let Ok(true) = poll(std::time::Duration::from_micros(1)) {
@ -100,10 +133,11 @@ fn main() {
}
}
}
if monolib::get_metadata().unwrap() != md
&& track_length <= (Instant::now() - track_start).as_secs_f64()
if next_md != md
&& md.track_length_secs as f64 <= (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})",
@ -115,7 +149,6 @@ fn main() {
);
track_start = Instant::now();
seconds_past = 0;
track_length = md.track_length_secs as f64 + md.track_length_frac as f64
} else if next_md == md {
next_md = monolib::get_metadata().unwrap();
}

View file

@ -1,19 +1,43 @@
[package]
name = "monolib"
version = "0.5.0"
version = "0.7.1"
edition = "2021"
license = "MIT"
description = "A library implementing the lonely radio audio streaming protocol"
description = "A library implementing the lonelyradio audio streaming protocol"
repository = "https://github.com/ivabus/lonelyradio"
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 }
rodio = { version = "0.19.0", default-features = false }
byteorder = "1.5.0"
rmp-serde = "1.1.2"
lonelyradio_types = { version = "0.5.0", path = "../lonelyradio_types" }
claxon = "0.4.3"
lonelyradio_types = { version = "0.7.0", path = "../lonelyradio_types" }
anyhow = "1.0.86"
# Optional decoders
claxon = { version = "0.4.3", optional = true }
symphonia-codec-alac = {version = "0.5.4", optional = true }
symphonia-core = {version = "0.5.4", optional = true }
lewton = {version = "0.10.2", optional = true }
sea-codec = { version = "0.5.2", optional = true }
[features]
default = ["all-lossless", "all-lossy"]
all-lossless = ["alac", "flac"]
all-lossy = ["vorbis", "sea"]
alac = ["dep:symphonia-codec-alac", "dep:symphonia-core"]
flac = ["dep:claxon"]
vorbis = ["dep:lewton"]
sea = ["dep:sea-codec"]
[package.metadata.xcframework]
include-dir = "src"
lib-type = "cdylib"
zip = false
macOS = true
iOS = true
simulators = false

View file

@ -9,7 +9,8 @@ A library implementing the lonely radio audio streaming protocol
## Examples
- [CLI](../monoclient)
- [SwiftUI](../platform/swiftui)
- [SwiftUI](../monoclient-x)
- [Slint](../monoclient-s)
## License

12
monolib/cbindgen.toml Normal file
View file

@ -0,0 +1,12 @@
language = "C"
include_version = false
############################ Code Style Options ################################
braces = "SameLine"
line_length = 100
tab_width = 2
line_endings = "LF"

View file

@ -1,21 +1,63 @@
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,
}
#[repr(C)]
#[derive(Clone, Debug, PartialEq)]
pub struct CSettings {
/// See lonelyradio_types for numeric representation -> 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) {
/// Starts audio playback using rodio
/// Play without playlist => playlist = ""
pub extern "C" fn c_start(server: *const c_char, settings: CSettings, playlist: *const c_char) {
let serv = unsafe { CStr::from_ptr(server) };
let playlist = unsafe { CStr::from_ptr(playlist) };
run(
match serv.to_str() {
Ok(s) => s,
_ => "",
serv.to_str().unwrap_or_default(),
Settings {
encoder: match settings.encoder {
0 => Encoder::Pcm16,
1 => Encoder::PcmFloat,
2 => Encoder::Flac,
3 => Encoder::Alac,
7 => Encoder::Vorbis,
8 => Encoder::Sea,
_ => return,
},
cover: settings.cover,
},
None,
playlist.to_str().unwrap_or_default(),
)
}
#[no_mangle]
#[allow(clippy::not_unsafe_ptr_arg_deref)]
/// Playlists separated by '\n'
pub extern "C" fn c_list_playlists(server: *const c_char) -> *mut c_char {
let serv = unsafe { CStr::from_ptr(server) };
let playlists = list_playlists(serv.to_str().unwrap_or_default());
CString::new(match playlists {
None => "".to_string(),
Some(s) => s.join("\n"),
})
.unwrap()
.into_raw()
}
#[no_mangle]
pub extern "C" fn c_toggle() {
toggle()
@ -27,9 +69,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 +111,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());
}

97
monolib/src/decode.rs Normal file
View file

@ -0,0 +1,97 @@
use std::io::{Cursor, Read};
use byteorder::{LittleEndian, ReadBytesExt};
use lonelyradio_types::{Encoder, FragmentMetadata, TrackMetadata};
use symphonia_core::{
audio::SampleBuffer,
codecs::{Decoder, CODEC_TYPE_ALAC},
formats::Packet,
};
pub(crate) fn decode(
mut stream: impl ReadBytesExt,
md: &TrackMetadata,
fmd: &FragmentMetadata,
) -> anyhow::Result<Vec<f32>> {
let mut samples = vec![];
match md.encoder {
Encoder::Pcm16 => {
let mut samples_i16 = vec![0; fmd.length as usize / 2];
stream.read_i16_into::<LittleEndian>(&mut samples_i16)?;
samples.extend(samples_i16.iter().map(|sample| *sample as f32 / 32767.0));
}
Encoder::PcmFloat => {
let mut samples_f32 = vec![0f32; fmd.length as usize / 4];
stream.read_f32_into::<LittleEndian>(&mut samples_f32)?;
samples.append(&mut samples_f32);
}
Encoder::Flac => {
#[cfg(feature = "alac")]
{
let take = std::io::Read::by_ref(&mut stream).take(fmd.length);
let mut reader = claxon::FlacReader::new(take)?;
samples
.extend(&mut reader.samples().map(|x| x.unwrap_or(0) as f32 / 32768.0 / 256.0));
}
#[cfg(not(feature = "flac"))]
{
unimplemented!("flac decoding is disabled in library")
}
}
Encoder::Alac => {
#[cfg(feature = "alac")]
{
let mut buf = vec![];
std::io::Read::by_ref(&mut stream).take(fmd.length).read_to_end(&mut buf)?;
let mut reader = symphonia_codec_alac::AlacDecoder::try_new(
symphonia_core::codecs::CodecParameters::default()
.for_codec(CODEC_TYPE_ALAC)
.with_extra_data(fmd.magic_cookie.clone().unwrap().into_boxed_slice()),
&symphonia_core::codecs::DecoderOptions {
verify: false,
},
)?;
let decoded = reader.decode(&Packet::new_from_slice(0, 0, 0, &buf))?;
let mut byte_buf =
SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
byte_buf.copy_interleaved_ref(decoded);
samples.extend(byte_buf.samples());
}
#[cfg(not(feature = "alac"))]
{
unimplemented!("alac decoding is disabled in library")
}
}
Encoder::Vorbis => {
#[cfg(feature = "vorbis")]
{
let mut buf = vec![];
std::io::Read::by_ref(&mut stream).take(fmd.length).read_to_end(&mut buf)?;
let mut srr = lewton::inside_ogg::OggStreamReader::new(Cursor::new(buf))?;
while let Some(pck_samples) = srr.read_dec_packet_itl()? {
samples.extend(pck_samples.iter().map(|x| *x as f32 / 32768.0));
}
}
#[cfg(not(feature = "vorbis"))]
{
unimplemented!("vorbis decoding is disabled in library")
}
}
Encoder::Sea => {
#[cfg(feature = "sea")]
{
let mut buf = vec![];
std::io::Read::by_ref(&mut stream).take(fmd.length).read_to_end(&mut buf)?;
let dec = sea_codec::sea_decode(buf.as_slice());
samples.extend(dec.samples.iter().map(|x| *x as f32 / 32768.0));
}
#[cfg(not(feature = "sea"))]
{
unimplemented!("sea decoding is disabled in library")
}
}
Encoder::Aac | Encoder::Opus | Encoder::WavPack => unimplemented!(),
};
Ok(samples)
}

View file

@ -5,8 +5,9 @@
//! extern crate monolib;
//! use std::thread::{sleep, spawn};
//! use std::time::Duration;
//! use monolib::lonelyradio_types::{Settings, Encoder};
//!
//! spawn(|| monolib::run("someserver:someport"));
//! spawn(|| monolib::run("someserver:someport", Settings {encoder: Encoder::Flac, cover: -1}, "my_playlist"));
//! while monolib::get_metadata().is_none() {}
//! let seconds = md.length / md.sample_rate as u64 / 2;
//! println!("Playing: {} - {} - {} ({}:{:02})", md.artist, md.album, md.title, seconds / 60, seconds % 60);
@ -16,20 +17,39 @@
/// Functions, providing C-like API
pub mod c;
mod reader;
use byteorder::{LittleEndian, ReadBytesExt};
use lonelyradio_types::{Message, TrackMetadata};
pub use lonelyradio_types;
use anyhow::{bail, Context};
use decode::decode;
use lonelyradio_types::{
Encoder, PlayMessage, Request, RequestResult, ServerCapabilities, Settings, TrackMetadata,
};
use rodio::buffer::SamplesBuffer;
use rodio::{OutputStream, Sink};
use std::error::Error;
use std::io::{BufReader, Read};
use std::io::Write;
use std::net::TcpStream;
use std::sync::atomic::AtomicU8;
use std::sync::RwLock;
use std::time::Instant;
const CACHE_SIZE: usize = 128;
mod decode;
const CACHE_SIZE_PCM: usize = 32;
const CACHE_SIZE_COMPRESSED: usize = 4;
pub const SUPPORTED_DECODERS: &[Encoder] = &[
#[cfg(feature = "flac")]
Encoder::Flac,
#[cfg(feature = "alac")]
Encoder::Alac,
#[cfg(feature = "vorbis")]
Encoder::Vorbis,
#[cfg(feature = "sea")]
Encoder::Sea,
Encoder::PcmFloat,
Encoder::Pcm16,
];
static SINK: RwLock<Option<Sink>> = RwLock::new(None);
static VOLUME: AtomicU8 = AtomicU8::new(255);
@ -76,7 +96,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,133 +164,171 @@ 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,
playlist: &str,
) -> anyhow::Result<(TrackMetadata, Vec<f32>)> {
let mut connection = TcpStream::connect(server)?;
connection.write_all(lonelyradio_types::HELLO_MAGIC)?;
let capabilities: ServerCapabilities = rmp_serde::from_read(&mut connection)?;
if !capabilities.encoders.contains(&settings.encoder) {
settings.encoder = Encoder::Pcm16
}
let request = if playlist.is_empty() {
Request::Play(settings)
} else {
Request::PlayPlaylist(playlist.to_string(), settings)
};
connection.write_all(&rmp_serde::to_vec_named(&request).unwrap())?;
let response: RequestResult = rmp_serde::from_read(&connection)?;
if let RequestResult::Error(e) = response {
bail!("{e:?}")
}
let mut samples = vec![];
let mut md: Option<TrackMetadata> = None;
loop {
let recv_md: Message = rmp_serde::from_read(&mut stream).expect("Failed to parse message");
let recv_md: PlayMessage = rmp_serde::from_read(&mut connection)?;
match recv_md {
Message::T(tmd) => {
PlayMessage::T(tmd) => {
if md.is_some() {
break;
}
md = Some(tmd);
}
Message::F(fmd) => {
if !md.clone().unwrap().flac {
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);
let mut reader = claxon::FlacReader::new(take).unwrap();
samples.append(
&mut reader.samples().map(|x| x.unwrap_or(0) as i16).collect::<Vec<i16>>(),
);
}
PlayMessage::F(fmd) => {
samples.extend(decode(&mut connection, md.as_ref().unwrap(), &fmd)?)
}
}
}
md.map(|md| (md, samples))
if let Some(md) = md {
Ok((md, samples))
} else {
bail!("No metadata")
}
}
fn unwrap<T, E: Error>(thing: Result<T, E>) -> T {
if thing.is_err() {
*STATE.write().unwrap() = State::NotStarted;
pub fn list_playlists(server: &str) -> Option<Vec<String>> {
let mut connection = TcpStream::connect(server).ok()?;
connection.write_all(lonelyradio_types::HELLO_MAGIC).ok()?;
let _: ServerCapabilities = rmp_serde::from_read(&mut connection).ok()?;
connection.write_all(&rmp_serde::to_vec_named(&Request::ListPlaylist).ok()?).ok()?;
let res: RequestResult = rmp_serde::from_read(connection).ok()?;
match res {
RequestResult::Playlist(plist) => Some(plist.playlists),
_ => None,
}
thing.unwrap()
}
/// Starts playing at "server:port"
pub fn run(server: &str, xor_key: Option<Vec<u8>>) {
pub fn run(server: &str, settings: Settings, playlist: &str) {
let result = _run(server, settings, playlist);
if let Err(e) = result {
println!("{:?}", e);
*STATE.write().unwrap() = State::NotStarted;
}
}
pub(crate) fn _run(server: &str, mut settings: Settings, playlist: &str) -> anyhow::Result<()> {
if !SUPPORTED_DECODERS.contains(&settings.encoder) {
eprintln!(
"monolib was built without support for {:?}, falling back to Pcm16",
settings.encoder
);
settings.encoder = Encoder::Pcm16
}
let mut state = STATE.write().unwrap();
if *state == State::Playing || *state == State::Paused {
return;
return Ok(());
}
*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 = TcpStream::connect(server).context("failed to connect to the server")?;
connection.write_all(lonelyradio_types::HELLO_MAGIC)?;
let capabilities: ServerCapabilities = rmp_serde::from_read(&mut connection)?;
if !capabilities.encoders.contains(&settings.encoder) {
settings.encoder = Encoder::Pcm16
}
let request = if playlist.is_empty() {
Request::Play(settings)
} else {
Request::PlayPlaylist(playlist.to_string(), settings)
};
connection.write_all(&rmp_serde::to_vec_named(&request).unwrap())?;
let response: RequestResult = rmp_serde::from_read(&connection).unwrap();
if let RequestResult::Error(e) = response {
bail!("{:?}", e)
}
let mut stream = connection;
let mut sink = SINK.write().unwrap();
let (_stream, stream_handle) = unwrap(OutputStream::try_default());
let (_stream, stream_handle) =
OutputStream::try_default().context("failed to determine audio device")?;
// Can't reuse old sink for some reason
let audio_sink = Sink::try_new(&stream_handle).unwrap();
let audio_sink = Sink::try_new(&stream_handle).context("failed to create audio sink")?;
*sink = Some(audio_sink);
drop(sink);
let mut samples = Vec::with_capacity(8192);
loop {
let recv_md: Message = rmp_serde::from_read(&mut stream).expect("Failed to parse message");
let recv_md: PlayMessage =
rmp_serde::from_read(&mut stream).expect("Failed to parse message");
match recv_md {
Message::T(tmd) => {
PlayMessage::T(tmd) => {
// No metadata shift
if watching_sleep_until_end() {
_stop();
return;
return Ok(());
}
let mut md = MD.write().unwrap();
*md = Some(tmd.clone());
drop(md);
}
Message::F(fmd) => {
PlayMessage::F(fmd) => {
while *STATE.read().unwrap() == State::Paused {
std::thread::sleep(std::time::Duration::from_secs_f32(0.25))
}
if *STATE.read().unwrap() == State::Resetting {
_stop();
return;
}
if !MD.read().unwrap().clone().unwrap().flac {
let mut samples_i16 = vec![0; fmd.length as usize];
if stream.read_i16_into::<LittleEndian>(&mut samples_i16).is_err() {
return;
};
samples.append(
&mut samples_i16.iter().map(|sample| *sample as f32 / 32767.0).collect(),
);
} else {
let take = stream.by_ref().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)
.collect::<Vec<f32>>(),
);
return Ok(());
}
// 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
samples.extend(decode(&mut stream, &MD.read().unwrap().clone().unwrap(), &fmd)?);
// 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::Pcm16
&& md.encoder == Encoder::PcmFloat)
|| (sink.len() >= CACHE_SIZE_COMPRESSED
&& md.encoder != Encoder::Pcm16
&& md.encoder != Encoder::PcmFloat)
{
// 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 Ok(());
}
}
sink.append(SamplesBuffer::new(

View file

@ -0,0 +1,5 @@
framework module MonoLib {
// a header file in the same directory as the modulemap
header "monolib.h"
export *
}

View file

@ -3,13 +3,35 @@
#include <stdint.h>
#include <stdlib.h>
void c_start(const char *server);
typedef struct CSettings {
/**
* See lonelyradio_types for numeric representation -> Encoder
*/
uint8_t encoder;
int32_t cover;
} CSettings;
typedef struct CImageJpeg {
uint32_t length;
uint8_t *bytes;
} CImageJpeg;
/**
* Starts audio playback using rodio
* Play without playlist => playlist = ""
*/
void c_start(const char *server, struct CSettings settings, const char *playlist);
/**
* Playlists separated by '\n'
*/
char *c_list_playlists(const char *server);
void c_toggle(void);
void c_stop(void);
unsigned short c_get_state(void);
char c_get_state(void);
char *c_get_metadata_artist(void);
@ -17,4 +39,16 @@ char *c_get_metadata_album(void);
char *c_get_metadata_title(void);
float *c_get_metadata_length(void);
float c_get_metadata_length(void);
/**
* # Safety
* Manually deallocate returned memory after use
*/
struct CImageJpeg c_get_cover_jpeg(void);
/**
* # Safety
* None
*/
void c_drop(uint8_t *ptr, uintptr_t count);

View file

@ -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/target Symbolic link
View file

@ -0,0 +1 @@
../target

View file

@ -1,11 +1,11 @@
[package]
name = "monoloader"
version = "0.4.0"
version = "0.7.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
monolib = { path = "../monolib" }
monolib = { version = "0.7.1", path = "../monolib" }
clap = { version = "4.4.18", features = ["derive"] }
hound = "3.5.1"

View file

@ -1,4 +1,5 @@
use clap::Parser;
use monolib::lonelyradio_types::Settings;
use std::path::PathBuf;
#[derive(Parser)]
@ -8,31 +9,53 @@ struct Args {
#[arg(long)]
xor_key_file: Option<PathBuf>,
#[arg(short, long, default_value = "")]
playlist: String,
#[arg(short, long)]
list: bool,
}
fn main() {
let args = Args::parse();
if args.list {
println!(
"Available playlists: {}",
match monolib::list_playlists(&args.address) {
Some(s) => format!("{:?}", s),
None => String::from("None"),
}
);
return;
}
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: monolib::lonelyradio_types::Encoder::Flac,
cover: -1,
},
&args.playlist,
)
.unwrap();
println!(
"Downloaded: {} - {} - {} ({} MB)",
"Downloaded: {} - {} - {} ({:?}, {} MiB)",
md.artist,
md.album,
md.title,
samples.len() as f32 * 2.0 / 1024.0 / 1024.0
md.encoder,
samples.len() as f32 / 256.0 / 1024.0
);
let spec = hound::WavSpec {
channels: md.channels,
sample_rate: md.sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let mut writer =
hound::WavWriter::create(format!("{} - {}.wav", md.artist, md.title), spec).unwrap();
let mut writer_i16 = writer.get_i16_writer(samples.len() as u32);
samples.iter().for_each(|s| writer_i16.write_sample(*s));
writer_i16.flush().unwrap();
samples.iter().for_each(|s| writer.write_sample(*s).unwrap());
writer.flush().unwrap();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

View file

@ -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 */

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View file

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

View file

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

View file

@ -11,7 +11,7 @@ use symphonia::core::units::Time;
use crate::Args;
pub async fn get_meta(file_path: &Path) -> (u16, u32, Time) {
pub fn get_meta(file_path: &Path, encoder_wants: u32) -> (u16, u32, Time) {
let file = Box::new(std::fs::File::open(file_path).unwrap());
let mut hint = Hint::new();
hint.with_extension(file_path.extension().unwrap().to_str().unwrap());
@ -41,12 +41,7 @@ pub async fn get_meta(file_path: &Path) -> (u16, u32, Time) {
let mut sample_rate = 0;
let track_length =
track.codec_params.time_base.unwrap().calc_time(track.codec_params.n_frames.unwrap());
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
_ => break,
};
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
@ -67,17 +62,24 @@ pub async fn get_meta(file_path: &Path) -> (u16, u32, Time) {
(
channels,
if sample_rate > args.max_samplerate {
args.max_samplerate
} else {
if args.no_resampling && encoder_wants == 0 {
sample_rate
} else {
get_resampling_rate(
&sample_rate,
&if encoder_wants != 0 {
args.max_samplerate.min(encoder_wants)
} else {
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, encoder_wants: u32) -> 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();
@ -101,54 +103,46 @@ pub fn decode_file_stream(file_path: PathBuf) -> impl Stream<Item = Vec<i16>> {
.expect("no supported audio tracks");
let mut decoder = symphonia::default::get_codecs()
.make(track.codec_params.clone().with_max_frames_per_packet(65536), &Default::default())
.make(&track.codec_params, &Default::default())
.expect("unsupported codec");
let track_id = track.id;
stream! {
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
_ => break,
};
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
if decoded.spec().rate > args.max_samplerate {
let output_rate = get_resampling_rate(&decoded.spec().rate, &if encoder_wants != 0 {
args.max_samplerate.min(encoder_wants)
} else {
args.max_samplerate
});
if decoded.spec().rate > output_rate && (!args.no_resampling || encoder_wants != 0) {
let spec = *decoded.spec();
let mut byte_buf =
SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
byte_buf.copy_interleaved_ref(decoded);
let output_rate = get_resampling_rate(&spec.rate, &args.max_samplerate);
// About Samplerate struct:
// 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()
} else {
samplerate::convert(
spec.rate,
args.max_samplerate,
spec.channels.count(),
samplerate::ConverterType::Linear,
byte_buf.samples(),
)
.unwrap()
.iter()
.map(|x| (*x * 32768.0) as i16)
.collect()
}
);
yield (if output_rate == spec.rate {
byte_buf.samples().to_vec()
} else {
samplerate::convert(
spec.rate,
output_rate,
spec.channels.count(),
samplerate::ConverterType::Linear,
byte_buf.samples(),
)
.unwrap()
});
} 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;
}

158
src/encode.rs Normal file
View file

@ -0,0 +1,158 @@
use lonelyradio_types::Encoder;
// Return: 0 - encoded bytes, 1 - magic cookie (for alac only)
#[allow(unused_variables)]
pub fn encode(
codec: Encoder,
mut samples: Vec<f32>,
sample_rate: u32,
channels: u16,
) -> Option<(Vec<u8>, Option<Vec<u8>>)> {
match codec {
Encoder::Pcm16 => {
#[allow(unused_mut)]
let mut samples = samples.iter_mut().map(|x| (*x * 32768.0) as i16).collect::<Vec<_>>();
// Launching lonelyradio on the router moment
#[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(), None))
}
Encoder::PcmFloat => {
// Sowwy about that
let (_, samples, _) = unsafe { samples.align_to::<u8>() };
Some((samples.to_vec(), None))
}
Encoder::Flac => {
#[cfg(feature = "flac")]
{
use flacenc::{component::BitRepr, error::Verify, source::MemSource};
let encoded = flacenc::encode_with_fixed_block_size(
&flacenc::config::Encoder::default().into_verified().unwrap(),
MemSource::from_samples(
&samples
.iter()
.map(|x| (x.clamp(-1.0, 1.0) 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(), None))
}
#[cfg(not(feature = "flac"))]
{
unimplemented!()
}
}
Encoder::Alac => {
#[cfg(feature = "alac")]
{
use alac_encoder::{AlacEncoder, FormatDescription};
let samples = samples.iter_mut().map(|x| (*x * 32768.0) as i16).collect::<Vec<_>>();
let (_, samples, _) = unsafe { samples.align_to::<u8>() };
let input_format =
FormatDescription::pcm::<i16>(sample_rate as f64, channels as u32);
let output_format = FormatDescription::alac(
sample_rate as f64,
samples.len() as u32,
channels as u32,
);
// Initialize the encoder
let mut encoder = AlacEncoder::new(&output_format);
// Allocate a buffer for the encoder to write chunks to.
let mut output = vec![0u8; output_format.max_packet_size()];
let size = encoder.encode(&input_format, samples, &mut output);
// Here you can do whatever you want with the result:
Some((Vec::from(&output[0..size]), Some(encoder.magic_cookie())))
}
#[cfg(not(feature = "alac"))]
{
unimplemented!()
}
}
Encoder::Vorbis => {
#[cfg(feature = "vorbis")]
{
use std::num::{NonZeroU32, NonZeroU8};
let out: Vec<u8> = vec![];
let mut encoder = vorbis_rs::VorbisEncoderBuilder::new(
NonZeroU32::new(sample_rate).unwrap(),
NonZeroU8::new(channels as u8).unwrap(),
out,
)
.unwrap()
.bitrate_management_strategy(
vorbis_rs::VorbisBitrateManagementStrategy::ConstrainedAbr {
// I will think about clients asking about bitrate later, now it's just
// "enough" 128 kib/s
maximum_bitrate: NonZeroU32::new(192 * 1024).unwrap(),
},
)
.build()
.unwrap();
let mut samples_channels = vec![];
for i in 0..channels as usize {
samples_channels.push(
samples[i..]
.iter()
.step_by(channels as usize)
.copied()
.collect::<Vec<f32>>(),
);
}
encoder.encode_audio_block(samples_channels).unwrap();
Some((encoder.finish().unwrap(), None))
}
#[cfg(not(feature = "vorbis"))]
{
unimplemented!()
}
}
Encoder::Sea => {
#[cfg(feature = "sea")]
{
Some((
sea_codec::sea_encode(
samples
.iter_mut()
.map(|x| (*x * 32768.0) as i16)
.collect::<Vec<_>>()
.as_slice(),
sample_rate,
channels as u32,
sea_codec::encoder::EncoderSettings {
residual_bits: 5.0,
..Default::default()
},
),
None,
))
}
#[cfg(not(feature = "sea"))]
{
unimplemented!()
}
}
Encoder::Aac | Encoder::Opus | Encoder::WavPack => unimplemented!(),
}
}

View file

@ -1,32 +1,40 @@
mod decode;
mod writer;
mod encode;
use std::collections::HashMap;
use std::io::Cursor;
use std::io::Read;
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::ImageReader;
use image::RgbImage;
use lofty::Accessor;
use lofty::TaggedFileExt;
use lonelyradio_types::{FragmentMetadata, Message, TrackMetadata};
use once_cell::sync::Lazy;
use lonelyradio_types::Encoder;
use lonelyradio_types::Request;
use lonelyradio_types::RequestResult;
use lonelyradio_types::ServerCapabilities;
use lonelyradio_types::Settings;
use lonelyradio_types::{FragmentMetadata, PlayMessage, TrackMetadata};
use rand::prelude::*;
use std::io::Write;
use tokio::net::TcpListener;
use tokio_stream::Stream;
use url::Url;
use walkdir::DirEntry;
use writer::Writer;
use xspf::Playlist;
use crate::decode::decode_file_stream;
use crate::decode::get_meta;
#[derive(Parser)]
#[derive(Parser, Clone)]
struct Args {
/// Directory with audio files
dir: PathBuf,
@ -35,14 +43,6 @@ struct Args {
#[arg(short, default_value = "0.0.0.0:5894")]
address: String,
/// Enable "public" log (without sensitive information)
#[arg(short, long)]
public_log: bool,
/// Process all samples to -1 or 1
#[arg(short, long)]
war: bool,
/// Resample all tracks, which samplerate exceeds N
#[arg(short, long, default_value = "96000")]
max_samplerate: u32,
@ -51,118 +51,119 @@ struct Args {
#[arg(long)]
no_resampling: bool,
/// Use FLAC compression
#[arg(short, long)]
flac: bool,
/// Size of artwork (-1 for no artwork, 0 for original, N for NxN)
#[arg(long, default_value = "96000")]
artwork: i32,
/// Enable XOR "encryption"
#[arg(long)]
xor_key_file: Option<PathBuf>,
playlist_dir: Option<PathBuf>,
}
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] = &[
Encoder::Pcm16,
Encoder::PcmFloat,
#[cfg(feature = "flac")]
Encoder::Flac,
#[cfg(feature = "alac")]
Encoder::Alac,
#[cfg(feature = "vorbis")]
Encoder::Vorbis,
#[cfg(feature = "sea")]
Encoder::Sea,
];
async fn stream_track(
samples_stream: impl Stream<Item = Vec<i16>>,
war: bool,
samples_stream: impl Stream<Item = Vec<f32>>,
md: TrackMetadata,
s: &mut Writer,
mut s: impl Write,
) -> 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(&PlayMessage::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)
// Different codecs have different quality on different audio lenghts
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,
Encoder::Alac => 32,
Encoder::Vorbis => 64,
Encoder::Sea => 64,
Encoder::Aac | Encoder::Opus | Encoder::WavPack => unimplemented!(),
})
.next()
.await
{
let mut _samples = _samples.concat();
if war {
_samples.iter_mut().for_each(|sample| {
*sample = sample.signum() * 32767;
});
}
if !md.flac {
let _md = Message::F(FragmentMetadata {
length: _samples.len() as u64,
});
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();
match md.encoder {
Encoder::Pcm16
| Encoder::PcmFloat
| Encoder::Flac
| Encoder::Alac
| Encoder::Vorbis
| Encoder::Sea => {
let (encoded, magic_cookie) =
encode(md.encoder, _samples, md.sample_rate, md.channels).unwrap();
let _md = PlayMessage::F(FragmentMetadata {
length: encoded.as_slice().len() as u64,
magic_cookie,
});
if s.write_all(rmp_serde::to_vec_named(&_md).unwrap().as_slice()).is_err() {
return true;
}
if s.write_all(encoded.as_slice()).is_err() {
return true;
}
}
// Sowwy about that
let (_, samples, _) = unsafe { _samples.align_to::<u8>() };
if s.write_all(samples).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();
let _md = Message::F(FragmentMetadata {
length: sink.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(sink.as_slice()).is_err() {
return true;
}
Encoder::Aac | Encoder::Opus | Encoder::WavPack => unimplemented!(),
}
}
false
}
fn get_playlists(dir: impl AsRef<Path>) -> Option<HashMap<String, Arc<Vec<PathBuf>>>> {
let mut map: HashMap<String, Arc<Vec<PathBuf>>> = HashMap::new();
for playlist in walkdir::WalkDir::new(dir)
.into_iter()
.filter_entry(is_not_hidden)
.filter_map(|v| v.ok())
.map(|x| x.into_path())
.filter(|x| x.is_file())
{
let mut name = playlist.file_name().unwrap().to_str().unwrap().to_string();
let parsed = Playlist::read_file(playlist).unwrap();
if let Some(ref n) = parsed.title {
name = n.clone();
}
let tracklist = parsed
.track_list
.iter()
.flat_map(|x| x.location.iter().flat_map(|l| Url::parse(l.as_str()).ok()))
.filter(|x| x.scheme() == "file")
.map(|x| x.to_file_path().unwrap())
.filter(|x| track_valid(x))
.collect();
map.insert(name, Arc::new(tracklist));
}
Some(map)
}
#[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())
@ -170,99 +171,222 @@ async fn main() {
.filter(|x| track_valid(x))
.collect::<Vec<PathBuf>>(),
);
let playlists: Option<HashMap<String, Arc<Vec<PathBuf>>>> = match args.playlist_dir.as_ref() {
None => None,
Some(dir) => get_playlists(dir),
};
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!()
}
},
0,
)
} else {
Writer::Unencrypted(s)
let mut hello = [0u8; 8];
if s.read_exact(&mut hello).is_err() {
continue;
}
if &hello != lonelyradio_types::HELLO_MAGIC {
continue;
}
if s.write_all(
&rmp_serde::to_vec_named(&ServerCapabilities {
encoders: SUPPORTED_ENCODERS.to_vec(),
})
.unwrap(),
)
.is_err()
{
continue;
};
tokio::spawn(stream(s, tracklist.clone()));
s.flush().unwrap();
let request: Request = match rmp_serde::from_read(&s) {
Ok(r) => r,
Err(_) => {
continue;
}
};
match request {
Request::Play(settings) => {
if s.write_all(&rmp_serde::to_vec_named(&check_settings(&settings)).unwrap())
.is_err()
{
continue;
}
tokio::spawn(stream(s, tracklist.clone(), settings));
}
Request::ListPlaylist => match playlists {
None => {
s.write_all(
&rmp_serde::to_vec_named(&RequestResult::Playlist(
lonelyradio_types::PlaylistResponce {
playlists: vec![],
},
))
.unwrap(),
)
.unwrap();
}
Some(ref playlists) => {
s.write_all(
&rmp_serde::to_vec_named(&RequestResult::Playlist(
lonelyradio_types::PlaylistResponce {
playlists: playlists.keys().cloned().collect(),
},
))
.unwrap(),
)
.unwrap();
}
},
Request::PlayPlaylist(playlist, settings) => {
if playlists.is_none() || playlists.as_ref().unwrap().get(&playlist).is_none() {
s.write_all(
&rmp_serde::to_vec_named(&RequestResult::Error(
lonelyradio_types::RequestError::NoSuchPlaylist,
))
.unwrap(),
)
.unwrap();
continue;
}
if s.write_all(&rmp_serde::to_vec_named(&check_settings(&settings)).unwrap())
.is_err()
{
continue;
}
let tracklist = playlists.as_ref().unwrap().get(&playlist).unwrap().clone();
tokio::spawn(stream(s, tracklist, settings));
}
}
}
}
fn check_settings(settings: &Settings) -> RequestResult {
if settings.cover < -1 {
return RequestResult::Error(lonelyradio_types::RequestError::WrongCoverSize);
}
if !SUPPORTED_ENCODERS.contains(&settings.encoder) {
return RequestResult::Error(lonelyradio_types::RequestError::UnsupportedEncoder);
}
RequestResult::Ok
}
fn is_not_hidden(entry: &DirEntry) -> bool {
entry.file_name().to_str().map(|s| entry.depth() == 0 || !s.starts_with('.')).unwrap_or(false)
}
fn track_valid(track: &Path) -> bool {
if !track.metadata().unwrap().is_file() {
if let Ok(meta) = track.metadata() {
if !meta.is_file() {
return false;
}
} else {
return false;
}
// Skipping "images" (covers)
if "jpgjpegpngwebp".contains(&track.extension().unwrap().to_str().unwrap().to_ascii_lowercase())
{
return false;
if let Some(ext) = track.extension() {
[
"aac", "mp1", "mp2", "mp3", "wav", "wave", "webm", "mkv", "mp4", "m4a", "m4p", "m4b",
"m4r", "m4v", "mov", "aiff", "aif", "aifc", "ogg", "ogv", "oga", "ogx", "ogm", "spx",
"opus", "caf", "flac",
]
.contains(&ext.to_str().unwrap())
} else {
false
}
true
}
async fn stream(mut s: Writer, tracklist: Arc<Vec<PathBuf>>) {
let args = Args::parse();
struct Metadata {
title: String,
album: String,
artist: String,
cover: Option<RgbImage>,
}
fn get_metadata(track: impl AsRef<Path>, args: &Args, settings: &Settings) -> Option<Metadata> {
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 = lofty::read_from(&mut file).ok()?;
if let Some(id3v2) = tagged.primary_tag() {
title = id3v2
.title()
.unwrap_or(track.as_ref().file_stem().unwrap().to_string_lossy())
.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();
let args = args.clone();
let settings = settings.clone();
cover = std::thread::spawn(move || {
let dec = ImageReader::new(Cursor::new(pic.into_data()))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let img = 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();
Some(img)
});
};
};
Some(Metadata {
title,
album,
artist,
cover: cover.join().unwrap(),
})
}
async fn stream(mut s: impl Write, tracklist: Arc<Vec<PathBuf>>, settings: Settings) {
let args = Args::parse();
let encoder_wants = match settings.encoder {
Encoder::Opus | Encoder::Vorbis | Encoder::Aac => 48000,
Encoder::Flac => 96000,
_ => 0,
};
loop {
let track = tracklist.choose(&mut thread_rng()).unwrap().clone();
let mut title = String::new();
let mut artist = String::new();
let mut album = String::new();
let mut file = std::fs::File::open(&track).unwrap();
let tagged = match lofty::read_from(&mut file) {
Ok(f) => f,
let Metadata {
title,
album,
artist,
cover,
} = match get_metadata(&track, &args, &settings) {
Some(m) => m,
_ => continue,
};
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();
};
let track_message = format!("{} - {} - {}", &artist, &album, &title);
eprintln!(
"[{}] {} to {}:{}{}",
Local::now().to_rfc3339(),
track_message,
s.peer_addr().unwrap().ip(),
s.peer_addr().unwrap().port(),
if args.war {
" with WAR.rs"
} else {
""
}
);
if args.public_log {
println!(
"[{}] {} to {}{}",
Local::now().to_rfc3339(),
track.to_str().unwrap(),
s.peer_addr().unwrap().port(),
if args.war {
" with WAR.rs"
} else {
""
}
);
}
let (channels, sample_rate, time) = get_meta(track.as_path()).await;
let stream = decode_file_stream(track);
let track_message = format!("{} - {} - {}", &artist, &album, &title);
println!("[{}] {} ({:?})", Local::now().to_rfc3339(), track_message, settings.encoder);
let (channels, sample_rate, time) = get_meta(track.as_path(), encoder_wants);
let stream = decode_file_stream(track, encoder_wants);
let id = thread_rng().gen();
if stream_track(
stream,
args.war,
TrackMetadata {
track_length_frac: time.frac as f32,
track_length_secs: time.seconds,
flac: args.flac,
encoder: settings.encoder,
cover: cover.map(|x| {
let mut buf = Cursor::new(Vec::new());
x.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
buf.into_inner()
}),
id,
album,
artist,
title,

View file

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