Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add rockbox-expo crate and rockbox-rpc module

+3728 -2
+109
CLAUDE.md
··· 259 259 bunx expo export --platform web # smoke-test the bundle (catches NativeWind transform issues) 260 260 ``` 261 261 262 + ### Native gRPC client — `crates/expo/` + `expo/modules/rockbox-rpc/` 263 + 264 + The mobile app talks to rockboxd through a native module that wraps a real 265 + tonic gRPC client written in Rust. It is split in two halves: 266 + 267 + **`crates/expo/`** — `rockbox-expo` Rust crate, `staticlib + cdylib`. 268 + - Generates client-only proto bindings in `build.rs` from the shared 269 + `crates/rpc/proto` tree (linked in via the `proto -> ../rpc/proto` symlink 270 + inside the crate so we don't duplicate `.proto` files). 271 + - Owns a single multi-thread Tokio runtime via `once_cell`. 272 + - Exposes a flat C ABI (`rb_set_server_url`, `rb_ping`, `rb_play`, `rb_pause`, 273 + `rb_play_pause`, `rb_next`, `rb_prev`, `rb_seek`, `rb_status_json`, 274 + `rb_current_track_json`, `rb_like_track`, `rb_unlike_track`, 275 + `rb_free_string`). Complex responses are returned as heap-allocated JSON 276 + C strings — caller MUST free via `rb_free_string`. Simple ops return `i32` 277 + status codes (0 = ok, <0 = error). 278 + - Deliberately does NOT depend on `rockbox-rpc` to avoid pulling sqlx / 279 + typesense / library transitive deps that fight cross-compilation. 280 + 281 + **`expo/modules/rockbox-rpc/`** — Expo SDK 54 native module. 282 + - `expo-module.config.json` declares iOS + Android module classes; the module 283 + is autolinked into the app via `expo/package.json` (`"rockbox-rpc": "file:./modules/rockbox-rpc"`). 284 + - iOS: `ios/RockboxRpcModule.swift` declares each `rb_*` symbol with 285 + `@_silgen_name(...)` and exposes them through `Function` / `AsyncFunction`. 286 + The static library is delivered as `ios/RockboxExpo.xcframework` (built by 287 + `scripts/build-ios.sh`); the `.podspec` `vendored_frameworks` it. 288 + - Android: `android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt` 289 + uses `System.loadLibrary("rockbox_expo")` + JNI `external fun` declarations. 290 + The `.so` per ABI is dropped into `android/src/main/jniLibs/<abi>/` by 291 + `scripts/build-android.sh` (uses `cargo-ndk`). 292 + - TS facade: `expo/modules/rockbox-rpc/src/index.ts` declares the JS surface; 293 + `expo/lib/rockbox-client.ts` is the in-app helper with an `isAvailable` 294 + flag so callers can fall back to the mock `PlayerProvider` on web or when 295 + the libs haven't been built yet. 296 + 297 + #### Building the native libs 298 + 299 + ```sh 300 + # iOS — produces expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework 301 + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 302 + cd expo/modules/rockbox-rpc 303 + bun run build:ios 304 + 305 + # Android — produces expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so 306 + cargo install cargo-ndk 307 + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 308 + export ANDROID_NDK_HOME=... # NDK r25+ 309 + bun run build:android 310 + ``` 311 + 312 + After the native libs are in place, run `bunx expo prebuild` and then 313 + `bunx expo run:ios` / `run:android` to bundle them into the app. 314 + 315 + #### Adding new RPCs 316 + 317 + 1. Add a thin wrapper in `crates/expo/src/lib.rs` (`rb_<name>` returning 318 + `c_int` for unit ops or `*mut c_char` for JSON-bearing reads). 319 + 2. Add the matching extern declaration in both 320 + `expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift` and 321 + `expo/modules/rockbox-rpc/android/src/main/java/.../RockboxRpcModule.kt`, 322 + plus an `AsyncFunction` binding. 323 + 3. Add the typed method to `expo/modules/rockbox-rpc/src/index.ts` and the 324 + forwarding helper in `expo/lib/rockbox-client.ts`. 325 + 4. Rebuild the native libs (`build:ios` / `build:android`) — `metro` doesn't 326 + pick up native changes automatically. 327 + 328 + #### Streaming subscriptions 329 + 330 + Server-streaming RPCs (`StreamStatus`, `StreamCurrentTrack`, `StreamPlaylist`) 331 + are exposed as JS events — not async iterators — to play nicely with React's 332 + render loop. The pipeline is: 333 + 334 + ``` 335 + tonic stream 336 + → tokio mpsc<String> (one queue per subscription, in crates/expo) 337 + → rb_poll_event(id, timeout_ms) -> *mut c_char 338 + → Swift dispatch_async / Kotlin Dispatchers.IO loop 339 + → sendEvent("rockbox.<topic>", payload) (Expo Modules EventEmitter) 340 + → RockboxRpc.addListener("rockbox.<topic>", cb) 341 + ``` 342 + 343 + Each `subscribe*` returns an opaque numeric subscription id; the JS facade in 344 + `expo/lib/rockbox-client.ts` wraps that with an `() => void` unsubscribe 345 + helper that removes both the event listener and the native subscription: 346 + 347 + ```ts 348 + const unsubscribe = RockboxClient.subscribeStatus( 349 + (s) => console.log("status", s.status), 350 + (e) => console.warn("stream error", e.error), 351 + ); 352 + // later: unsubscribe(); 353 + ``` 354 + 355 + Topics today: `rockbox.status`, `rockbox.currentTrack`, `rockbox.playlist`, 356 + `rockbox.library`, `rockbox.discovery` (LAN mDNS / Bonjour scan via the 357 + `rockbox-discovery` crate — emits one `DiscoveredService` per resolved peer), 358 + plus `rockbox.error` for stream failures (carries `subId`, `stream`, `error`). 359 + 360 + The `subscribeDiscovery` helper defaults to the `_rockbox._tcp.local.` 361 + service; pass any other Bonjour service name (e.g. `_googlecast._tcp.local.`) 362 + to scan for Chromecast / etc. Constants are also surfaced on the JS side via 363 + `RockboxClient.rockboxServiceName()` and `RockboxClient.chromecastServiceName()`. 364 + 365 + To add a new streamed RPC: add a `rb_subscribe_<name>` in `crates/expo/src/lib.rs` 366 + that follows the `spawn_stream(...)` pattern, declare the matching event topic 367 + in the iOS / Android `Events(...)` lists, register a `Function("subscribe<Name>")` 368 + in both modules, and add the typed `subscribe<Name>(cb, onError?)` helper to 369 + `expo/lib/rockbox-client.ts`. 370 + 262 371 ## Useful commands 263 372 264 373 ```sh
+15
Cargo.lock
··· 9265 9265 ] 9266 9266 9267 9267 [[package]] 9268 + name = "rockbox-expo" 9269 + version = "0.1.0" 9270 + dependencies = [ 9271 + "futures-util", 9272 + "once_cell", 9273 + "prost", 9274 + "rockbox-discovery", 9275 + "serde", 9276 + "serde_json", 9277 + "tokio", 9278 + "tonic", 9279 + "tonic-build", 9280 + ] 9281 + 9282 + [[package]] 9268 9283 name = "rockbox-graphql" 9269 9284 version = "0.1.0" 9270 9285 dependencies = [
+23
crates/expo/Cargo.toml
··· 1 + [package] 2 + name = "rockbox-expo" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "C-ABI gRPC client wrapper for embedding in the rockbox-zig Expo (React Native) mobile app" 6 + license = "LGPL-2.1" 7 + 8 + [lib] 9 + name = "rockbox_expo" 10 + crate-type = ["staticlib", "cdylib"] 11 + 12 + [dependencies] 13 + prost = "0.13" 14 + tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } 15 + tonic = { version = "0.12", default-features = false, features = ["transport", "codegen", "prost"] } 16 + once_cell = "1" 17 + serde = { version = "1", features = ["derive"] } 18 + serde_json = "1" 19 + futures-util = "0.3" 20 + rockbox-discovery = { path = "../discovery" } 21 + 22 + [build-dependencies] 23 + tonic-build = { version = "0.12", default-features = false, features = ["prost", "transport"] }
+136
crates/expo/README.md
··· 1 + # `rockbox-expo` — C-ABI gRPC client for the Expo mobile app 2 + 3 + This crate produces a `staticlib` + `cdylib` that wraps the rockboxd gRPC 4 + client (built with [`tonic`]) and exposes a flat C ABI suitable for embedding 5 + in iOS (`.xcframework`) and Android (`jniLibs/<abi>/librockbox_expo.so`) 6 + builds. The companion [Expo Modules wrapper](../../expo/modules/rockbox-rpc/) 7 + loads the resulting library and forwards calls through React Native. 8 + 9 + It is the **mobile counterpart** to the desktop client in [`gpui/`](../../gpui/); 10 + the surface mirrors `gpui/src/client.rs` 1:1 wherever it makes sense. 11 + 12 + ## Why a separate crate? 13 + 14 + The `rockbox-rpc` crate (which the rockboxd server uses) pulls in heavy 15 + dependencies — `sqlx`, `typesense`, `library`, `reqwest` with native TLS, 16 + `rocksky`, etc. — that are painful to cross-compile to iOS / Android. This 17 + crate keeps deps minimal: 18 + 19 + - `tonic` (transport + codegen + prost), client only 20 + - `tokio` runtime (multi-thread, 2 worker threads) 21 + - `prost`, `serde`, `serde_json`, `once_cell`, `futures-util` 22 + - `rockbox-discovery` for LAN mDNS / Bonjour scans 23 + 24 + Proto bindings are generated from `proto/` (a symlink to 25 + `../rpc/proto`) via [`tonic-build`] in `build.rs`, with 26 + `build_server(false)` and a `type_attribute(".", "#[derive(serde::Serialize)]")` 27 + configuration so any response can be JSON-serialized in one line. 28 + 29 + ## Layout 30 + 31 + ``` 32 + crates/expo/ 33 + ├── Cargo.toml staticlib + cdylib, slim deps 34 + ├── build.rs tonic-build (client only) + serde derive on every proto 35 + ├── proto -> ../rpc/proto shared with the rest of the workspace 36 + └── src/lib.rs runtime, FFI surface, subscriptions 37 + ``` 38 + 39 + ## ABI conventions 40 + 41 + - All entry points are prefixed `rb_*` and exported with `#[no_mangle]`. 42 + - Unit operations return `i32` — `0` on success, negative on error. 43 + - Reads return `*mut c_char` — heap-owned JSON. Caller **must** free via 44 + `rb_free_string`. Errors come back as `{ "error": "..." }` JSON objects; 45 + the platform glue checks for that key and throws. 46 + - Strings flow in as `*const c_char` (NUL-terminated UTF-8); collections 47 + flow in as JSON-array C strings to keep the FFI narrow. 48 + 49 + ## Surface map 50 + 51 + | Group | Examples | 52 + |-------|----------| 53 + | Init | `rb_set_server_url`, `rb_ping` | 54 + | Playback | `rb_play / pause / play_pause / next / prev`, `rb_seek`, `rb_play_album / play_artist_tracks / play_track / play_directory` | 55 + | Queue | `rb_jump_to_queue_position`, `rb_insert_tracks`, `rb_insert_track_next / last`, `rb_remove_from_queue`, `rb_shuffle_playlist`, `rb_get_playlist_current_json` | 56 + | Library | `rb_get_tracks_json`, `rb_get_artists_json`, `rb_get_album_json`, `rb_search_json`, `rb_like_track / unlike_track`, `rb_get_liked_tracks_json` | 57 + | Sound / Settings | `rb_adjust_volume`, `rb_sound_current_json`, `rb_save_shuffle / save_repeat`, `rb_get_global_settings_json`, `rb_get_global_status_json` | 58 + | Browse | `rb_tree_get_entries_json` | 59 + | Saved playlists | `rb_get_saved_playlists_json`, `rb_create_saved_playlist`, `rb_update_saved_playlist`, `rb_delete_saved_playlist`, `rb_add_track_to_playlist`, `rb_remove_track_from_playlist`, `rb_get_saved_playlist_tracks_json`, `rb_play_saved_playlist` | 60 + | Smart playlists | `rb_get_smart_playlists_json`, `rb_get_smart_playlist_tracks_json`, `rb_play_smart_playlist` | 61 + | Bluetooth | `rb_bluetooth_available`, `rb_get_bluetooth_devices_json`, `rb_connect_bluetooth`, `rb_disconnect_bluetooth` | 62 + | Server-streaming | `rb_subscribe_status`, `rb_subscribe_current_track`, `rb_subscribe_playlist`, `rb_subscribe_library`, `rb_subscribe_discovery(serviceName)` | 63 + | Stream pump | `rb_poll_event(subId, timeoutMs)`, `rb_unsubscribe(subId)` | 64 + | Discovery constants | `rb_rockbox_service_name`, `rb_chromecast_service_name` | 65 + | Memory | `rb_free_string` | 66 + 67 + ## Streaming subscriptions 68 + 69 + Server-streaming RPCs and the mDNS scan share one model: 70 + 71 + ```text 72 + tonic / mdns-sd stream 73 + → tokio mpsc<String> (one queue per subscription) 74 + → rb_poll_event(id, timeout_ms) -> *mut c_char 75 + → Swift dispatch_async / Kotlin Dispatchers.IO loop 76 + → sendEvent("rockbox.<topic>", payload) 77 + ``` 78 + 79 + `rb_subscribe_*` returns an opaque `i32` subscription id. Each event JSON is 80 + the prost message for the topic (e.g. `StatusResponse`, `CurrentTrackResponse`, 81 + `PlaylistResponse`) or a `DiscoveredService` snapshot for the mDNS topic. 82 + 83 + Topics: `rockbox.status`, `rockbox.currentTrack`, `rockbox.playlist`, 84 + `rockbox.library`, `rockbox.discovery`. Stream errors propagate as 85 + `{ "error": "..." }` payloads on the same channel; the platform glue 86 + re-emits them on `rockbox.error`. 87 + 88 + ## Building 89 + 90 + Host-only sanity check: 91 + 92 + ```sh 93 + cargo check -p rockbox-expo 94 + ``` 95 + 96 + Cross-compile for mobile (driven by the [Expo module's build scripts](../../expo/modules/rockbox-rpc/scripts/)): 97 + 98 + ```sh 99 + # iOS — produces ../../expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework 100 + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 101 + ( cd ../../expo/modules/rockbox-rpc && bun run build:ios ) 102 + 103 + # Android — produces ../../expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so 104 + cargo install cargo-ndk 105 + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 106 + export ANDROID_NDK_HOME=... # NDK r25+ 107 + ( cd ../../expo/modules/rockbox-rpc && bun run build:android ) 108 + ``` 109 + 110 + ## Adding a new RPC 111 + 112 + 1. Add a `rb_<name>` wrapper in `src/lib.rs`. For unit ops, use the 113 + `simple_call!` macro or write `run_unit(async move { ... })`. For reads, 114 + `unwrap_or_err_string(res.map(|r| r.into_inner()))` does the JSON wrap. 115 + 2. Add the matching extern + `Function` / `AsyncFunction` in both 116 + `expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift` and 117 + `.../RockboxRpcModule.kt`. 118 + 3. Add the typed signature to `expo/modules/rockbox-rpc/src/index.ts` and a 119 + one-line forwarder on `RockboxClient` in `expo/lib/rockbox-client.ts`. 120 + 4. Rebuild the native libs (`build:ios` / `build:android`); Metro doesn't 121 + pick up native changes automatically. 122 + 123 + For server-streaming RPCs, follow the `spawn_stream(...)` pattern, declare 124 + the matching event topic in `Events(...)` on both platforms, register a 125 + `Function("subscribe<Name>")` Function, and add a typed 126 + `subscribe<Name>(cb, onError?)` helper to `expo/lib/rockbox-client.ts`. 127 + 128 + ## Skipped vs. `gpui/src/client.rs` 129 + 130 + The HTTP-REST device endpoints (`fetch_devices`, `connect_device`, 131 + `disconnect_device`) are not gRPC and aren't covered by this crate. The 132 + `run_*_sync` driver loops are also not exposed — the JS side can call the 133 + underlying unary RPCs directly and orchestrate its own caching. 134 + 135 + [`tonic`]: https://docs.rs/tonic 136 + [`tonic-build`]: https://docs.rs/tonic-build
+36
crates/expo/build.rs
··· 1 + fn main() -> Result<(), Box<dyn std::error::Error>> { 2 + // Generate client-only bindings from the shared proto files. The local 3 + // `proto` directory is a symlink to `../rpc/proto` so we read through the 4 + // same path the rest of the workspace uses (`crates/rpc/proto`) without 5 + // duplicating files. We deliberately avoid depending on `rockbox-rpc` to 6 + // keep the mobile crate slim (no sqlx / typesense / library transitive 7 + // deps that fight cross-compilation). 8 + let protos = [ 9 + "proto/rockbox/v1alpha1/browse.proto", 10 + "proto/rockbox/v1alpha1/library.proto", 11 + "proto/rockbox/v1alpha1/metadata.proto", 12 + "proto/rockbox/v1alpha1/playback.proto", 13 + "proto/rockbox/v1alpha1/playlist.proto", 14 + "proto/rockbox/v1alpha1/settings.proto", 15 + "proto/rockbox/v1alpha1/sound.proto", 16 + "proto/rockbox/v1alpha1/system.proto", 17 + "proto/rockbox/v1alpha1/saved_playlist.proto", 18 + "proto/rockbox/v1alpha1/smart_playlist.proto", 19 + "proto/rockbox/v1alpha1/bluetooth.proto", 20 + ]; 21 + 22 + // Derive serde::Serialize on every generated proto type so we can ship 23 + // entire responses to JS as JSON in one line. (Deserialize is not derived 24 + // — we only serialize *out* of Rust today.) 25 + tonic_build::configure() 26 + .build_server(false) 27 + .build_client(true) 28 + .type_attribute(".", "#[derive(serde::Serialize)]") 29 + .compile_protos(&protos, &["proto"])?; 30 + 31 + for p in &protos { 32 + println!("cargo:rerun-if-changed={p}"); 33 + } 34 + 35 + Ok(()) 36 + }
+1
crates/expo/proto
··· 1 + ../rpc/proto
+1550
crates/expo/src/lib.rs
··· 1 + //! Rockbox mobile client — C-ABI wrapper around a tonic gRPC client. 2 + //! 3 + //! Designed to be linked into iOS (.xcframework) and Android (jniLibs) builds 4 + //! and called from Expo modules' Swift / Kotlin glue. 5 + //! 6 + //! ## ABI 7 + //! 8 + //! - All entry points return either an `i32` status code (0 = ok, <0 = error) 9 + //! or a `*mut c_char` heap-owned C string. Callers MUST free returned strings 10 + //! via `rb_free_string`. 11 + //! - Complex responses (status snapshot, current track, etc.) are returned as 12 + //! JSON to avoid struct marshaling pain across language boundaries. 13 + //! - All synchronous fn names are prefixed `rb_*` and block on the embedded 14 + //! Tokio runtime — call from a background thread on the platform side. 15 + 16 + use std::collections::HashMap; 17 + use std::ffi::{CStr, CString}; 18 + use std::os::raw::{c_char, c_int}; 19 + use std::sync::atomic::{AtomicI32, Ordering}; 20 + use std::sync::RwLock; 21 + use std::time::Duration; 22 + 23 + use futures_util::pin_mut; 24 + use futures_util::StreamExt; 25 + use once_cell::sync::Lazy; 26 + use serde::Serialize; 27 + use tokio::runtime::Runtime; 28 + use tokio::sync::mpsc; 29 + use tokio::task::AbortHandle; 30 + 31 + pub mod api { 32 + pub mod v1alpha1 { 33 + tonic::include_proto!("rockbox.v1alpha1"); 34 + } 35 + } 36 + 37 + use api::v1alpha1::{ 38 + bluetooth_service_client::BluetoothServiceClient, browse_service_client::BrowseServiceClient, 39 + library_service_client::LibraryServiceClient, playback_service_client::PlaybackServiceClient, 40 + playlist_service_client::PlaylistServiceClient, 41 + saved_playlist_service_client::SavedPlaylistServiceClient, 42 + settings_service_client::SettingsServiceClient, 43 + smart_playlist_service_client::SmartPlaylistServiceClient, 44 + sound_service_client::SoundServiceClient, system_service_client::SystemServiceClient, 45 + AddTracksToSavedPlaylistRequest, AdjustVolumeRequest, ConnectBluetoothDeviceRequest, 46 + CreateSavedPlaylistRequest, CurrentTrackRequest, DeleteSavedPlaylistRequest, 47 + DisconnectBluetoothDeviceRequest, FastForwardRewindRequest, GetAlbumRequest, GetArtistsRequest, 48 + GetBluetoothDevicesRequest, GetCurrentRequest, GetGlobalSettingsRequest, 49 + GetGlobalStatusRequest, GetLikedTracksRequest, GetSavedPlaylistTracksRequest, 50 + GetSavedPlaylistsRequest, GetSmartPlaylistTracksRequest, GetSmartPlaylistsRequest, 51 + GetTracksRequest, InsertDirectoryRequest, InsertTracksRequest, LikeTrackRequest, NextRequest, 52 + PauseRequest, PlayAlbumRequest, PlayAllTracksRequest, PlayArtistTracksRequest, 53 + PlayDirectoryRequest, PlayOrPauseRequest, PlaySavedPlaylistRequest, PlaySmartPlaylistRequest, 54 + PlayTrackRequest, PlaylistResumeRequest, PreviousRequest, RemoveTrackFromSavedPlaylistRequest, 55 + RemoveTracksRequest, ResumeRequest, ResumeTrackRequest, SaveSettingsRequest, SearchRequest, 56 + ShufflePlaylistRequest, SoundCurrentRequest, StartRequest, StatusRequest, 57 + StreamCurrentTrackRequest, StreamLibraryRequest, StreamPlaylistRequest, StreamStatusRequest, 58 + TreeGetEntriesRequest, UnlikeTrackRequest, UpdateSavedPlaylistRequest, 59 + }; 60 + 61 + // ── Globals ────────────────────────────────────────────────────────────────── 62 + 63 + /// One Tokio runtime per process. Multi-thread is fine on Android; iOS works 64 + /// too — tokio uses pthread under the hood. 65 + static RT: Lazy<Runtime> = Lazy::new(|| { 66 + tokio::runtime::Builder::new_multi_thread() 67 + .enable_all() 68 + .worker_threads(2) 69 + .thread_name("rockbox-rpc") 70 + .build() 71 + .expect("failed to build tokio runtime") 72 + }); 73 + 74 + static SERVER_URL: Lazy<RwLock<String>> = 75 + Lazy::new(|| RwLock::new("http://127.0.0.1:6061".to_string())); 76 + 77 + fn url() -> String { 78 + SERVER_URL.read().expect("server url poisoned").clone() 79 + } 80 + 81 + // ── String helpers ─────────────────────────────────────────────────────────── 82 + 83 + unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { 84 + if p.is_null() { 85 + return None; 86 + } 87 + CStr::from_ptr(p).to_str().ok() 88 + } 89 + 90 + fn ok_string<T: Serialize>(value: &T) -> *mut c_char { 91 + match serde_json::to_string(value) { 92 + Ok(s) => CString::new(s) 93 + .map(|c| c.into_raw()) 94 + .unwrap_or(std::ptr::null_mut()), 95 + Err(_) => std::ptr::null_mut(), 96 + } 97 + } 98 + 99 + fn err_string(msg: impl AsRef<str>) -> *mut c_char { 100 + let payload = serde_json::json!({ "error": msg.as_ref() }); 101 + CString::new(payload.to_string()) 102 + .map(|c| c.into_raw()) 103 + .unwrap_or(std::ptr::null_mut()) 104 + } 105 + 106 + /// Frees a string previously returned by any `rb_*_json` entry point. 107 + /// Safe to call with a null pointer. 108 + /// 109 + /// # Safety 110 + /// `ptr` must be either null or a pointer returned by this library and must 111 + /// not have been freed already. 112 + #[no_mangle] 113 + pub unsafe extern "C" fn rb_free_string(ptr: *mut c_char) { 114 + if ptr.is_null() { 115 + return; 116 + } 117 + // Reclaim ownership and drop. 118 + drop(CString::from_raw(ptr)); 119 + } 120 + 121 + // ── Init / config ──────────────────────────────────────────────────────────── 122 + 123 + /// Configure the gRPC server URL. Call once at startup before any other entry. 124 + /// Returns 0 on success, -1 on invalid input. 125 + /// 126 + /// # Safety 127 + /// `url_ptr` must point to a valid NUL-terminated UTF-8 string. 128 + #[no_mangle] 129 + pub unsafe extern "C" fn rb_set_server_url(url_ptr: *const c_char) -> c_int { 130 + let Some(s) = cstr_to_str(url_ptr) else { 131 + return -1; 132 + }; 133 + match SERVER_URL.write() { 134 + Ok(mut g) => { 135 + *g = s.to_string(); 136 + 0 137 + } 138 + Err(_) => -2, 139 + } 140 + } 141 + 142 + /// Health check — round-trips a Status RPC. Returns 0 on success, -1 otherwise. 143 + #[no_mangle] 144 + pub extern "C" fn rb_ping() -> c_int { 145 + let res: Result<(), tonic::Status> = RT.block_on(async { 146 + let mut c = PlaybackServiceClient::connect(url()) 147 + .await 148 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 149 + c.status(StatusRequest {}).await?; 150 + Ok(()) 151 + }); 152 + match res { 153 + Ok(_) => 0, 154 + Err(_) => -1, 155 + } 156 + } 157 + 158 + // ── Playback control ───────────────────────────────────────────────────────── 159 + 160 + macro_rules! simple_call { 161 + ($fn_name:ident, $client:ident, $method:ident, $req:expr) => { 162 + #[no_mangle] 163 + pub extern "C" fn $fn_name() -> c_int { 164 + let res: Result<(), tonic::Status> = RT.block_on(async { 165 + let mut c = $client::connect(url()) 166 + .await 167 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 168 + c.$method($req).await?; 169 + Ok(()) 170 + }); 171 + match res { 172 + Ok(_) => 0, 173 + Err(_) => -1, 174 + } 175 + } 176 + }; 177 + } 178 + 179 + simple_call!(rb_play, PlaybackServiceClient, resume, ResumeRequest {}); 180 + simple_call!(rb_pause, PlaybackServiceClient, pause, PauseRequest {}); 181 + simple_call!( 182 + rb_play_pause, 183 + PlaybackServiceClient, 184 + play_or_pause, 185 + PlayOrPauseRequest {} 186 + ); 187 + simple_call!(rb_next, PlaybackServiceClient, next, NextRequest {}); 188 + simple_call!(rb_prev, PlaybackServiceClient, previous, PreviousRequest {}); 189 + 190 + /// Seek to `position_ms` milliseconds from the start of the current track. 191 + #[no_mangle] 192 + pub extern "C" fn rb_seek(position_ms: i32) -> c_int { 193 + let res: Result<(), tonic::Status> = RT.block_on(async { 194 + let mut c = PlaybackServiceClient::connect(url()) 195 + .await 196 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 197 + c.fast_forward_rewind(FastForwardRewindRequest { 198 + new_time: position_ms, 199 + }) 200 + .await?; 201 + Ok(()) 202 + }); 203 + match res { 204 + Ok(_) => 0, 205 + Err(_) => -1, 206 + } 207 + } 208 + 209 + // ── Read / status (returns JSON; caller must rb_free_string) ──────────────── 210 + 211 + #[derive(Serialize)] 212 + struct StatusJson { 213 + /// Server-side playback status code: 0 stopped, 1 playing, 2 paused. 214 + status: i32, 215 + } 216 + 217 + /// Returns a heap-allocated JSON string with global playback status, or an 218 + /// error JSON object on failure. Caller must free via `rb_free_string`. 219 + #[no_mangle] 220 + pub extern "C" fn rb_status_json() -> *mut c_char { 221 + let res: Result<StatusJson, tonic::Status> = RT.block_on(async { 222 + let mut c = PlaybackServiceClient::connect(url()) 223 + .await 224 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 225 + let resp = c.status(StatusRequest {}).await?.into_inner(); 226 + Ok(StatusJson { 227 + status: resp.status, 228 + }) 229 + }); 230 + match res { 231 + Ok(v) => ok_string(&v), 232 + Err(e) => err_string(format!("status: {e}")), 233 + } 234 + } 235 + 236 + #[derive(Serialize, Default)] 237 + struct TrackJson { 238 + id: String, 239 + path: String, 240 + title: String, 241 + artist: String, 242 + album: String, 243 + album_art: Option<String>, 244 + duration_ms: i64, 245 + elapsed_ms: i64, 246 + } 247 + 248 + /// Returns the current track as JSON (heap-allocated; free with `rb_free_string`). 249 + /// JSON shape: `{ id, path, title, artist, album, album_art, duration_ms }`. 250 + #[no_mangle] 251 + pub extern "C" fn rb_current_track_json() -> *mut c_char { 252 + let res: Result<TrackJson, tonic::Status> = RT.block_on(async { 253 + let mut c = PlaybackServiceClient::connect(url()) 254 + .await 255 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 256 + let resp = c.current_track(CurrentTrackRequest {}).await?.into_inner(); 257 + let album_art: Option<String> = resp.album_art.filter(|s: &String| !s.is_empty()); 258 + Ok(TrackJson { 259 + id: resp.id, 260 + path: resp.path, 261 + title: resp.title, 262 + artist: resp.artist, 263 + album: resp.album, 264 + album_art, 265 + duration_ms: resp.length as i64, 266 + elapsed_ms: resp.elapsed as i64, 267 + }) 268 + }); 269 + match res { 270 + Ok(v) => ok_string(&v), 271 + Err(_e) => { 272 + // Many fresh installs return `unimplemented` until a track is loaded; 273 + // surface an empty JSON object rather than an error so the UI degrades 274 + // gracefully. 275 + ok_string(&TrackJson::default()) 276 + } 277 + } 278 + } 279 + 280 + // ── Like / unlike ──────────────────────────────────────────────────────────── 281 + 282 + /// # Safety 283 + /// `track_id_ptr` must point to a valid NUL-terminated UTF-8 string. 284 + #[no_mangle] 285 + pub unsafe extern "C" fn rb_like_track(track_id_ptr: *const c_char) -> c_int { 286 + let Some(track_id) = cstr_to_str(track_id_ptr) else { 287 + return -1; 288 + }; 289 + let track_id = track_id.to_string(); 290 + let res: Result<(), tonic::Status> = RT.block_on(async { 291 + let mut c = LibraryServiceClient::connect(url()) 292 + .await 293 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 294 + c.like_track(LikeTrackRequest { id: track_id }).await?; 295 + Ok(()) 296 + }); 297 + if res.is_ok() { 298 + 0 299 + } else { 300 + -1 301 + } 302 + } 303 + 304 + /// # Safety 305 + /// `track_id_ptr` must point to a valid NUL-terminated UTF-8 string. 306 + #[no_mangle] 307 + pub unsafe extern "C" fn rb_unlike_track(track_id_ptr: *const c_char) -> c_int { 308 + let Some(track_id) = cstr_to_str(track_id_ptr) else { 309 + return -1; 310 + }; 311 + let track_id = track_id.to_string(); 312 + let res: Result<(), tonic::Status> = RT.block_on(async { 313 + let mut c = LibraryServiceClient::connect(url()) 314 + .await 315 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 316 + c.unlike_track(UnlikeTrackRequest { id: track_id }).await?; 317 + Ok(()) 318 + }); 319 + if res.is_ok() { 320 + 0 321 + } else { 322 + -1 323 + } 324 + } 325 + 326 + // ── Comprehensive RPC surface ─────────────────────────────────────────────── 327 + // 328 + // Mirrors `gpui/src/client.rs`. JSON-returning entry points serialize the full 329 + // proto response (every generated type derives `serde::Serialize`), so callers 330 + // can `JSON.parse` and use the message structure directly. Unit ops return 331 + // `i32` (0 = ok, <0 = error) and don't allocate. 332 + 333 + /// Returns 0 if the gRPC call resolved Ok, -1 otherwise. 334 + fn run_unit<F>(fut: F) -> c_int 335 + where 336 + F: std::future::Future<Output = Result<(), tonic::Status>>, 337 + { 338 + match RT.block_on(fut) { 339 + Ok(_) => 0, 340 + Err(_) => -1, 341 + } 342 + } 343 + 344 + /// Returns a heap JSON C string of `value`, or null on serialization failure. 345 + fn json_response<T: Serialize>(value: T) -> *mut c_char { 346 + match serde_json::to_string(&value) { 347 + Ok(s) => CString::new(s) 348 + .map(|c| c.into_raw()) 349 + .unwrap_or(std::ptr::null_mut()), 350 + Err(_) => std::ptr::null_mut(), 351 + } 352 + } 353 + 354 + /// Take the inner of a tonic response if Ok, else build an `{ "error": ... }` 355 + /// JSON payload. 356 + fn unwrap_or_err_string<T: Serialize, E: std::fmt::Display>(res: Result<T, E>) -> *mut c_char { 357 + match res { 358 + Ok(v) => json_response(v), 359 + Err(e) => err_string(e.to_string()), 360 + } 361 + } 362 + 363 + /// Helper that maps tonic transport / RPC errors to a printable string. 364 + async fn connect_err<T, F: std::future::Future<Output = Result<T, tonic::Status>>>( 365 + fut: F, 366 + ) -> Result<T, String> { 367 + fut.await.map_err(|e| e.to_string()) 368 + } 369 + 370 + /// Boolean → bool conversion via i32 sentinel; 0 = false, anything else = true. 371 + fn b(v: c_int) -> bool { 372 + v != 0 373 + } 374 + 375 + // ── Playback ──────────────────────────────────────────────────────────────── 376 + 377 + simple_call!( 378 + rb_resume_track, 379 + PlaylistServiceClient, 380 + resume_track, 381 + ResumeTrackRequest { 382 + start_index: 0, 383 + crc: 0, 384 + elapsed: 0, 385 + offset: 0, 386 + } 387 + ); 388 + simple_call!( 389 + rb_playlist_resume, 390 + PlaylistServiceClient, 391 + playlist_resume, 392 + PlaylistResumeRequest {} 393 + ); 394 + simple_call!( 395 + rb_play_all_tracks, 396 + PlaybackServiceClient, 397 + play_all_tracks, 398 + PlayAllTracksRequest { 399 + shuffle: Some(false), 400 + position: Some(0), 401 + } 402 + ); 403 + 404 + /// # Safety 405 + /// `path_ptr` must be a valid NUL-terminated UTF-8 string. 406 + #[no_mangle] 407 + pub unsafe extern "C" fn rb_play_track(path_ptr: *const c_char) -> c_int { 408 + let Some(path) = cstr_to_str(path_ptr) else { 409 + return -1; 410 + }; 411 + let path = path.to_string(); 412 + run_unit(async move { 413 + let mut c = PlaybackServiceClient::connect(url()) 414 + .await 415 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 416 + c.play_track(PlayTrackRequest { path }).await?; 417 + Ok(()) 418 + }) 419 + } 420 + 421 + /// # Safety 422 + /// `album_id_ptr` must be a valid NUL-terminated UTF-8 string. 423 + #[no_mangle] 424 + pub unsafe extern "C" fn rb_play_album(album_id_ptr: *const c_char, shuffle: c_int) -> c_int { 425 + let Some(album_id) = cstr_to_str(album_id_ptr) else { 426 + return -1; 427 + }; 428 + let album_id = album_id.to_string(); 429 + let shuffle = b(shuffle); 430 + run_unit(async move { 431 + let mut c = PlaybackServiceClient::connect(url()) 432 + .await 433 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 434 + c.play_album(PlayAlbumRequest { 435 + album_id, 436 + shuffle: Some(shuffle), 437 + position: Some(0), 438 + }) 439 + .await?; 440 + Ok(()) 441 + }) 442 + } 443 + 444 + /// # Safety 445 + /// `artist_id_ptr` must be a valid NUL-terminated UTF-8 string. 446 + #[no_mangle] 447 + pub unsafe extern "C" fn rb_play_artist_tracks( 448 + artist_id_ptr: *const c_char, 449 + shuffle: c_int, 450 + ) -> c_int { 451 + let Some(artist_id) = cstr_to_str(artist_id_ptr) else { 452 + return -1; 453 + }; 454 + let artist_id = artist_id.to_string(); 455 + let shuffle = b(shuffle); 456 + run_unit(async move { 457 + let mut c = PlaybackServiceClient::connect(url()) 458 + .await 459 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 460 + c.play_artist_tracks(PlayArtistTracksRequest { 461 + artist_id, 462 + shuffle: Some(shuffle), 463 + position: Some(0), 464 + }) 465 + .await?; 466 + Ok(()) 467 + }) 468 + } 469 + 470 + /// # Safety 471 + /// `path_ptr` must be a valid NUL-terminated UTF-8 string. 472 + #[no_mangle] 473 + pub unsafe extern "C" fn rb_play_directory( 474 + path_ptr: *const c_char, 475 + shuffle: c_int, 476 + position: c_int, 477 + ) -> c_int { 478 + let Some(path) = cstr_to_str(path_ptr) else { 479 + return -1; 480 + }; 481 + let path = path.to_string(); 482 + let shuffle = b(shuffle); 483 + let pos = if position < 0 { None } else { Some(position) }; 484 + run_unit(async move { 485 + let mut c = PlaybackServiceClient::connect(url()) 486 + .await 487 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 488 + c.play_directory(PlayDirectoryRequest { 489 + path, 490 + shuffle: Some(shuffle), 491 + recurse: Some(true), 492 + position: pos, 493 + }) 494 + .await?; 495 + Ok(()) 496 + }) 497 + } 498 + 499 + // ── Playlist (queue) ──────────────────────────────────────────────────────── 500 + 501 + /// Jump to a queue position (calls `Start { start_index: pos }`). 502 + #[no_mangle] 503 + pub extern "C" fn rb_jump_to_queue_position(pos: c_int) -> c_int { 504 + run_unit(async move { 505 + let mut c = PlaylistServiceClient::connect(url()) 506 + .await 507 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 508 + c.start(StartRequest { 509 + start_index: Some(pos), 510 + elapsed: Some(0), 511 + offset: Some(0), 512 + }) 513 + .await?; 514 + Ok(()) 515 + }) 516 + } 517 + 518 + simple_call!( 519 + rb_shuffle_playlist, 520 + PlaylistServiceClient, 521 + shuffle_playlist, 522 + ShufflePlaylistRequest { start_index: 0 } 523 + ); 524 + 525 + /// `paths_json` must be a JSON array of strings. 526 + /// `position` follows the rockbox INSERT_* constants. 527 + /// 528 + /// # Safety 529 + /// `paths_json_ptr` must be a valid NUL-terminated UTF-8 string. 530 + #[no_mangle] 531 + pub unsafe extern "C" fn rb_insert_tracks( 532 + paths_json_ptr: *const c_char, 533 + position: c_int, 534 + shuffle: c_int, 535 + ) -> c_int { 536 + let Some(paths_json) = cstr_to_str(paths_json_ptr) else { 537 + return -1; 538 + }; 539 + let Ok(paths) = serde_json::from_str::<Vec<String>>(paths_json) else { 540 + return -2; 541 + }; 542 + let shuffle = b(shuffle); 543 + run_unit(async move { 544 + let mut c = PlaylistServiceClient::connect(url()) 545 + .await 546 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 547 + c.insert_tracks(InsertTracksRequest { 548 + playlist_id: None, 549 + position, 550 + tracks: paths, 551 + shuffle: Some(shuffle), 552 + }) 553 + .await?; 554 + Ok(()) 555 + }) 556 + } 557 + 558 + /// Convenience: insert a single track at position INSERT_FIRST (-4). 559 + /// 560 + /// # Safety 561 + /// `path_ptr` must be a valid NUL-terminated UTF-8 string. 562 + #[no_mangle] 563 + pub unsafe extern "C" fn rb_insert_track_next(path_ptr: *const c_char) -> c_int { 564 + let Some(p) = cstr_to_str(path_ptr) else { 565 + return -1; 566 + }; 567 + let path = p.to_string(); 568 + run_unit(async move { 569 + let mut c = PlaylistServiceClient::connect(url()) 570 + .await 571 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 572 + c.insert_tracks(InsertTracksRequest { 573 + playlist_id: None, 574 + position: -4, // INSERT_FIRST 575 + tracks: vec![path], 576 + shuffle: Some(false), 577 + }) 578 + .await?; 579 + Ok(()) 580 + }) 581 + } 582 + 583 + /// Convenience: insert a single track at position INSERT_LAST (-3). 584 + /// 585 + /// # Safety 586 + /// `path_ptr` must be a valid NUL-terminated UTF-8 string. 587 + #[no_mangle] 588 + pub unsafe extern "C" fn rb_insert_track_last(path_ptr: *const c_char) -> c_int { 589 + let Some(p) = cstr_to_str(path_ptr) else { 590 + return -1; 591 + }; 592 + let path = p.to_string(); 593 + run_unit(async move { 594 + let mut c = PlaylistServiceClient::connect(url()) 595 + .await 596 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 597 + c.insert_tracks(InsertTracksRequest { 598 + playlist_id: None, 599 + position: -3, // INSERT_LAST 600 + tracks: vec![path], 601 + shuffle: Some(false), 602 + }) 603 + .await?; 604 + Ok(()) 605 + }) 606 + } 607 + 608 + /// # Safety 609 + /// `path_ptr` must be a valid NUL-terminated UTF-8 string. 610 + #[no_mangle] 611 + pub unsafe extern "C" fn rb_insert_directory(path_ptr: *const c_char, position: c_int) -> c_int { 612 + let Some(p) = cstr_to_str(path_ptr) else { 613 + return -1; 614 + }; 615 + let directory = p.to_string(); 616 + run_unit(async move { 617 + let mut c = PlaylistServiceClient::connect(url()) 618 + .await 619 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 620 + c.insert_directory(InsertDirectoryRequest { 621 + playlist_id: None, 622 + position, 623 + directory, 624 + recurse: Some(true), 625 + shuffle: Some(false), 626 + }) 627 + .await?; 628 + Ok(()) 629 + }) 630 + } 631 + 632 + #[no_mangle] 633 + pub extern "C" fn rb_remove_from_queue(position: c_int) -> c_int { 634 + run_unit(async move { 635 + let mut c = PlaylistServiceClient::connect(url()) 636 + .await 637 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 638 + c.remove_tracks(RemoveTracksRequest { 639 + positions: vec![position], 640 + }) 641 + .await?; 642 + Ok(()) 643 + }) 644 + } 645 + 646 + /// JSON snapshot of `PlaylistService::GetCurrent` (queue position + tracks). 647 + #[no_mangle] 648 + pub extern "C" fn rb_get_playlist_current_json() -> *mut c_char { 649 + let res = RT.block_on(async { 650 + let mut c = PlaylistServiceClient::connect(url()) 651 + .await 652 + .map_err(|e| e.to_string())?; 653 + connect_err(c.get_current(GetCurrentRequest {})).await 654 + }); 655 + unwrap_or_err_string(res.map(|r| r.into_inner())) 656 + } 657 + 658 + // ── Library ───────────────────────────────────────────────────────────────── 659 + 660 + #[no_mangle] 661 + pub extern "C" fn rb_get_tracks_json() -> *mut c_char { 662 + let res = RT.block_on(async { 663 + let mut c = LibraryServiceClient::connect(url()) 664 + .await 665 + .map_err(|e| e.to_string())?; 666 + connect_err(c.get_tracks(GetTracksRequest {})).await 667 + }); 668 + unwrap_or_err_string(res.map(|r| r.into_inner())) 669 + } 670 + 671 + #[no_mangle] 672 + pub extern "C" fn rb_get_artists_json() -> *mut c_char { 673 + let res = RT.block_on(async { 674 + let mut c = LibraryServiceClient::connect(url()) 675 + .await 676 + .map_err(|e| e.to_string())?; 677 + connect_err(c.get_artists(GetArtistsRequest {})).await 678 + }); 679 + unwrap_or_err_string(res.map(|r| r.into_inner())) 680 + } 681 + 682 + /// # Safety 683 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 684 + #[no_mangle] 685 + pub unsafe extern "C" fn rb_get_album_json(id_ptr: *const c_char) -> *mut c_char { 686 + let Some(id) = cstr_to_str(id_ptr) else { 687 + return err_string("missing id"); 688 + }; 689 + let id = id.to_string(); 690 + let res = RT.block_on(async { 691 + let mut c = LibraryServiceClient::connect(url()) 692 + .await 693 + .map_err(|e| e.to_string())?; 694 + connect_err(c.get_album(GetAlbumRequest { id })).await 695 + }); 696 + unwrap_or_err_string(res.map(|r| r.into_inner())) 697 + } 698 + 699 + #[no_mangle] 700 + pub extern "C" fn rb_get_liked_tracks_json() -> *mut c_char { 701 + let res = RT.block_on(async { 702 + let mut c = LibraryServiceClient::connect(url()) 703 + .await 704 + .map_err(|e| e.to_string())?; 705 + connect_err(c.get_liked_tracks(GetLikedTracksRequest {})).await 706 + }); 707 + unwrap_or_err_string(res.map(|r| r.into_inner())) 708 + } 709 + 710 + /// # Safety 711 + /// `term_ptr` must be a valid NUL-terminated UTF-8 string. 712 + #[no_mangle] 713 + pub unsafe extern "C" fn rb_search_json(term_ptr: *const c_char) -> *mut c_char { 714 + let Some(term) = cstr_to_str(term_ptr) else { 715 + return err_string("missing term"); 716 + }; 717 + let term = term.to_string(); 718 + let res = RT.block_on(async { 719 + let mut c = LibraryServiceClient::connect(url()) 720 + .await 721 + .map_err(|e| e.to_string())?; 722 + connect_err(c.search(SearchRequest { term })).await 723 + }); 724 + unwrap_or_err_string(res.map(|r| r.into_inner())) 725 + } 726 + 727 + // ── Sound ─────────────────────────────────────────────────────────────────── 728 + 729 + #[no_mangle] 730 + pub extern "C" fn rb_adjust_volume(steps: c_int) -> c_int { 731 + run_unit(async move { 732 + let mut c = SoundServiceClient::connect(url()) 733 + .await 734 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 735 + c.adjust_volume(AdjustVolumeRequest { steps }).await?; 736 + Ok(()) 737 + }) 738 + } 739 + 740 + /// `setting` follows the rockbox SOUND_* enum (0 = volume, etc.). 741 + #[no_mangle] 742 + pub extern "C" fn rb_sound_current_json(setting: c_int) -> *mut c_char { 743 + let res = RT.block_on(async { 744 + let mut c = SoundServiceClient::connect(url()) 745 + .await 746 + .map_err(|e| e.to_string())?; 747 + connect_err(c.sound_current(SoundCurrentRequest { setting })).await 748 + }); 749 + unwrap_or_err_string(res.map(|r| r.into_inner())) 750 + } 751 + 752 + // ── Settings ──────────────────────────────────────────────────────────────── 753 + 754 + #[no_mangle] 755 + pub extern "C" fn rb_save_shuffle(enabled: c_int) -> c_int { 756 + let enabled = b(enabled); 757 + run_unit(async move { 758 + let mut c = SettingsServiceClient::connect(url()) 759 + .await 760 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 761 + c.save_settings(SaveSettingsRequest { 762 + playlist_shuffle: Some(enabled), 763 + ..Default::default() 764 + }) 765 + .await?; 766 + Ok(()) 767 + }) 768 + } 769 + 770 + #[no_mangle] 771 + pub extern "C" fn rb_save_repeat(repeat_mode: c_int) -> c_int { 772 + run_unit(async move { 773 + let mut c = SettingsServiceClient::connect(url()) 774 + .await 775 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 776 + c.save_settings(SaveSettingsRequest { 777 + repeat_mode: Some(repeat_mode), 778 + ..Default::default() 779 + }) 780 + .await?; 781 + Ok(()) 782 + }) 783 + } 784 + 785 + #[no_mangle] 786 + pub extern "C" fn rb_get_global_settings_json() -> *mut c_char { 787 + let res = RT.block_on(async { 788 + let mut c = SettingsServiceClient::connect(url()) 789 + .await 790 + .map_err(|e| e.to_string())?; 791 + connect_err(c.get_global_settings(GetGlobalSettingsRequest {})).await 792 + }); 793 + unwrap_or_err_string(res.map(|r| r.into_inner())) 794 + } 795 + 796 + // ── System ────────────────────────────────────────────────────────────────── 797 + 798 + #[no_mangle] 799 + pub extern "C" fn rb_get_global_status_json() -> *mut c_char { 800 + let res = RT.block_on(async { 801 + let mut c = SystemServiceClient::connect(url()) 802 + .await 803 + .map_err(|e| e.to_string())?; 804 + connect_err(c.get_global_status(GetGlobalStatusRequest {})).await 805 + }); 806 + unwrap_or_err_string(res.map(|r| r.into_inner())) 807 + } 808 + 809 + // ── Browse ────────────────────────────────────────────────────────────────── 810 + 811 + /// `path_ptr` may be null to fetch the music root. 812 + /// 813 + /// # Safety 814 + /// If non-null, `path_ptr` must be a valid NUL-terminated UTF-8 string. 815 + #[no_mangle] 816 + pub unsafe extern "C" fn rb_tree_get_entries_json(path_ptr: *const c_char) -> *mut c_char { 817 + let path = if path_ptr.is_null() { 818 + None 819 + } else { 820 + cstr_to_str(path_ptr).map(|s| s.to_string()) 821 + }; 822 + let res = RT.block_on(async { 823 + let mut c = BrowseServiceClient::connect(url()) 824 + .await 825 + .map_err(|e| e.to_string())?; 826 + connect_err(c.tree_get_entries(TreeGetEntriesRequest { path })).await 827 + }); 828 + unwrap_or_err_string(res.map(|r| r.into_inner())) 829 + } 830 + 831 + // ── Saved playlists ───────────────────────────────────────────────────────── 832 + 833 + #[no_mangle] 834 + pub extern "C" fn rb_get_saved_playlists_json() -> *mut c_char { 835 + let res = RT.block_on(async { 836 + let mut c = SavedPlaylistServiceClient::connect(url()) 837 + .await 838 + .map_err(|e| e.to_string())?; 839 + connect_err(c.get_saved_playlists(GetSavedPlaylistsRequest { folder_id: None })).await 840 + }); 841 + unwrap_or_err_string(res.map(|r| r.into_inner())) 842 + } 843 + 844 + /// # Safety 845 + /// `name_ptr` must be a valid UTF-8 NUL-terminated string. `description_ptr` 846 + /// may be null. `track_ids_json_ptr` must be a JSON array of strings (may be 847 + /// `[]` for an empty playlist). 848 + #[no_mangle] 849 + pub unsafe extern "C" fn rb_create_saved_playlist( 850 + name_ptr: *const c_char, 851 + description_ptr: *const c_char, 852 + track_ids_json_ptr: *const c_char, 853 + ) -> c_int { 854 + let Some(name) = cstr_to_str(name_ptr) else { 855 + return -1; 856 + }; 857 + let name = name.to_string(); 858 + let description = if description_ptr.is_null() { 859 + None 860 + } else { 861 + cstr_to_str(description_ptr).map(|s| s.to_string()) 862 + }; 863 + let track_ids: Vec<String> = if track_ids_json_ptr.is_null() { 864 + Vec::new() 865 + } else { 866 + let Some(s) = cstr_to_str(track_ids_json_ptr) else { 867 + return -2; 868 + }; 869 + match serde_json::from_str(s) { 870 + Ok(v) => v, 871 + Err(_) => return -3, 872 + } 873 + }; 874 + run_unit(async move { 875 + let mut c = SavedPlaylistServiceClient::connect(url()) 876 + .await 877 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 878 + c.create_saved_playlist(CreateSavedPlaylistRequest { 879 + name, 880 + description, 881 + image: None, 882 + folder_id: None, 883 + track_ids, 884 + }) 885 + .await?; 886 + Ok(()) 887 + }) 888 + } 889 + 890 + /// # Safety 891 + /// `id_ptr` and `name_ptr` must be valid UTF-8 NUL-terminated strings. 892 + /// `description_ptr` may be null. 893 + #[no_mangle] 894 + pub unsafe extern "C" fn rb_update_saved_playlist( 895 + id_ptr: *const c_char, 896 + name_ptr: *const c_char, 897 + description_ptr: *const c_char, 898 + ) -> c_int { 899 + let Some(id) = cstr_to_str(id_ptr) else { 900 + return -1; 901 + }; 902 + let Some(name) = cstr_to_str(name_ptr) else { 903 + return -1; 904 + }; 905 + let id = id.to_string(); 906 + let name = name.to_string(); 907 + let description = if description_ptr.is_null() { 908 + None 909 + } else { 910 + cstr_to_str(description_ptr).map(|s| s.to_string()) 911 + }; 912 + run_unit(async move { 913 + let mut c = SavedPlaylistServiceClient::connect(url()) 914 + .await 915 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 916 + c.update_saved_playlist(UpdateSavedPlaylistRequest { 917 + id, 918 + name, 919 + description, 920 + image: None, 921 + folder_id: None, 922 + }) 923 + .await?; 924 + Ok(()) 925 + }) 926 + } 927 + 928 + /// # Safety 929 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 930 + #[no_mangle] 931 + pub unsafe extern "C" fn rb_delete_saved_playlist(id_ptr: *const c_char) -> c_int { 932 + let Some(id) = cstr_to_str(id_ptr) else { 933 + return -1; 934 + }; 935 + let id = id.to_string(); 936 + run_unit(async move { 937 + let mut c = SavedPlaylistServiceClient::connect(url()) 938 + .await 939 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 940 + c.delete_saved_playlist(DeleteSavedPlaylistRequest { id }) 941 + .await?; 942 + Ok(()) 943 + }) 944 + } 945 + 946 + /// # Safety 947 + /// Both pointers must be valid NUL-terminated UTF-8 strings. 948 + #[no_mangle] 949 + pub unsafe extern "C" fn rb_add_track_to_playlist( 950 + playlist_id_ptr: *const c_char, 951 + track_id_ptr: *const c_char, 952 + ) -> c_int { 953 + let Some(playlist_id) = cstr_to_str(playlist_id_ptr) else { 954 + return -1; 955 + }; 956 + let Some(track_id) = cstr_to_str(track_id_ptr) else { 957 + return -1; 958 + }; 959 + let playlist_id = playlist_id.to_string(); 960 + let track_id = track_id.to_string(); 961 + run_unit(async move { 962 + let mut c = SavedPlaylistServiceClient::connect(url()) 963 + .await 964 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 965 + c.add_tracks_to_saved_playlist(AddTracksToSavedPlaylistRequest { 966 + playlist_id, 967 + track_ids: vec![track_id], 968 + }) 969 + .await?; 970 + Ok(()) 971 + }) 972 + } 973 + 974 + /// # Safety 975 + /// Both pointers must be valid NUL-terminated UTF-8 strings. 976 + #[no_mangle] 977 + pub unsafe extern "C" fn rb_remove_track_from_playlist( 978 + playlist_id_ptr: *const c_char, 979 + track_id_ptr: *const c_char, 980 + ) -> c_int { 981 + let Some(playlist_id) = cstr_to_str(playlist_id_ptr) else { 982 + return -1; 983 + }; 984 + let Some(track_id) = cstr_to_str(track_id_ptr) else { 985 + return -1; 986 + }; 987 + let playlist_id = playlist_id.to_string(); 988 + let track_id = track_id.to_string(); 989 + run_unit(async move { 990 + let mut c = SavedPlaylistServiceClient::connect(url()) 991 + .await 992 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 993 + c.remove_track_from_saved_playlist(RemoveTrackFromSavedPlaylistRequest { 994 + playlist_id, 995 + track_id, 996 + }) 997 + .await?; 998 + Ok(()) 999 + }) 1000 + } 1001 + 1002 + /// # Safety 1003 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1004 + #[no_mangle] 1005 + pub unsafe extern "C" fn rb_get_saved_playlist_tracks_json(id_ptr: *const c_char) -> *mut c_char { 1006 + let Some(id) = cstr_to_str(id_ptr) else { 1007 + return err_string("missing id"); 1008 + }; 1009 + let playlist_id = id.to_string(); 1010 + let res = RT.block_on(async { 1011 + let mut c = SavedPlaylistServiceClient::connect(url()) 1012 + .await 1013 + .map_err(|e| e.to_string())?; 1014 + connect_err(c.get_saved_playlist_tracks(GetSavedPlaylistTracksRequest { playlist_id })) 1015 + .await 1016 + }); 1017 + unwrap_or_err_string(res.map(|r| r.into_inner())) 1018 + } 1019 + 1020 + /// # Safety 1021 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1022 + #[no_mangle] 1023 + pub unsafe extern "C" fn rb_play_saved_playlist(id_ptr: *const c_char) -> c_int { 1024 + let Some(playlist_id) = cstr_to_str(id_ptr) else { 1025 + return -1; 1026 + }; 1027 + let playlist_id = playlist_id.to_string(); 1028 + run_unit(async move { 1029 + let mut c = SavedPlaylistServiceClient::connect(url()) 1030 + .await 1031 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 1032 + c.play_saved_playlist(PlaySavedPlaylistRequest { playlist_id }) 1033 + .await?; 1034 + Ok(()) 1035 + }) 1036 + } 1037 + 1038 + // ── Smart playlists ───────────────────────────────────────────────────────── 1039 + 1040 + #[no_mangle] 1041 + pub extern "C" fn rb_get_smart_playlists_json() -> *mut c_char { 1042 + let res = RT.block_on(async { 1043 + let mut c = SmartPlaylistServiceClient::connect(url()) 1044 + .await 1045 + .map_err(|e| e.to_string())?; 1046 + connect_err(c.get_smart_playlists(GetSmartPlaylistsRequest {})).await 1047 + }); 1048 + unwrap_or_err_string(res.map(|r| r.into_inner())) 1049 + } 1050 + 1051 + /// # Safety 1052 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1053 + #[no_mangle] 1054 + pub unsafe extern "C" fn rb_get_smart_playlist_tracks_json(id_ptr: *const c_char) -> *mut c_char { 1055 + let Some(id_s) = cstr_to_str(id_ptr) else { 1056 + return err_string("missing id"); 1057 + }; 1058 + let id = id_s.to_string(); 1059 + let res = RT.block_on(async { 1060 + let mut c = SmartPlaylistServiceClient::connect(url()) 1061 + .await 1062 + .map_err(|e| e.to_string())?; 1063 + connect_err(c.get_smart_playlist_tracks(GetSmartPlaylistTracksRequest { id })).await 1064 + }); 1065 + unwrap_or_err_string(res.map(|r| r.into_inner())) 1066 + } 1067 + 1068 + /// # Safety 1069 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1070 + #[no_mangle] 1071 + pub unsafe extern "C" fn rb_play_smart_playlist(id_ptr: *const c_char) -> c_int { 1072 + let Some(id_s) = cstr_to_str(id_ptr) else { 1073 + return -1; 1074 + }; 1075 + let id = id_s.to_string(); 1076 + run_unit(async move { 1077 + let mut c = SmartPlaylistServiceClient::connect(url()) 1078 + .await 1079 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 1080 + c.play_smart_playlist(PlaySmartPlaylistRequest { id }) 1081 + .await?; 1082 + Ok(()) 1083 + }) 1084 + } 1085 + 1086 + // ── Bluetooth ─────────────────────────────────────────────────────────────── 1087 + 1088 + /// 1 if the Bluetooth service is reachable and answers GetDevices, 0 otherwise. 1089 + #[no_mangle] 1090 + pub extern "C" fn rb_bluetooth_available() -> c_int { 1091 + let ok = RT.block_on(async { 1092 + let Ok(mut c) = BluetoothServiceClient::connect(url()).await else { 1093 + return false; 1094 + }; 1095 + c.get_devices(GetBluetoothDevicesRequest {}).await.is_ok() 1096 + }); 1097 + if ok { 1098 + 1 1099 + } else { 1100 + 0 1101 + } 1102 + } 1103 + 1104 + #[no_mangle] 1105 + pub extern "C" fn rb_get_bluetooth_devices_json() -> *mut c_char { 1106 + let res = RT.block_on(async { 1107 + let mut c = BluetoothServiceClient::connect(url()) 1108 + .await 1109 + .map_err(|e| e.to_string())?; 1110 + connect_err(c.get_devices(GetBluetoothDevicesRequest {})).await 1111 + }); 1112 + unwrap_or_err_string(res.map(|r| r.into_inner())) 1113 + } 1114 + 1115 + /// # Safety 1116 + /// `addr_ptr` must be a valid NUL-terminated UTF-8 string. 1117 + #[no_mangle] 1118 + pub unsafe extern "C" fn rb_connect_bluetooth(addr_ptr: *const c_char) -> c_int { 1119 + let Some(s) = cstr_to_str(addr_ptr) else { 1120 + return -1; 1121 + }; 1122 + let address = s.to_string(); 1123 + run_unit(async move { 1124 + let mut c = BluetoothServiceClient::connect(url()) 1125 + .await 1126 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 1127 + c.connect_device(ConnectBluetoothDeviceRequest { address }) 1128 + .await?; 1129 + Ok(()) 1130 + }) 1131 + } 1132 + 1133 + /// # Safety 1134 + /// `addr_ptr` must be a valid NUL-terminated UTF-8 string. 1135 + #[no_mangle] 1136 + pub unsafe extern "C" fn rb_disconnect_bluetooth(addr_ptr: *const c_char) -> c_int { 1137 + let Some(s) = cstr_to_str(addr_ptr) else { 1138 + return -1; 1139 + }; 1140 + let address = s.to_string(); 1141 + run_unit(async move { 1142 + let mut c = BluetoothServiceClient::connect(url()) 1143 + .await 1144 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 1145 + c.disconnect(DisconnectBluetoothDeviceRequest { address }) 1146 + .await?; 1147 + Ok(()) 1148 + }) 1149 + } 1150 + 1151 + // ── Streaming subscriptions ───────────────────────────────────────────────── 1152 + // 1153 + // Pattern (Option B from the design discussion): each `rb_subscribe_*` spawns 1154 + // a tonic streaming RPC on the runtime, drains it into a bounded mpsc, and 1155 + // returns an `i32` subscription id. Platform code (Swift / Kotlin) drives a 1156 + // background loop calling `rb_poll_event(id, timeout_ms)`, which blocks up to 1157 + // `timeout_ms` waiting for the next JSON payload, returns it as a heap 1158 + // C string (free with `rb_free_string`), or returns null on timeout / closed 1159 + // stream. `rb_unsubscribe` aborts the task and removes the entry. 1160 + 1161 + const EVENT_BUFFER: usize = 64; 1162 + 1163 + struct Subscription { 1164 + rx: mpsc::Receiver<String>, 1165 + abort: AbortHandle, 1166 + } 1167 + 1168 + static SUBS: Lazy<RwLock<HashMap<i32, Subscription>>> = Lazy::new(|| RwLock::new(HashMap::new())); 1169 + static NEXT_SUB_ID: AtomicI32 = AtomicI32::new(1); 1170 + 1171 + #[derive(Serialize)] 1172 + struct StreamErrorJson { 1173 + error: String, 1174 + } 1175 + 1176 + fn next_sub_id() -> i32 { 1177 + NEXT_SUB_ID.fetch_add(1, Ordering::SeqCst) 1178 + } 1179 + 1180 + fn register_sub(rx: mpsc::Receiver<String>, abort: AbortHandle) -> i32 { 1181 + let id = next_sub_id(); 1182 + if let Ok(mut map) = SUBS.write() { 1183 + map.insert(id, Subscription { rx, abort }); 1184 + } 1185 + id 1186 + } 1187 + 1188 + /// Spawns `task_factory` on the runtime, where it should drain a tonic stream 1189 + /// into the supplied sender. Returns the subscription id. 1190 + fn spawn_stream<F, Fut>(task_factory: F) -> i32 1191 + where 1192 + F: FnOnce(mpsc::Sender<String>) -> Fut + Send + 'static, 1193 + Fut: std::future::Future<Output = ()> + Send + 'static, 1194 + { 1195 + let (tx, rx) = mpsc::channel::<String>(EVENT_BUFFER); 1196 + let handle = RT.spawn(async move { 1197 + task_factory(tx).await; 1198 + }); 1199 + register_sub(rx, handle.abort_handle()) 1200 + } 1201 + 1202 + /// Subscribes to `PlaybackService::StreamStatus`. Each event is a JSON 1203 + /// `{ "status": <int> }` payload (mirrors `rb_status_json`). 1204 + #[no_mangle] 1205 + pub extern "C" fn rb_subscribe_status() -> c_int { 1206 + let server_url = url(); 1207 + spawn_stream(move |tx| async move { 1208 + match PlaybackServiceClient::connect(server_url).await { 1209 + Ok(mut c) => match c.stream_status(StreamStatusRequest {}).await { 1210 + Ok(resp) => { 1211 + let mut s = resp.into_inner(); 1212 + while let Ok(Some(msg)) = s.message().await { 1213 + let payload = serde_json::to_string(&StatusJson { status: msg.status }) 1214 + .unwrap_or_else(|_| "{}".into()); 1215 + if tx.send(payload).await.is_err() { 1216 + break; 1217 + } 1218 + } 1219 + } 1220 + Err(e) => { 1221 + let _ = tx 1222 + .send( 1223 + serde_json::to_string(&StreamErrorJson { 1224 + error: format!("stream_status: {e}"), 1225 + }) 1226 + .unwrap_or_default(), 1227 + ) 1228 + .await; 1229 + } 1230 + }, 1231 + Err(e) => { 1232 + let _ = tx 1233 + .send( 1234 + serde_json::to_string(&StreamErrorJson { 1235 + error: format!("connect: {e}"), 1236 + }) 1237 + .unwrap_or_default(), 1238 + ) 1239 + .await; 1240 + } 1241 + } 1242 + }) 1243 + } 1244 + 1245 + /// Subscribes to `PlaybackService::StreamCurrentTrack`. Each event JSON 1246 + /// matches the `rb_current_track_json` shape. 1247 + #[no_mangle] 1248 + pub extern "C" fn rb_subscribe_current_track() -> c_int { 1249 + let server_url = url(); 1250 + spawn_stream(move |tx| async move { 1251 + match PlaybackServiceClient::connect(server_url).await { 1252 + Ok(mut c) => match c.stream_current_track(StreamCurrentTrackRequest {}).await { 1253 + Ok(resp) => { 1254 + let mut s = resp.into_inner(); 1255 + while let Ok(Some(msg)) = s.message().await { 1256 + let payload = serde_json::to_string(&TrackJson { 1257 + id: msg.id, 1258 + path: msg.path, 1259 + title: msg.title, 1260 + artist: msg.artist, 1261 + album: msg.album, 1262 + album_art: msg.album_art.filter(|a| !a.is_empty()), 1263 + duration_ms: msg.length as i64, 1264 + elapsed_ms: msg.elapsed as i64, 1265 + }) 1266 + .unwrap_or_else(|_| "{}".into()); 1267 + if tx.send(payload).await.is_err() { 1268 + break; 1269 + } 1270 + } 1271 + } 1272 + Err(e) => { 1273 + let _ = tx 1274 + .send( 1275 + serde_json::to_string(&StreamErrorJson { 1276 + error: format!("stream_current_track: {e}"), 1277 + }) 1278 + .unwrap_or_default(), 1279 + ) 1280 + .await; 1281 + } 1282 + }, 1283 + Err(e) => { 1284 + let _ = tx 1285 + .send( 1286 + serde_json::to_string(&StreamErrorJson { 1287 + error: format!("connect: {e}"), 1288 + }) 1289 + .unwrap_or_default(), 1290 + ) 1291 + .await; 1292 + } 1293 + } 1294 + }) 1295 + } 1296 + 1297 + #[derive(Serialize)] 1298 + struct PlaylistEventJson { 1299 + index: i32, 1300 + amount: i32, 1301 + tracks: Vec<TrackJson>, 1302 + } 1303 + 1304 + /// Subscribes to `PlaybackService::StreamPlaylist`. Each event is a JSON 1305 + /// `{ index, amount, tracks: [...] }` snapshot of the queue. 1306 + #[no_mangle] 1307 + pub extern "C" fn rb_subscribe_playlist() -> c_int { 1308 + let server_url = url(); 1309 + spawn_stream(move |tx| async move { 1310 + match PlaybackServiceClient::connect(server_url).await { 1311 + Ok(mut c) => match c.stream_playlist(StreamPlaylistRequest {}).await { 1312 + Ok(resp) => { 1313 + let mut s = resp.into_inner(); 1314 + while let Ok(Some(msg)) = s.message().await { 1315 + let tracks: Vec<TrackJson> = msg 1316 + .tracks 1317 + .into_iter() 1318 + .map(|t| TrackJson { 1319 + id: t.id, 1320 + path: t.path, 1321 + title: t.title, 1322 + artist: t.artist, 1323 + album: t.album, 1324 + album_art: t.album_art.filter(|a| !a.is_empty()), 1325 + duration_ms: t.length as i64, 1326 + elapsed_ms: t.elapsed as i64, 1327 + }) 1328 + .collect(); 1329 + let payload = serde_json::to_string(&PlaylistEventJson { 1330 + index: msg.index, 1331 + amount: msg.amount, 1332 + tracks, 1333 + }) 1334 + .unwrap_or_else(|_| "{}".into()); 1335 + if tx.send(payload).await.is_err() { 1336 + break; 1337 + } 1338 + } 1339 + } 1340 + Err(e) => { 1341 + let _ = tx 1342 + .send( 1343 + serde_json::to_string(&StreamErrorJson { 1344 + error: format!("stream_playlist: {e}"), 1345 + }) 1346 + .unwrap_or_default(), 1347 + ) 1348 + .await; 1349 + } 1350 + }, 1351 + Err(e) => { 1352 + let _ = tx 1353 + .send( 1354 + serde_json::to_string(&StreamErrorJson { 1355 + error: format!("connect: {e}"), 1356 + }) 1357 + .unwrap_or_default(), 1358 + ) 1359 + .await; 1360 + } 1361 + } 1362 + }) 1363 + } 1364 + 1365 + // ── mDNS / Bonjour discovery ──────────────────────────────────────────────── 1366 + // 1367 + // Wraps the `rockbox-discovery` crate so the mobile app can find rockboxd 1368 + // instances on the local network. Each event is one resolved service: 1369 + // { "name", "fullname", "hostname", "port", "addresses": [...], "properties": {...} } 1370 + // Service names follow Bonjour conventions, e.g. "_rockbox._tcp.local.". 1371 + 1372 + #[derive(Serialize)] 1373 + struct DiscoveryEventJson { 1374 + name: String, 1375 + fullname: String, 1376 + hostname: String, 1377 + port: u16, 1378 + addresses: Vec<String>, 1379 + properties: HashMap<String, String>, 1380 + } 1381 + 1382 + /// Subscribes to mDNS / Bonjour discovery for `service_name` (e.g. the 1383 + /// constants exposed by `rockbox-discovery`: `_rockbox._tcp.local.`, 1384 + /// `_googlecast._tcp.local.`, etc.). Each resolved service triggers one 1385 + /// event payload. 1386 + /// 1387 + /// # Safety 1388 + /// `service_name_ptr` must be a valid NUL-terminated UTF-8 string. 1389 + #[no_mangle] 1390 + pub unsafe extern "C" fn rb_subscribe_discovery(service_name_ptr: *const c_char) -> c_int { 1391 + let Some(service_name) = cstr_to_str(service_name_ptr) else { 1392 + return -1; 1393 + }; 1394 + let service_name = service_name.to_string(); 1395 + spawn_stream(move |tx| async move { 1396 + let stream = rockbox_discovery::discover(&service_name); 1397 + pin_mut!(stream); 1398 + while let Some(info) = stream.next().await { 1399 + let payload = DiscoveryEventJson { 1400 + name: info.get_hostname().to_string(), 1401 + fullname: info.get_fullname().to_string(), 1402 + hostname: info.get_hostname().to_string(), 1403 + port: info.get_port(), 1404 + addresses: info.get_addresses().iter().map(|a| a.to_string()).collect(), 1405 + properties: info 1406 + .get_properties() 1407 + .iter() 1408 + .map(|(k, v)| (k.clone(), v.clone())) 1409 + .collect(), 1410 + }; 1411 + let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into()); 1412 + if tx.send(json).await.is_err() { 1413 + break; 1414 + } 1415 + } 1416 + }) 1417 + } 1418 + 1419 + /// Returns the well-known mDNS service name for the rockboxd gRPC daemon 1420 + /// (`_rockbox._tcp.local.`) as a heap C string. Free with `rb_free_string`. 1421 + #[no_mangle] 1422 + pub extern "C" fn rb_rockbox_service_name() -> *mut c_char { 1423 + CString::new(rockbox_discovery::ROCKBOX_SERVICE_NAME) 1424 + .map(|c| c.into_raw()) 1425 + .unwrap_or(std::ptr::null_mut()) 1426 + } 1427 + 1428 + /// Returns the well-known mDNS service name for Chromecast devices. 1429 + #[no_mangle] 1430 + pub extern "C" fn rb_chromecast_service_name() -> *mut c_char { 1431 + CString::new(rockbox_discovery::CHROMECAST_SERVICE_NAME) 1432 + .map(|c| c.into_raw()) 1433 + .unwrap_or(std::ptr::null_mut()) 1434 + } 1435 + 1436 + /// Subscribes to `LibraryService::StreamLibrary`. Each event is the full 1437 + /// (potentially large) library snapshot serialized as JSON. 1438 + #[no_mangle] 1439 + pub extern "C" fn rb_subscribe_library() -> c_int { 1440 + let server_url = url(); 1441 + spawn_stream(move |tx| async move { 1442 + match LibraryServiceClient::connect(server_url).await { 1443 + Ok(mut c) => match c.stream_library(StreamLibraryRequest {}).await { 1444 + Ok(resp) => { 1445 + let mut s = resp.into_inner(); 1446 + while let Ok(Some(msg)) = s.message().await { 1447 + let payload = serde_json::to_string(&msg).unwrap_or_else(|_| "{}".into()); 1448 + if tx.send(payload).await.is_err() { 1449 + break; 1450 + } 1451 + } 1452 + } 1453 + Err(e) => { 1454 + let _ = tx 1455 + .send( 1456 + serde_json::to_string(&StreamErrorJson { 1457 + error: format!("stream_library: {e}"), 1458 + }) 1459 + .unwrap_or_default(), 1460 + ) 1461 + .await; 1462 + } 1463 + }, 1464 + Err(e) => { 1465 + let _ = tx 1466 + .send( 1467 + serde_json::to_string(&StreamErrorJson { 1468 + error: format!("connect: {e}"), 1469 + }) 1470 + .unwrap_or_default(), 1471 + ) 1472 + .await; 1473 + } 1474 + } 1475 + }) 1476 + } 1477 + 1478 + /// Blocks up to `timeout_ms` waiting for the next event on subscription 1479 + /// `sub_id`. Returns a heap-owned JSON C string (free with `rb_free_string`), 1480 + /// or null on timeout / closed stream / unknown subscription. 1481 + #[no_mangle] 1482 + pub extern "C" fn rb_poll_event(sub_id: c_int, timeout_ms: c_int) -> *mut c_char { 1483 + // Take the receiver out of the map briefly so we don't hold the lock 1484 + // across the blocking await. Put it back unless it's been closed. 1485 + let mut rx = { 1486 + let mut map = match SUBS.write() { 1487 + Ok(m) => m, 1488 + Err(_) => return std::ptr::null_mut(), 1489 + }; 1490 + match map.remove(&sub_id) { 1491 + Some(s) => s, 1492 + None => return std::ptr::null_mut(), 1493 + } 1494 + }; 1495 + 1496 + let timeout = if timeout_ms < 0 { 1497 + Duration::from_secs(60 * 60) 1498 + } else { 1499 + Duration::from_millis(timeout_ms as u64) 1500 + }; 1501 + 1502 + let received = RT.block_on(async { tokio::time::timeout(timeout, rx.rx.recv()).await }); 1503 + 1504 + let payload = match received { 1505 + Ok(Some(msg)) => Some(msg), 1506 + Ok(None) => None, // sender dropped → stream closed 1507 + Err(_) => Some(String::new()), // marker: timeout, sub still alive 1508 + }; 1509 + 1510 + // Re-insert if the stream is still alive (any non-None payload). 1511 + if payload.is_some() { 1512 + if let Ok(mut map) = SUBS.write() { 1513 + map.insert(sub_id, rx); 1514 + } 1515 + } else { 1516 + // Stream closed — make sure the task is gone too. 1517 + rx.abort.abort(); 1518 + } 1519 + 1520 + match payload { 1521 + Some(s) if s.is_empty() => std::ptr::null_mut(), 1522 + Some(s) => CString::new(s) 1523 + .map(|c| c.into_raw()) 1524 + .unwrap_or(std::ptr::null_mut()), 1525 + None => std::ptr::null_mut(), 1526 + } 1527 + } 1528 + 1529 + /// Cancels and removes a subscription. Returns 0 if found, -1 otherwise. 1530 + #[no_mangle] 1531 + pub extern "C" fn rb_unsubscribe(sub_id: c_int) -> c_int { 1532 + let removed = match SUBS.write() { 1533 + Ok(mut m) => m.remove(&sub_id), 1534 + Err(_) => return -1, 1535 + }; 1536 + match removed { 1537 + Some(s) => { 1538 + s.abort.abort(); 1539 + 0 1540 + } 1541 + None => -1, 1542 + } 1543 + } 1544 + 1545 + // Reference values so the linker keeps the connect timeout default usable 1546 + // after stripping. (Defensive — some link configurations drop unused symbols.) 1547 + #[doc(hidden)] 1548 + pub fn _link_keepalive() -> Duration { 1549 + Duration::from_secs(5) 1550 + }
+3
expo/bun.lock
··· 33 33 "react-native-screens": "~4.16.0", 34 34 "react-native-web": "~0.21.0", 35 35 "react-native-worklets": "0.5.1", 36 + "rockbox-rpc": "file:./modules/rockbox-rpc", 36 37 }, 37 38 "devDependencies": { 38 39 "@types/react": "~19.1.0", ··· 1485 1486 "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], 1486 1487 1487 1488 "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], 1489 + 1490 + "rockbox-rpc": ["rockbox-rpc@file:modules/rockbox-rpc", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }], 1488 1491 1489 1492 "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 1490 1493
+356
expo/lib/rockbox-client.ts
··· 1 + /** 2 + * Thin wrapper around the `rockbox-rpc` native module. 3 + * 4 + * - On iOS / Android, delegates to the native module (which calls into the 5 + * `rockbox-expo` Rust crate via JNI / Swift's `@_silgen_name`). 6 + * - On web / when the native module isn't built yet, throws a friendly error 7 + * from `requireNativeModule`. Callers should guard with `isAvailable` and 8 + * fall back to the mock `PlayerProvider` ticker. 9 + */ 10 + import { Platform } from "react-native"; 11 + import type { 12 + DiscoveredService, 13 + PlaylistSnapshot, 14 + StatusSnapshot, 15 + StreamErrorEvent, 16 + TrackSnapshot, 17 + } from "rockbox-rpc"; 18 + 19 + let nativeModule: 20 + | typeof import("rockbox-rpc").default 21 + | null = null; 22 + 23 + if (Platform.OS === "ios" || Platform.OS === "android") { 24 + try { 25 + // eslint-disable-next-line @typescript-eslint/no-require-imports 26 + nativeModule = require("rockbox-rpc").default; 27 + } catch { 28 + nativeModule = null; 29 + } 30 + } 31 + 32 + export const isAvailable = nativeModule !== null; 33 + 34 + function require_(): NonNullable<typeof nativeModule> { 35 + if (!nativeModule) { 36 + throw new Error( 37 + "rockbox-rpc native module not available — run `bun run build:ios` or `build:android` from `expo/modules/rockbox-rpc/`", 38 + ); 39 + } 40 + return nativeModule; 41 + } 42 + 43 + export const RockboxClient = { 44 + isAvailable, 45 + 46 + setServerUrl(url: string) { 47 + require_().setServerUrl(url); 48 + }, 49 + 50 + ping(): Promise<boolean> { 51 + return require_().ping(); 52 + }, 53 + 54 + // Playback 55 + play() { 56 + return require_().play(); 57 + }, 58 + pause() { 59 + return require_().pause(); 60 + }, 61 + playPause() { 62 + return require_().playPause(); 63 + }, 64 + next() { 65 + return require_().next(); 66 + }, 67 + prev() { 68 + return require_().prev(); 69 + }, 70 + seek(positionMs: number) { 71 + return require_().seek(positionMs); 72 + }, 73 + 74 + // Read 75 + status(): Promise<StatusSnapshot> { 76 + return require_().status(); 77 + }, 78 + currentTrack(): Promise<TrackSnapshot> { 79 + return require_().currentTrack(); 80 + }, 81 + 82 + // Playback (extended) 83 + resumeTrack() { 84 + return require_().resumeTrack(); 85 + }, 86 + playlistResume() { 87 + return require_().playlistResume(); 88 + }, 89 + playAllTracks() { 90 + return require_().playAllTracks(); 91 + }, 92 + playTrack(path: string) { 93 + return require_().playTrack(path); 94 + }, 95 + playAlbum(albumId: string, shuffle = false) { 96 + return require_().playAlbum(albumId, shuffle); 97 + }, 98 + playArtistTracks(artistId: string, shuffle = false) { 99 + return require_().playArtistTracks(artistId, shuffle); 100 + }, 101 + playDirectory(path: string, shuffle = false, position = -1) { 102 + return require_().playDirectory(path, shuffle, position); 103 + }, 104 + 105 + // Queue 106 + jumpToQueuePosition(pos: number) { 107 + return require_().jumpToQueuePosition(pos); 108 + }, 109 + shufflePlaylistAtStart() { 110 + return require_().shufflePlaylist(); 111 + }, 112 + insertTracks(paths: string[], position: number, shuffle = false) { 113 + return require_().insertTracks(paths, position, shuffle); 114 + }, 115 + insertTrackNext(path: string) { 116 + return require_().insertTrackNext(path); 117 + }, 118 + insertTrackLast(path: string) { 119 + return require_().insertTrackLast(path); 120 + }, 121 + insertDirectory(path: string, position: number) { 122 + return require_().insertDirectory(path, position); 123 + }, 124 + removeFromQueue(pos: number) { 125 + return require_().removeFromQueue(pos); 126 + }, 127 + getPlaylistCurrent() { 128 + return require_().getPlaylistCurrent(); 129 + }, 130 + 131 + // Library 132 + likeTrack(id: string) { 133 + return require_().likeTrack(id); 134 + }, 135 + unlikeTrack(id: string) { 136 + return require_().unlikeTrack(id); 137 + }, 138 + getTracks() { 139 + return require_().getTracks(); 140 + }, 141 + getArtists() { 142 + return require_().getArtists(); 143 + }, 144 + getAlbum(id: string) { 145 + return require_().getAlbum(id); 146 + }, 147 + getLikedTracks() { 148 + return require_().getLikedTracks(); 149 + }, 150 + search(term: string) { 151 + return require_().search(term); 152 + }, 153 + 154 + // Sound 155 + adjustVolume(steps: number) { 156 + return require_().adjustVolume(steps); 157 + }, 158 + soundCurrent(setting: number) { 159 + return require_().soundCurrent(setting); 160 + }, 161 + 162 + // Settings 163 + saveShuffle(enabled: boolean) { 164 + return require_().saveShuffle(enabled); 165 + }, 166 + saveRepeat(mode: number) { 167 + return require_().saveRepeat(mode); 168 + }, 169 + getGlobalSettings() { 170 + return require_().getGlobalSettings(); 171 + }, 172 + getGlobalStatus() { 173 + return require_().getGlobalStatus(); 174 + }, 175 + 176 + // Browse 177 + treeGetEntries(path: string | null = null) { 178 + return require_().treeGetEntries(path); 179 + }, 180 + 181 + // Saved playlists 182 + getSavedPlaylists() { 183 + return require_().getSavedPlaylists(); 184 + }, 185 + createSavedPlaylist( 186 + name: string, 187 + description: string | null = null, 188 + trackIds: string[] = [], 189 + ) { 190 + return require_().createSavedPlaylist(name, description, trackIds); 191 + }, 192 + updateSavedPlaylist(id: string, name: string, description: string | null = null) { 193 + return require_().updateSavedPlaylist(id, name, description); 194 + }, 195 + deleteSavedPlaylist(id: string) { 196 + return require_().deleteSavedPlaylist(id); 197 + }, 198 + addTrackToPlaylist(playlistId: string, trackId: string) { 199 + return require_().addTrackToPlaylist(playlistId, trackId); 200 + }, 201 + removeTrackFromPlaylist(playlistId: string, trackId: string) { 202 + return require_().removeTrackFromPlaylist(playlistId, trackId); 203 + }, 204 + getSavedPlaylistTracks(playlistId: string) { 205 + return require_().getSavedPlaylistTracks(playlistId); 206 + }, 207 + playSavedPlaylist(playlistId: string) { 208 + return require_().playSavedPlaylist(playlistId); 209 + }, 210 + 211 + // Smart playlists 212 + getSmartPlaylists() { 213 + return require_().getSmartPlaylists(); 214 + }, 215 + getSmartPlaylistTracks(id: string) { 216 + return require_().getSmartPlaylistTracks(id); 217 + }, 218 + playSmartPlaylist(id: string) { 219 + return require_().playSmartPlaylist(id); 220 + }, 221 + 222 + // Bluetooth 223 + bluetoothAvailable() { 224 + return require_().bluetoothAvailable(); 225 + }, 226 + getBluetoothDevices() { 227 + return require_().getBluetoothDevices(); 228 + }, 229 + connectBluetooth(address: string) { 230 + return require_().connectBluetooth(address); 231 + }, 232 + disconnectBluetooth(address: string) { 233 + return require_().disconnectBluetooth(address); 234 + }, 235 + 236 + // mDNS service-name constants 237 + rockboxServiceName() { 238 + return require_().rockboxServiceName(); 239 + }, 240 + chromecastServiceName() { 241 + return require_().chromecastServiceName(); 242 + }, 243 + 244 + // ── Streaming subscriptions ───────────────────────────────────────────── 245 + // Each helper returns an unsubscribe function that tears down both the 246 + // event listener and the native subscription. 247 + 248 + subscribeStatus( 249 + onEvent: (s: StatusSnapshot) => void, 250 + onError?: (e: StreamErrorEvent) => void, 251 + ): () => void { 252 + const m = require_(); 253 + const subId = m.subscribeStatus(); 254 + const evtSub = m.addListener("rockbox.status", onEvent); 255 + const errSub = onError 256 + ? m.addListener("rockbox.error", (e) => { 257 + if (e.subId === subId) onError(e); 258 + }) 259 + : null; 260 + return () => { 261 + evtSub.remove(); 262 + errSub?.remove(); 263 + m.unsubscribe(subId); 264 + }; 265 + }, 266 + 267 + subscribeCurrentTrack( 268 + onEvent: (t: TrackSnapshot) => void, 269 + onError?: (e: StreamErrorEvent) => void, 270 + ): () => void { 271 + const m = require_(); 272 + const subId = m.subscribeCurrentTrack(); 273 + const evtSub = m.addListener("rockbox.currentTrack", onEvent); 274 + const errSub = onError 275 + ? m.addListener("rockbox.error", (e) => { 276 + if (e.subId === subId) onError(e); 277 + }) 278 + : null; 279 + return () => { 280 + evtSub.remove(); 281 + errSub?.remove(); 282 + m.unsubscribe(subId); 283 + }; 284 + }, 285 + 286 + subscribePlaylist( 287 + onEvent: (p: PlaylistSnapshot) => void, 288 + onError?: (e: StreamErrorEvent) => void, 289 + ): () => void { 290 + const m = require_(); 291 + const subId = m.subscribePlaylist(); 292 + const evtSub = m.addListener("rockbox.playlist", onEvent); 293 + const errSub = onError 294 + ? m.addListener("rockbox.error", (e) => { 295 + if (e.subId === subId) onError(e); 296 + }) 297 + : null; 298 + return () => { 299 + evtSub.remove(); 300 + errSub?.remove(); 301 + m.unsubscribe(subId); 302 + }; 303 + }, 304 + 305 + subscribeLibrary( 306 + onEvent: (snapshot: unknown) => void, 307 + onError?: (e: StreamErrorEvent) => void, 308 + ): () => void { 309 + const m = require_(); 310 + const subId = m.subscribeLibrary(); 311 + const evtSub = m.addListener("rockbox.library", onEvent); 312 + const errSub = onError 313 + ? m.addListener("rockbox.error", (e) => { 314 + if (e.subId === subId) onError(e); 315 + }) 316 + : null; 317 + return () => { 318 + evtSub.remove(); 319 + errSub?.remove(); 320 + m.unsubscribe(subId); 321 + }; 322 + }, 323 + 324 + /** 325 + * Discover rockbox / Chromecast / arbitrary mDNS services on the LAN. 326 + * Defaults to `_rockbox._tcp.local.` when `serviceName` is omitted. 327 + */ 328 + subscribeDiscovery( 329 + onEvent: (service: DiscoveredService) => void, 330 + onError?: (e: StreamErrorEvent) => void, 331 + serviceName?: string, 332 + ): () => void { 333 + const m = require_(); 334 + const name = serviceName ?? m.rockboxServiceName(); 335 + const subId = m.subscribeDiscovery(name); 336 + const evtSub = m.addListener("rockbox.discovery", onEvent); 337 + const errSub = onError 338 + ? m.addListener("rockbox.error", (e) => { 339 + if (e.subId === subId) onError(e); 340 + }) 341 + : null; 342 + return () => { 343 + evtSub.remove(); 344 + errSub?.remove(); 345 + m.unsubscribe(subId); 346 + }; 347 + }, 348 + }; 349 + 350 + export type { 351 + DiscoveredService, 352 + PlaylistSnapshot, 353 + StatusSnapshot, 354 + StreamErrorEvent, 355 + TrackSnapshot, 356 + };
+221
expo/modules/rockbox-rpc/README.md
··· 1 + # `rockbox-rpc` — Expo native module 2 + 3 + Expo Modules wrapper around the [`rockbox-expo`](../../../crates/expo/) Rust 4 + crate. Exposes the rockboxd gRPC client and the mDNS discovery surface to 5 + the React Native app under one TypeScript API. 6 + 7 + ```ts 8 + import RockboxRpc from "rockbox-rpc"; 9 + // or via the helper that adds an `isAvailable` guard: 10 + import { RockboxClient } from "@/lib/rockbox-client"; 11 + ``` 12 + 13 + This module is autolinked into [`expo/`](../..) via 14 + `"rockbox-rpc": "file:./modules/rockbox-rpc"` in `expo/package.json`. A 15 + `postinstall` hook (`expo/scripts/link-rockbox-rpc.js`) replaces the 16 + copied directory in `node_modules/` with a live symlink so edits to the 17 + TypeScript / Swift / Kotlin source show up immediately. 18 + 19 + ## Layout 20 + 21 + ``` 22 + modules/rockbox-rpc/ 23 + ├── expo-module.config.json declares iOS + Android module classes 24 + ├── package.json local "file:" dependency 25 + ├── src/index.ts TypeScript surface (types + native interface) 26 + ├── ios/ 27 + │ ├── RockboxRpc.podspec vendored xcframework + ExpoModulesCore dep 28 + │ └── RockboxRpcModule.swift @_silgen_name extern declarations + bindings 29 + ├── android/ 30 + │ ├── build.gradle jniLibs.srcDirs wired to src/main/jniLibs 31 + │ └── src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt 32 + └── scripts/ 33 + ├── build-ios.sh cross-compile + lipo + xcframework bundle 34 + └── build-android.sh cargo-ndk → jniLibs/<abi>/librockbox_expo.so 35 + ``` 36 + 37 + ## Building the native libs 38 + 39 + You only need to run these once per code change in `crates/expo/`: 40 + 41 + ```sh 42 + # iOS — produces ios/RockboxExpo.xcframework 43 + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 44 + bun run build:ios 45 + 46 + # Android — produces android/src/main/jniLibs/<abi>/librockbox_expo.so 47 + cargo install cargo-ndk 48 + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 49 + export ANDROID_NDK_HOME=... # NDK r25+ 50 + bun run build:android 51 + ``` 52 + 53 + After the artifacts are in place, run `bunx expo prebuild` and then 54 + `bunx expo run:ios` / `run:android` from the `expo/` root. 55 + 56 + ## API surface 57 + 58 + ### Init / health 59 + 60 + ```ts 61 + RockboxClient.setServerUrl("http://192.168.1.10:6061"); // call once at startup 62 + await RockboxClient.ping(); // → true on success 63 + ``` 64 + 65 + `isAvailable` is `false` on web / before the native libs are built; the JS 66 + helper falls back to throwing a friendly error so the rest of the app can 67 + keep using the mock `PlayerProvider`. 68 + 69 + ### Playback 70 + 71 + ```ts 72 + await RockboxClient.play(); 73 + await RockboxClient.pause(); 74 + await RockboxClient.playPause(); 75 + await RockboxClient.next(); 76 + await RockboxClient.prev(); 77 + await RockboxClient.seek(positionMs); 78 + 79 + await RockboxClient.playTrack("/Music/foo.flac"); 80 + await RockboxClient.playAlbum(albumId, /* shuffle */ true); 81 + await RockboxClient.playArtistTracks(artistId, false); 82 + await RockboxClient.playAllTracks(); 83 + await RockboxClient.playDirectory(path, false, /* position = -1 means "no override" */ -1); 84 + ``` 85 + 86 + ### Queue 87 + 88 + ```ts 89 + await RockboxClient.jumpToQueuePosition(idx); 90 + await RockboxClient.insertTracks(paths, /* position */ -3, /* shuffle */ false); 91 + await RockboxClient.insertTrackNext(path); 92 + await RockboxClient.insertTrackLast(path); 93 + await RockboxClient.insertDirectory(path, position); 94 + await RockboxClient.removeFromQueue(pos); 95 + await RockboxClient.shufflePlaylistAtStart(); 96 + 97 + const snapshot = await RockboxClient.getPlaylistCurrent(); 98 + ``` 99 + 100 + ### Library / search 101 + 102 + ```ts 103 + const tracks = await RockboxClient.getTracks(); 104 + const artists = await RockboxClient.getArtists(); 105 + const album = await RockboxClient.getAlbum(id); 106 + const liked = await RockboxClient.getLikedTracks(); 107 + const results = await RockboxClient.search("aphex twin"); 108 + 109 + await RockboxClient.likeTrack(trackId); 110 + await RockboxClient.unlikeTrack(trackId); 111 + ``` 112 + 113 + ### Sound / settings 114 + 115 + ```ts 116 + await RockboxClient.adjustVolume(+2); 117 + const v = await RockboxClient.soundCurrent(/* SOUND_VOLUME = 0 */ 0); 118 + 119 + await RockboxClient.saveShuffle(true); 120 + await RockboxClient.saveRepeat(/* off=0 all=1 one=2 */ 1); 121 + 122 + const status = await RockboxClient.getGlobalStatus(); 123 + const settings = await RockboxClient.getGlobalSettings(); 124 + ``` 125 + 126 + ### Saved / smart playlists 127 + 128 + ```ts 129 + const playlists = await RockboxClient.getSavedPlaylists(); 130 + await RockboxClient.createSavedPlaylist("Faves", "best of 2024", [trackId1, trackId2]); 131 + await RockboxClient.updateSavedPlaylist(id, "New name", null); 132 + await RockboxClient.deleteSavedPlaylist(id); 133 + await RockboxClient.addTrackToPlaylist(playlistId, trackId); 134 + await RockboxClient.removeTrackFromPlaylist(playlistId, trackId); 135 + const trackIds = await RockboxClient.getSavedPlaylistTracks(playlistId); 136 + await RockboxClient.playSavedPlaylist(playlistId); 137 + 138 + const smart = await RockboxClient.getSmartPlaylists(); 139 + await RockboxClient.playSmartPlaylist(id); 140 + ``` 141 + 142 + ### Browse + Bluetooth 143 + 144 + ```ts 145 + const root = await RockboxClient.treeGetEntries(null); 146 + const sub = await RockboxClient.treeGetEntries("/Music/Albums"); 147 + 148 + if (await RockboxClient.bluetoothAvailable()) { 149 + const devices = await RockboxClient.getBluetoothDevices(); 150 + await RockboxClient.connectBluetooth(address); 151 + await RockboxClient.disconnectBluetooth(address); 152 + } 153 + ``` 154 + 155 + ### Streaming subscriptions 156 + 157 + Each helper returns a single `() => void` that tears down both the event 158 + listener and the native subscription: 159 + 160 + ```ts 161 + const unsubStatus = RockboxClient.subscribeStatus( 162 + (s) => setStatus(s.status), 163 + (e) => console.warn("status stream error", e.error), 164 + ); 165 + 166 + const unsubTrack = RockboxClient.subscribeCurrentTrack((t) => setTrack(t)); 167 + const unsubQueue = RockboxClient.subscribePlaylist((p) => setQueue(p.tracks)); 168 + const unsubLib = RockboxClient.subscribeLibrary((snapshot) => /* refresh */ null); 169 + 170 + // LAN scan — defaults to "_rockbox._tcp.local."; pass any other Bonjour name 171 + // for Chromecast etc. 172 + const unsubScan = RockboxClient.subscribeDiscovery( 173 + (svc) => console.log("found", svc.name, svc.addresses, svc.port), 174 + undefined, 175 + RockboxClient.chromecastServiceName(), 176 + ); 177 + 178 + // later: 179 + unsubStatus(); unsubTrack(); unsubQueue(); unsubLib(); unsubScan(); 180 + ``` 181 + 182 + Topics surfaced through the EventEmitter base class: 183 + 184 + | Topic | Payload | 185 + |-------|---------| 186 + | `rockbox.status` | `{ status: 0|1|2 }` | 187 + | `rockbox.currentTrack` | `TrackSnapshot` | 188 + | `rockbox.playlist` | `PlaylistSnapshot` (`index`, `amount`, `tracks`) | 189 + | `rockbox.library` | full library snapshot (`unknown` — cast as needed) | 190 + | `rockbox.discovery` | `DiscoveredService` (`name`, `hostname`, `port`, `addresses[]`, `properties{}`) | 191 + | `rockbox.error` | `{ subId, stream, error }` | 192 + 193 + ## Adding a new method 194 + 195 + 1. Add the `rb_<name>` wrapper in [`crates/expo/src/lib.rs`](../../../crates/expo/src/lib.rs). 196 + 2. Declare the symbol on both platforms: 197 + - Swift (`ios/RockboxRpcModule.swift`): `@_silgen_name(...) private func ...` 198 + - Kotlin (`android/src/main/java/.../RockboxRpcModule.kt`): `@JvmStatic external fun ...` 199 + 3. Add a `Function` / `AsyncFunction` binding in each platform module. 200 + 4. Add the typed method to `src/index.ts` (`RockboxRpcNative`) and a 201 + one-line forwarder on `RockboxClient` in `expo/lib/rockbox-client.ts`. 202 + 5. Rebuild the native libs (`bun run build:ios` / `build:android`). 203 + 204 + ## Troubleshooting 205 + 206 + - **`requireNativeModule("RockboxRpc")` fails** — the native libs aren't 207 + built. Run `bun run build:ios` / `build:android` (and re-prebuild the 208 + iOS / Android projects). 209 + - **`UnsatisfiedLinkError: librockbox_expo.so`** — the `.so` for the 210 + current ABI is missing from `android/src/main/jniLibs/`. Make sure 211 + `cargo-ndk` ran successfully for that ABI. 212 + - **iOS simulator can't find symbols** — the xcframework needs both the 213 + device and simulator slices. Check that `lipo -info` reports both 214 + `arm64` and `x86_64` for the simulator library before xcframework packing. 215 + - **Linker error referencing `_dispatch_main_q`** — link `c++` and `resolv` 216 + (already declared in the podspec). 217 + - **TypeScript can't find `rockbox-rpc`** — bun copied the dir instead of 218 + symlinking it; the `postinstall` hook replaces it. Re-run `bun install`. 219 + 220 + See [`crates/expo/README.md`](../../../crates/expo/README.md) for the 221 + full list of FFI symbols and the Rust-side conventions.
+1
expo/modules/rockbox-rpc/android/.gitignore
··· 1 + build/
+27
expo/modules/rockbox-rpc/android/build.gradle
··· 1 + plugins { 2 + id 'com.android.library' 3 + id 'expo-module-gradle-plugin' 4 + } 5 + 6 + group = 'expo.modules.rockboxrpc' 7 + version = '0.1.0' 8 + 9 + android { 10 + namespace 'expo.modules.rockboxrpc' 11 + defaultConfig { 12 + minSdkVersion 24 13 + versionCode 1 14 + versionName '0.1.0' 15 + } 16 + // The .so artifacts produced by scripts/build-android.sh are dropped into 17 + // src/main/jniLibs/<abi>/ — link them directly into the AAR. 18 + sourceSets { 19 + main { 20 + jniLibs.srcDirs = ['src/main/jniLibs'] 21 + } 22 + } 23 + } 24 + 25 + dependencies { 26 + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' 27 + }
+4
expo/modules/rockbox-rpc/android/src/main/AndroidManifest.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 3 + <uses-permission android:name="android.permission.INTERNET" /> 4 + </manifest>
+414
expo/modules/rockbox-rpc/android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt
··· 1 + package expo.modules.rockboxrpc 2 + 3 + import expo.modules.kotlin.modules.Module 4 + import expo.modules.kotlin.modules.ModuleDefinition 5 + import kotlinx.coroutines.CoroutineScope 6 + import kotlinx.coroutines.Dispatchers 7 + import kotlinx.coroutines.Job 8 + import kotlinx.coroutines.cancel 9 + import kotlinx.coroutines.isActive 10 + import kotlinx.coroutines.launch 11 + import org.json.JSONObject 12 + import java.util.concurrent.ConcurrentHashMap 13 + 14 + /** 15 + * Native module wrapping the rockbox-expo Rust crate (built into a .so per ABI 16 + * and dropped into android/src/main/jniLibs/). 17 + * 18 + * The C ABI is a 1:1 mapping of `rb_*` functions in `crates/expo/src/lib.rs`. 19 + * String returns are heap-owned in Rust and freed by `rb_free_string` on the 20 + * Kotlin side after copying. 21 + */ 22 + class RockboxRpcModule : Module() { 23 + companion object { 24 + init { 25 + System.loadLibrary("rockbox_expo") 26 + } 27 + 28 + @JvmStatic external fun rb_set_server_url(url: String): Int 29 + @JvmStatic external fun rb_ping(): Int 30 + @JvmStatic external fun rb_play(): Int 31 + @JvmStatic external fun rb_pause(): Int 32 + @JvmStatic external fun rb_play_pause(): Int 33 + @JvmStatic external fun rb_next(): Int 34 + @JvmStatic external fun rb_prev(): Int 35 + @JvmStatic external fun rb_seek(positionMs: Int): Int 36 + @JvmStatic external fun rb_status_json(): String? 37 + @JvmStatic external fun rb_current_track_json(): String? 38 + @JvmStatic external fun rb_like_track(id: String): Int 39 + @JvmStatic external fun rb_unlike_track(id: String): Int 40 + 41 + @JvmStatic external fun rb_subscribe_status(): Int 42 + @JvmStatic external fun rb_subscribe_current_track(): Int 43 + @JvmStatic external fun rb_subscribe_playlist(): Int 44 + @JvmStatic external fun rb_subscribe_library(): Int 45 + @JvmStatic external fun rb_subscribe_discovery(serviceName: String): Int 46 + @JvmStatic external fun rb_rockbox_service_name(): String? 47 + @JvmStatic external fun rb_chromecast_service_name(): String? 48 + @JvmStatic external fun rb_poll_event(subId: Int, timeoutMs: Int): String? 49 + @JvmStatic external fun rb_unsubscribe(subId: Int): Int 50 + 51 + @JvmStatic external fun rb_resume_track(): Int 52 + @JvmStatic external fun rb_playlist_resume(): Int 53 + @JvmStatic external fun rb_play_all_tracks(): Int 54 + @JvmStatic external fun rb_play_track(path: String): Int 55 + @JvmStatic external fun rb_play_album(id: String, shuffle: Int): Int 56 + @JvmStatic external fun rb_play_artist_tracks(id: String, shuffle: Int): Int 57 + @JvmStatic external fun rb_play_directory(path: String, shuffle: Int, position: Int): Int 58 + 59 + @JvmStatic external fun rb_jump_to_queue_position(pos: Int): Int 60 + @JvmStatic external fun rb_shuffle_playlist(): Int 61 + @JvmStatic external fun rb_insert_tracks(pathsJson: String, position: Int, shuffle: Int): Int 62 + @JvmStatic external fun rb_insert_track_next(path: String): Int 63 + @JvmStatic external fun rb_insert_track_last(path: String): Int 64 + @JvmStatic external fun rb_insert_directory(path: String, position: Int): Int 65 + @JvmStatic external fun rb_remove_from_queue(pos: Int): Int 66 + @JvmStatic external fun rb_get_playlist_current_json(): String? 67 + 68 + @JvmStatic external fun rb_get_tracks_json(): String? 69 + @JvmStatic external fun rb_get_artists_json(): String? 70 + @JvmStatic external fun rb_get_album_json(id: String): String? 71 + @JvmStatic external fun rb_get_liked_tracks_json(): String? 72 + @JvmStatic external fun rb_search_json(term: String): String? 73 + 74 + @JvmStatic external fun rb_adjust_volume(steps: Int): Int 75 + @JvmStatic external fun rb_sound_current_json(setting: Int): String? 76 + 77 + @JvmStatic external fun rb_save_shuffle(enabled: Int): Int 78 + @JvmStatic external fun rb_save_repeat(mode: Int): Int 79 + @JvmStatic external fun rb_get_global_settings_json(): String? 80 + @JvmStatic external fun rb_get_global_status_json(): String? 81 + 82 + @JvmStatic external fun rb_tree_get_entries_json(path: String?): String? 83 + 84 + @JvmStatic external fun rb_get_saved_playlists_json(): String? 85 + @JvmStatic external fun rb_create_saved_playlist(name: String, description: String?, idsJson: String?): Int 86 + @JvmStatic external fun rb_update_saved_playlist(id: String, name: String, description: String?): Int 87 + @JvmStatic external fun rb_delete_saved_playlist(id: String): Int 88 + @JvmStatic external fun rb_add_track_to_playlist(playlistId: String, trackId: String): Int 89 + @JvmStatic external fun rb_remove_track_from_playlist(playlistId: String, trackId: String): Int 90 + @JvmStatic external fun rb_get_saved_playlist_tracks_json(playlistId: String): String? 91 + @JvmStatic external fun rb_play_saved_playlist(playlistId: String): Int 92 + 93 + @JvmStatic external fun rb_get_smart_playlists_json(): String? 94 + @JvmStatic external fun rb_get_smart_playlist_tracks_json(id: String): String? 95 + @JvmStatic external fun rb_play_smart_playlist(id: String): Int 96 + 97 + @JvmStatic external fun rb_bluetooth_available(): Int 98 + @JvmStatic external fun rb_get_bluetooth_devices_json(): String? 99 + @JvmStatic external fun rb_connect_bluetooth(address: String): Int 100 + @JvmStatic external fun rb_disconnect_bluetooth(address: String): Int 101 + } 102 + 103 + private val scope = CoroutineScope(Dispatchers.IO) 104 + private val pollJobs = ConcurrentHashMap<Int, Job>() 105 + 106 + override fun definition() = ModuleDefinition { 107 + Name("RockboxRpc") 108 + 109 + Events( 110 + "rockbox.status", 111 + "rockbox.currentTrack", 112 + "rockbox.playlist", 113 + "rockbox.library", 114 + "rockbox.discovery", 115 + "rockbox.error", 116 + ) 117 + 118 + Function("rockboxServiceName") { 119 + rb_rockbox_service_name() ?: "_rockbox._tcp.local." 120 + } 121 + Function("chromecastServiceName") { 122 + rb_chromecast_service_name() ?: "_googlecast._tcp.local." 123 + } 124 + 125 + Function("setServerUrl") { url: String -> 126 + rb_set_server_url(url) 127 + } 128 + 129 + AsyncFunction("ping") { 130 + rb_ping() == 0 131 + } 132 + 133 + AsyncFunction("play") { if (rb_play() != 0) throw RpcError("play") } 134 + AsyncFunction("pause") { if (rb_pause() != 0) throw RpcError("pause") } 135 + AsyncFunction("playPause") { if (rb_play_pause() != 0) throw RpcError("playPause") } 136 + AsyncFunction("next") { if (rb_next() != 0) throw RpcError("next") } 137 + AsyncFunction("prev") { if (rb_prev() != 0) throw RpcError("prev") } 138 + AsyncFunction("seek") { positionMs: Int -> 139 + if (rb_seek(positionMs) != 0) throw RpcError("seek") 140 + } 141 + 142 + AsyncFunction("status") { 143 + val json = rb_status_json() ?: throw RpcError("status") 144 + jsonToMap(json) 145 + } 146 + 147 + AsyncFunction("currentTrack") { 148 + val json = rb_current_track_json() ?: throw RpcError("currentTrack") 149 + jsonToMap(json) 150 + } 151 + 152 + AsyncFunction("likeTrack") { id: String -> 153 + if (rb_like_track(id) != 0) throw RpcError("likeTrack") 154 + } 155 + 156 + AsyncFunction("unlikeTrack") { id: String -> 157 + if (rb_unlike_track(id) != 0) throw RpcError("unlikeTrack") 158 + } 159 + 160 + // ── Comprehensive RPC surface ─────────────────────────────────────────── 161 + AsyncFunction("resumeTrack") { if (rb_resume_track() != 0) throw RpcError("resumeTrack") } 162 + AsyncFunction("playlistResume") { if (rb_playlist_resume() != 0) throw RpcError("playlistResume") } 163 + AsyncFunction("playAllTracks") { if (rb_play_all_tracks() != 0) throw RpcError("playAllTracks") } 164 + AsyncFunction("playTrack") { path: String -> 165 + if (rb_play_track(path) != 0) throw RpcError("playTrack") 166 + } 167 + AsyncFunction("playAlbum") { id: String, shuffle: Boolean -> 168 + if (rb_play_album(id, if (shuffle) 1 else 0) != 0) throw RpcError("playAlbum") 169 + } 170 + AsyncFunction("playArtistTracks") { id: String, shuffle: Boolean -> 171 + if (rb_play_artist_tracks(id, if (shuffle) 1 else 0) != 0) throw RpcError("playArtistTracks") 172 + } 173 + AsyncFunction("playDirectory") { path: String, shuffle: Boolean, position: Int -> 174 + if (rb_play_directory(path, if (shuffle) 1 else 0, position) != 0) throw RpcError("playDirectory") 175 + } 176 + 177 + AsyncFunction("jumpToQueuePosition") { pos: Int -> 178 + if (rb_jump_to_queue_position(pos) != 0) throw RpcError("jumpToQueuePosition") 179 + } 180 + AsyncFunction("shufflePlaylist") { 181 + if (rb_shuffle_playlist() != 0) throw RpcError("shufflePlaylist") 182 + } 183 + AsyncFunction("insertTracks") { paths: List<String>, position: Int, shuffle: Boolean -> 184 + val arr = org.json.JSONArray() 185 + for (p in paths) arr.put(p) 186 + if (rb_insert_tracks(arr.toString(), position, if (shuffle) 1 else 0) != 0) 187 + throw RpcError("insertTracks") 188 + } 189 + AsyncFunction("insertTrackNext") { path: String -> 190 + if (rb_insert_track_next(path) != 0) throw RpcError("insertTrackNext") 191 + } 192 + AsyncFunction("insertTrackLast") { path: String -> 193 + if (rb_insert_track_last(path) != 0) throw RpcError("insertTrackLast") 194 + } 195 + AsyncFunction("insertDirectory") { path: String, position: Int -> 196 + if (rb_insert_directory(path, position) != 0) throw RpcError("insertDirectory") 197 + } 198 + AsyncFunction("removeFromQueue") { pos: Int -> 199 + if (rb_remove_from_queue(pos) != 0) throw RpcError("removeFromQueue") 200 + } 201 + 202 + AsyncFunction("getPlaylistCurrent") { 203 + parseJsonOrThrow(rb_get_playlist_current_json(), "getPlaylistCurrent") 204 + } 205 + AsyncFunction("getTracks") { 206 + parseJsonOrThrow(rb_get_tracks_json(), "getTracks") 207 + } 208 + AsyncFunction("getArtists") { 209 + parseJsonOrThrow(rb_get_artists_json(), "getArtists") 210 + } 211 + AsyncFunction("getAlbum") { id: String -> 212 + parseJsonOrThrow(rb_get_album_json(id), "getAlbum") 213 + } 214 + AsyncFunction("getLikedTracks") { 215 + parseJsonOrThrow(rb_get_liked_tracks_json(), "getLikedTracks") 216 + } 217 + AsyncFunction("search") { term: String -> 218 + parseJsonOrThrow(rb_search_json(term), "search") 219 + } 220 + 221 + AsyncFunction("adjustVolume") { steps: Int -> 222 + if (rb_adjust_volume(steps) != 0) throw RpcError("adjustVolume") 223 + } 224 + AsyncFunction("soundCurrent") { setting: Int -> 225 + parseJsonOrThrow(rb_sound_current_json(setting), "soundCurrent") 226 + } 227 + 228 + AsyncFunction("saveShuffle") { enabled: Boolean -> 229 + if (rb_save_shuffle(if (enabled) 1 else 0) != 0) throw RpcError("saveShuffle") 230 + } 231 + AsyncFunction("saveRepeat") { mode: Int -> 232 + if (rb_save_repeat(mode) != 0) throw RpcError("saveRepeat") 233 + } 234 + AsyncFunction("getGlobalSettings") { 235 + parseJsonOrThrow(rb_get_global_settings_json(), "getGlobalSettings") 236 + } 237 + AsyncFunction("getGlobalStatus") { 238 + parseJsonOrThrow(rb_get_global_status_json(), "getGlobalStatus") 239 + } 240 + 241 + AsyncFunction("treeGetEntries") { path: String? -> 242 + parseJsonOrThrow(rb_tree_get_entries_json(path), "treeGetEntries") 243 + } 244 + 245 + AsyncFunction("getSavedPlaylists") { 246 + parseJsonOrThrow(rb_get_saved_playlists_json(), "getSavedPlaylists") 247 + } 248 + AsyncFunction("createSavedPlaylist") { name: String, description: String?, trackIds: List<String> -> 249 + val arr = org.json.JSONArray() 250 + for (id in trackIds) arr.put(id) 251 + if (rb_create_saved_playlist(name, description, arr.toString()) != 0) 252 + throw RpcError("createSavedPlaylist") 253 + } 254 + AsyncFunction("updateSavedPlaylist") { id: String, name: String, description: String? -> 255 + if (rb_update_saved_playlist(id, name, description) != 0) throw RpcError("updateSavedPlaylist") 256 + } 257 + AsyncFunction("deleteSavedPlaylist") { id: String -> 258 + if (rb_delete_saved_playlist(id) != 0) throw RpcError("deleteSavedPlaylist") 259 + } 260 + AsyncFunction("addTrackToPlaylist") { playlistId: String, trackId: String -> 261 + if (rb_add_track_to_playlist(playlistId, trackId) != 0) throw RpcError("addTrackToPlaylist") 262 + } 263 + AsyncFunction("removeTrackFromPlaylist") { playlistId: String, trackId: String -> 264 + if (rb_remove_track_from_playlist(playlistId, trackId) != 0) throw RpcError("removeTrackFromPlaylist") 265 + } 266 + AsyncFunction("getSavedPlaylistTracks") { playlistId: String -> 267 + parseJsonOrThrow(rb_get_saved_playlist_tracks_json(playlistId), "getSavedPlaylistTracks") 268 + } 269 + AsyncFunction("playSavedPlaylist") { playlistId: String -> 270 + if (rb_play_saved_playlist(playlistId) != 0) throw RpcError("playSavedPlaylist") 271 + } 272 + 273 + AsyncFunction("getSmartPlaylists") { 274 + parseJsonOrThrow(rb_get_smart_playlists_json(), "getSmartPlaylists") 275 + } 276 + AsyncFunction("getSmartPlaylistTracks") { id: String -> 277 + parseJsonOrThrow(rb_get_smart_playlist_tracks_json(id), "getSmartPlaylistTracks") 278 + } 279 + AsyncFunction("playSmartPlaylist") { id: String -> 280 + if (rb_play_smart_playlist(id) != 0) throw RpcError("playSmartPlaylist") 281 + } 282 + 283 + AsyncFunction("bluetoothAvailable") { rb_bluetooth_available() == 1 } 284 + AsyncFunction("getBluetoothDevices") { 285 + parseJsonOrThrow(rb_get_bluetooth_devices_json(), "getBluetoothDevices") 286 + } 287 + AsyncFunction("connectBluetooth") { address: String -> 288 + if (rb_connect_bluetooth(address) != 0) throw RpcError("connectBluetooth") 289 + } 290 + AsyncFunction("disconnectBluetooth") { address: String -> 291 + if (rb_disconnect_bluetooth(address) != 0) throw RpcError("disconnectBluetooth") 292 + } 293 + 294 + // ── Streaming subscriptions ───────────────────────────────────────────── 295 + Function("subscribeStatus") { 296 + val id = rb_subscribe_status() 297 + startPollLoop(id, "rockbox.status") 298 + id 299 + } 300 + Function("subscribeCurrentTrack") { 301 + val id = rb_subscribe_current_track() 302 + startPollLoop(id, "rockbox.currentTrack") 303 + id 304 + } 305 + Function("subscribePlaylist") { 306 + val id = rb_subscribe_playlist() 307 + startPollLoop(id, "rockbox.playlist") 308 + id 309 + } 310 + Function("subscribeLibrary") { 311 + val id = rb_subscribe_library() 312 + startPollLoop(id, "rockbox.library") 313 + id 314 + } 315 + Function("subscribeDiscovery") { serviceName: String -> 316 + val id = rb_subscribe_discovery(serviceName) 317 + startPollLoop(id, "rockbox.discovery") 318 + id 319 + } 320 + Function("unsubscribe") { subId: Int -> 321 + pollJobs.remove(subId)?.cancel() 322 + rb_unsubscribe(subId) 323 + } 324 + 325 + OnDestroy { 326 + pollJobs.values.forEach { it.cancel() } 327 + pollJobs.clear() 328 + scope.cancel() 329 + } 330 + } 331 + 332 + private fun startPollLoop(subId: Int, eventName: String) { 333 + val job = scope.launch { 334 + while (isActive) { 335 + val raw = rb_poll_event(subId, 1_000) ?: continue 336 + try { 337 + val obj = JSONObject(raw) 338 + if (obj.has("error")) { 339 + sendEvent("rockbox.error", mapOf( 340 + "subId" to subId, 341 + "error" to obj.getString("error"), 342 + "stream" to eventName, 343 + )) 344 + return@launch 345 + } 346 + val map = mutableMapOf<String, Any?>() 347 + val it = obj.keys() 348 + while (it.hasNext()) { 349 + val k = it.next() 350 + map[k] = if (obj.isNull(k)) null else obj.get(k) 351 + } 352 + sendEvent(eventName, map) 353 + } catch (e: Exception) { 354 + // Bad JSON — skip and keep polling. 355 + } 356 + } 357 + } 358 + pollJobs[subId] = job 359 + } 360 + 361 + private fun parseJsonOrThrow(s: String?, op: String): Any { 362 + if (s == null) throw RpcError(op) 363 + val trimmed = s.trim() 364 + return when { 365 + trimmed.startsWith("{") -> { 366 + val obj = JSONObject(trimmed) 367 + if (obj.has("error")) throw RpcError("$op: ${obj.getString("error")}") 368 + jsonObjectToMap(obj) 369 + } 370 + trimmed.startsWith("[") -> { 371 + val arr = org.json.JSONArray(trimmed) 372 + jsonArrayToList(arr) 373 + } 374 + else -> trimmed 375 + } 376 + } 377 + 378 + private fun jsonObjectToMap(obj: JSONObject): Map<String, Any?> { 379 + val map = mutableMapOf<String, Any?>() 380 + val it = obj.keys() 381 + while (it.hasNext()) { 382 + val k = it.next() 383 + map[k] = unwrap(obj.opt(k)) 384 + } 385 + return map 386 + } 387 + 388 + private fun jsonArrayToList(arr: org.json.JSONArray): List<Any?> { 389 + val out = ArrayList<Any?>(arr.length()) 390 + for (i in 0 until arr.length()) out.add(unwrap(arr.opt(i))) 391 + return out 392 + } 393 + 394 + private fun unwrap(v: Any?): Any? = when (v) { 395 + JSONObject.NULL, null -> null 396 + is JSONObject -> jsonObjectToMap(v) 397 + is org.json.JSONArray -> jsonArrayToList(v) 398 + else -> v 399 + } 400 + 401 + private fun jsonToMap(s: String): Map<String, Any?> { 402 + val obj = JSONObject(s) 403 + if (obj.has("error")) throw RpcError(obj.getString("error")) 404 + val map = mutableMapOf<String, Any?>() 405 + val it = obj.keys() 406 + while (it.hasNext()) { 407 + val k = it.next() 408 + map[k] = if (obj.isNull(k)) null else obj.get(k) 409 + } 410 + return map 411 + } 412 + } 413 + 414 + private class RpcError(msg: String) : RuntimeException("rockbox-rpc: $msg")
+9
expo/modules/rockbox-rpc/expo-module.config.json
··· 1 + { 2 + "platforms": ["ios", "android"], 3 + "ios": { 4 + "modules": ["RockboxRpcModule"] 5 + }, 6 + "android": { 7 + "modules": ["expo.modules.rockboxrpc.RockboxRpcModule"] 8 + } 9 + }
+31
expo/modules/rockbox-rpc/ios/RockboxRpc.podspec
··· 1 + require 'json' 2 + 3 + package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) 4 + 5 + Pod::Spec.new do |s| 6 + s.name = 'RockboxRpc' 7 + s.version = package['version'] 8 + s.summary = package['description'] 9 + s.description = package['description'] 10 + s.license = 'LGPL-2.1' 11 + s.author = 'Rockbox Zig' 12 + s.homepage = 'https://github.com/tsirysndr/rockbox-zig' 13 + s.platforms = { :ios => '15.1', :tvos => '15.1' } 14 + s.swift_version = '5.9' 15 + s.source = { git: '' } 16 + s.static_framework = true 17 + 18 + s.dependency 'ExpoModulesCore' 19 + 20 + # Build the Rust static library before linking. The xcframework is produced 21 + # by `scripts/build-ios.sh` and dropped into `ios/RockboxExpo.xcframework`. 22 + s.vendored_frameworks = 'RockboxExpo.xcframework' 23 + s.libraries = 'c++', 'resolv' 24 + 25 + s.pod_target_xcconfig = { 26 + 'DEFINES_MODULE' => 'YES', 27 + 'SWIFT_COMPILATION_MODE' => 'wholemodule', 28 + } 29 + 30 + s.source_files = 'RockboxRpcModule.swift' 31 + end
+436
expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift
··· 1 + import ExpoModulesCore 2 + import Foundation 3 + 4 + // C symbols exported by the rockbox_expo Rust crate (built from 5 + // `crates/expo/`). The static library is linked via the .podspec. 6 + @_silgen_name("rb_set_server_url") private func rb_set_server_url(_ url: UnsafePointer<CChar>) -> Int32 7 + @_silgen_name("rb_ping") private func rb_ping() -> Int32 8 + @_silgen_name("rb_play") private func rb_play() -> Int32 9 + @_silgen_name("rb_pause") private func rb_pause() -> Int32 10 + @_silgen_name("rb_play_pause") private func rb_play_pause() -> Int32 11 + @_silgen_name("rb_next") private func rb_next() -> Int32 12 + @_silgen_name("rb_prev") private func rb_prev() -> Int32 13 + @_silgen_name("rb_seek") private func rb_seek(_ positionMs: Int32) -> Int32 14 + @_silgen_name("rb_status_json") private func rb_status_json() -> UnsafeMutablePointer<CChar>? 15 + @_silgen_name("rb_current_track_json") private func rb_current_track_json() -> UnsafeMutablePointer<CChar>? 16 + @_silgen_name("rb_like_track") private func rb_like_track(_ id: UnsafePointer<CChar>) -> Int32 17 + @_silgen_name("rb_unlike_track") private func rb_unlike_track(_ id: UnsafePointer<CChar>) -> Int32 18 + @_silgen_name("rb_free_string") private func rb_free_string(_ ptr: UnsafeMutablePointer<CChar>?) 19 + 20 + @_silgen_name("rb_subscribe_status") private func rb_subscribe_status() -> Int32 21 + @_silgen_name("rb_subscribe_current_track") private func rb_subscribe_current_track() -> Int32 22 + @_silgen_name("rb_subscribe_playlist") private func rb_subscribe_playlist() -> Int32 23 + @_silgen_name("rb_subscribe_library") private func rb_subscribe_library() -> Int32 24 + @_silgen_name("rb_subscribe_discovery") private func rb_subscribe_discovery(_ name: UnsafePointer<CChar>) -> Int32 25 + @_silgen_name("rb_rockbox_service_name") private func rb_rockbox_service_name() -> UnsafeMutablePointer<CChar>? 26 + @_silgen_name("rb_chromecast_service_name") private func rb_chromecast_service_name() -> UnsafeMutablePointer<CChar>? 27 + @_silgen_name("rb_poll_event") private func rb_poll_event(_ subId: Int32, _ timeoutMs: Int32) -> UnsafeMutablePointer<CChar>? 28 + @_silgen_name("rb_unsubscribe") private func rb_unsubscribe(_ subId: Int32) -> Int32 29 + 30 + @_silgen_name("rb_resume_track") private func rb_resume_track() -> Int32 31 + @_silgen_name("rb_playlist_resume") private func rb_playlist_resume() -> Int32 32 + @_silgen_name("rb_play_all_tracks") private func rb_play_all_tracks() -> Int32 33 + @_silgen_name("rb_play_track") private func rb_play_track(_ p: UnsafePointer<CChar>) -> Int32 34 + @_silgen_name("rb_play_album") private func rb_play_album(_ id: UnsafePointer<CChar>, _ shuffle: Int32) -> Int32 35 + @_silgen_name("rb_play_artist_tracks") private func rb_play_artist_tracks(_ id: UnsafePointer<CChar>, _ shuffle: Int32) -> Int32 36 + @_silgen_name("rb_play_directory") private func rb_play_directory(_ p: UnsafePointer<CChar>, _ shuffle: Int32, _ position: Int32) -> Int32 37 + 38 + @_silgen_name("rb_jump_to_queue_position") private func rb_jump_to_queue_position(_ p: Int32) -> Int32 39 + @_silgen_name("rb_shuffle_playlist") private func rb_shuffle_playlist() -> Int32 40 + @_silgen_name("rb_insert_tracks") private func rb_insert_tracks(_ paths: UnsafePointer<CChar>, _ pos: Int32, _ shuffle: Int32) -> Int32 41 + @_silgen_name("rb_insert_track_next") private func rb_insert_track_next(_ p: UnsafePointer<CChar>) -> Int32 42 + @_silgen_name("rb_insert_track_last") private func rb_insert_track_last(_ p: UnsafePointer<CChar>) -> Int32 43 + @_silgen_name("rb_insert_directory") private func rb_insert_directory(_ p: UnsafePointer<CChar>, _ pos: Int32) -> Int32 44 + @_silgen_name("rb_remove_from_queue") private func rb_remove_from_queue(_ pos: Int32) -> Int32 45 + @_silgen_name("rb_get_playlist_current_json") private func rb_get_playlist_current_json() -> UnsafeMutablePointer<CChar>? 46 + 47 + @_silgen_name("rb_get_tracks_json") private func rb_get_tracks_json() -> UnsafeMutablePointer<CChar>? 48 + @_silgen_name("rb_get_artists_json") private func rb_get_artists_json() -> UnsafeMutablePointer<CChar>? 49 + @_silgen_name("rb_get_album_json") private func rb_get_album_json(_ id: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 50 + @_silgen_name("rb_get_liked_tracks_json") private func rb_get_liked_tracks_json() -> UnsafeMutablePointer<CChar>? 51 + @_silgen_name("rb_search_json") private func rb_search_json(_ term: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 52 + 53 + @_silgen_name("rb_adjust_volume") private func rb_adjust_volume(_ steps: Int32) -> Int32 54 + @_silgen_name("rb_sound_current_json") private func rb_sound_current_json(_ setting: Int32) -> UnsafeMutablePointer<CChar>? 55 + 56 + @_silgen_name("rb_save_shuffle") private func rb_save_shuffle(_ enabled: Int32) -> Int32 57 + @_silgen_name("rb_save_repeat") private func rb_save_repeat(_ mode: Int32) -> Int32 58 + @_silgen_name("rb_get_global_settings_json") private func rb_get_global_settings_json() -> UnsafeMutablePointer<CChar>? 59 + @_silgen_name("rb_get_global_status_json") private func rb_get_global_status_json() -> UnsafeMutablePointer<CChar>? 60 + 61 + @_silgen_name("rb_tree_get_entries_json") private func rb_tree_get_entries_json(_ p: UnsafePointer<CChar>?) -> UnsafeMutablePointer<CChar>? 62 + 63 + @_silgen_name("rb_get_saved_playlists_json") private func rb_get_saved_playlists_json() -> UnsafeMutablePointer<CChar>? 64 + @_silgen_name("rb_create_saved_playlist") private func rb_create_saved_playlist(_ name: UnsafePointer<CChar>, _ desc: UnsafePointer<CChar>?, _ ids: UnsafePointer<CChar>?) -> Int32 65 + @_silgen_name("rb_update_saved_playlist") private func rb_update_saved_playlist(_ id: UnsafePointer<CChar>, _ name: UnsafePointer<CChar>, _ desc: UnsafePointer<CChar>?) -> Int32 66 + @_silgen_name("rb_delete_saved_playlist") private func rb_delete_saved_playlist(_ id: UnsafePointer<CChar>) -> Int32 67 + @_silgen_name("rb_add_track_to_playlist") private func rb_add_track_to_playlist(_ pid: UnsafePointer<CChar>, _ tid: UnsafePointer<CChar>) -> Int32 68 + @_silgen_name("rb_remove_track_from_playlist") private func rb_remove_track_from_playlist(_ pid: UnsafePointer<CChar>, _ tid: UnsafePointer<CChar>) -> Int32 69 + @_silgen_name("rb_get_saved_playlist_tracks_json") private func rb_get_saved_playlist_tracks_json(_ pid: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 70 + @_silgen_name("rb_play_saved_playlist") private func rb_play_saved_playlist(_ pid: UnsafePointer<CChar>) -> Int32 71 + 72 + @_silgen_name("rb_get_smart_playlists_json") private func rb_get_smart_playlists_json() -> UnsafeMutablePointer<CChar>? 73 + @_silgen_name("rb_get_smart_playlist_tracks_json") private func rb_get_smart_playlist_tracks_json(_ id: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 74 + @_silgen_name("rb_play_smart_playlist") private func rb_play_smart_playlist(_ id: UnsafePointer<CChar>) -> Int32 75 + 76 + @_silgen_name("rb_bluetooth_available") private func rb_bluetooth_available() -> Int32 77 + @_silgen_name("rb_get_bluetooth_devices_json") private func rb_get_bluetooth_devices_json() -> UnsafeMutablePointer<CChar>? 78 + @_silgen_name("rb_connect_bluetooth") private func rb_connect_bluetooth(_ a: UnsafePointer<CChar>) -> Int32 79 + @_silgen_name("rb_disconnect_bluetooth") private func rb_disconnect_bluetooth(_ a: UnsafePointer<CChar>) -> Int32 80 + 81 + private func takeString(_ ptr: UnsafeMutablePointer<CChar>?) -> String? { 82 + guard let ptr = ptr else { return nil } 83 + let s = String(cString: ptr) 84 + rb_free_string(ptr) 85 + return s 86 + } 87 + 88 + private func parseJson(_ s: String?) throws -> Any { 89 + guard let s = s, let data = s.data(using: .utf8) else { 90 + throw NSError(domain: "RockboxRpc", code: -1, userInfo: [NSLocalizedDescriptionKey: "no response"]) 91 + } 92 + let obj = try JSONSerialization.jsonObject(with: data, options: []) 93 + if let dict = obj as? [String: Any], let err = dict["error"] as? String { 94 + throw NSError(domain: "RockboxRpc", code: -2, userInfo: [NSLocalizedDescriptionKey: err]) 95 + } 96 + return obj 97 + } 98 + 99 + public class RockboxRpcModule: Module { 100 + private let pollQueue = DispatchQueue(label: "rockbox.rpc.poll", qos: .utility, attributes: .concurrent) 101 + private var pollTokens: [Int32: Bool] = [:] // subId → keepRunning 102 + private let pollLock = NSLock() 103 + 104 + public func definition() -> ModuleDefinition { 105 + Name("RockboxRpc") 106 + 107 + Events( 108 + "rockbox.status", 109 + "rockbox.currentTrack", 110 + "rockbox.playlist", 111 + "rockbox.library", 112 + "rockbox.discovery", 113 + "rockbox.error" 114 + ) 115 + 116 + Function("rockboxServiceName") { () -> String in 117 + takeString(rb_rockbox_service_name()) ?? "_rockbox._tcp.local." 118 + } 119 + Function("chromecastServiceName") { () -> String in 120 + takeString(rb_chromecast_service_name()) ?? "_googlecast._tcp.local." 121 + } 122 + 123 + Function("setServerUrl") { (url: String) in 124 + url.withCString { _ = rb_set_server_url($0) } 125 + } 126 + 127 + AsyncFunction("ping") { () -> Bool in 128 + return rb_ping() == 0 129 + } 130 + 131 + AsyncFunction("play") { () -> Void in if rb_play() != 0 { throw playbackError("play") } } 132 + AsyncFunction("pause") { () -> Void in if rb_pause() != 0 { throw playbackError("pause") } } 133 + AsyncFunction("playPause") { () -> Void in if rb_play_pause() != 0 { throw playbackError("playPause") } } 134 + AsyncFunction("next") { () -> Void in if rb_next() != 0 { throw playbackError("next") } } 135 + AsyncFunction("prev") { () -> Void in if rb_prev() != 0 { throw playbackError("prev") } } 136 + AsyncFunction("seek") { (positionMs: Int) -> Void in 137 + if rb_seek(Int32(positionMs)) != 0 { throw playbackError("seek") } 138 + } 139 + 140 + AsyncFunction("status") { () -> [String: Any] in 141 + let s = takeString(rb_status_json()) 142 + guard let dict = try parseJson(s) as? [String: Any] else { 143 + throw playbackError("status") 144 + } 145 + return dict 146 + } 147 + 148 + AsyncFunction("currentTrack") { () -> [String: Any] in 149 + let s = takeString(rb_current_track_json()) 150 + guard let dict = try parseJson(s) as? [String: Any] else { 151 + throw playbackError("currentTrack") 152 + } 153 + return dict 154 + } 155 + 156 + AsyncFunction("likeTrack") { (id: String) -> Void in 157 + let rc = id.withCString { rb_like_track($0) } 158 + if rc != 0 { throw playbackError("likeTrack") } 159 + } 160 + 161 + AsyncFunction("unlikeTrack") { (id: String) -> Void in 162 + let rc = id.withCString { rb_unlike_track($0) } 163 + if rc != 0 { throw playbackError("unlikeTrack") } 164 + } 165 + 166 + // ── Comprehensive RPC surface ─────────────────────────────────────── 167 + AsyncFunction("resumeTrack") { () -> Void in if rb_resume_track() != 0 { throw self.playbackError("resumeTrack") } } 168 + AsyncFunction("playlistResume") { () -> Void in if rb_playlist_resume() != 0 { throw self.playbackError("playlistResume") } } 169 + AsyncFunction("playAllTracks") { () -> Void in if rb_play_all_tracks() != 0 { throw self.playbackError("playAllTracks") } } 170 + 171 + AsyncFunction("playTrack") { (path: String) -> Void in 172 + let rc = path.withCString { rb_play_track($0) } 173 + if rc != 0 { throw self.playbackError("playTrack") } 174 + } 175 + AsyncFunction("playAlbum") { (id: String, shuffle: Bool) -> Void in 176 + let rc = id.withCString { rb_play_album($0, shuffle ? 1 : 0) } 177 + if rc != 0 { throw self.playbackError("playAlbum") } 178 + } 179 + AsyncFunction("playArtistTracks") { (id: String, shuffle: Bool) -> Void in 180 + let rc = id.withCString { rb_play_artist_tracks($0, shuffle ? 1 : 0) } 181 + if rc != 0 { throw self.playbackError("playArtistTracks") } 182 + } 183 + AsyncFunction("playDirectory") { (path: String, shuffle: Bool, position: Int) -> Void in 184 + let rc = path.withCString { rb_play_directory($0, shuffle ? 1 : 0, Int32(position)) } 185 + if rc != 0 { throw self.playbackError("playDirectory") } 186 + } 187 + 188 + AsyncFunction("jumpToQueuePosition") { (pos: Int) -> Void in 189 + if rb_jump_to_queue_position(Int32(pos)) != 0 { throw self.playbackError("jumpToQueuePosition") } 190 + } 191 + AsyncFunction("shufflePlaylist") { () -> Void in 192 + if rb_shuffle_playlist() != 0 { throw self.playbackError("shufflePlaylist") } 193 + } 194 + AsyncFunction("insertTracks") { (paths: [String], position: Int, shuffle: Bool) -> Void in 195 + let json = (try? JSONSerialization.data(withJSONObject: paths)) 196 + .flatMap { String(data: $0, encoding: .utf8) } ?? "[]" 197 + let rc = json.withCString { rb_insert_tracks($0, Int32(position), shuffle ? 1 : 0) } 198 + if rc != 0 { throw self.playbackError("insertTracks") } 199 + } 200 + AsyncFunction("insertTrackNext") { (path: String) -> Void in 201 + let rc = path.withCString { rb_insert_track_next($0) } 202 + if rc != 0 { throw self.playbackError("insertTrackNext") } 203 + } 204 + AsyncFunction("insertTrackLast") { (path: String) -> Void in 205 + let rc = path.withCString { rb_insert_track_last($0) } 206 + if rc != 0 { throw self.playbackError("insertTrackLast") } 207 + } 208 + AsyncFunction("insertDirectory") { (path: String, position: Int) -> Void in 209 + let rc = path.withCString { rb_insert_directory($0, Int32(position)) } 210 + if rc != 0 { throw self.playbackError("insertDirectory") } 211 + } 212 + AsyncFunction("removeFromQueue") { (pos: Int) -> Void in 213 + if rb_remove_from_queue(Int32(pos)) != 0 { throw self.playbackError("removeFromQueue") } 214 + } 215 + 216 + AsyncFunction("getPlaylistCurrent") { () -> Any in 217 + try self.parseJsonOrThrow(takeString(rb_get_playlist_current_json()), op: "getPlaylistCurrent") 218 + } 219 + AsyncFunction("getTracks") { () -> Any in 220 + try self.parseJsonOrThrow(takeString(rb_get_tracks_json()), op: "getTracks") 221 + } 222 + AsyncFunction("getArtists") { () -> Any in 223 + try self.parseJsonOrThrow(takeString(rb_get_artists_json()), op: "getArtists") 224 + } 225 + AsyncFunction("getAlbum") { (id: String) -> Any in 226 + let raw = id.withCString { rb_get_album_json($0) } 227 + return try self.parseJsonOrThrow(takeString(raw), op: "getAlbum") 228 + } 229 + AsyncFunction("getLikedTracks") { () -> Any in 230 + try self.parseJsonOrThrow(takeString(rb_get_liked_tracks_json()), op: "getLikedTracks") 231 + } 232 + AsyncFunction("search") { (term: String) -> Any in 233 + let raw = term.withCString { rb_search_json($0) } 234 + return try self.parseJsonOrThrow(takeString(raw), op: "search") 235 + } 236 + 237 + AsyncFunction("adjustVolume") { (steps: Int) -> Void in 238 + if rb_adjust_volume(Int32(steps)) != 0 { throw self.playbackError("adjustVolume") } 239 + } 240 + AsyncFunction("soundCurrent") { (setting: Int) -> Any in 241 + try self.parseJsonOrThrow(takeString(rb_sound_current_json(Int32(setting))), op: "soundCurrent") 242 + } 243 + 244 + AsyncFunction("saveShuffle") { (enabled: Bool) -> Void in 245 + if rb_save_shuffle(enabled ? 1 : 0) != 0 { throw self.playbackError("saveShuffle") } 246 + } 247 + AsyncFunction("saveRepeat") { (mode: Int) -> Void in 248 + if rb_save_repeat(Int32(mode)) != 0 { throw self.playbackError("saveRepeat") } 249 + } 250 + AsyncFunction("getGlobalSettings") { () -> Any in 251 + try self.parseJsonOrThrow(takeString(rb_get_global_settings_json()), op: "getGlobalSettings") 252 + } 253 + AsyncFunction("getGlobalStatus") { () -> Any in 254 + try self.parseJsonOrThrow(takeString(rb_get_global_status_json()), op: "getGlobalStatus") 255 + } 256 + 257 + AsyncFunction("treeGetEntries") { (path: String?) -> Any in 258 + let raw: UnsafeMutablePointer<CChar>? 259 + if let p = path { 260 + raw = p.withCString { rb_tree_get_entries_json($0) } 261 + } else { 262 + raw = rb_tree_get_entries_json(nil) 263 + } 264 + return try self.parseJsonOrThrow(takeString(raw), op: "treeGetEntries") 265 + } 266 + 267 + AsyncFunction("getSavedPlaylists") { () -> Any in 268 + try self.parseJsonOrThrow(takeString(rb_get_saved_playlists_json()), op: "getSavedPlaylists") 269 + } 270 + AsyncFunction("createSavedPlaylist") { (name: String, description: String?, trackIds: [String]) -> Void in 271 + let idsJson = (try? JSONSerialization.data(withJSONObject: trackIds)) 272 + .flatMap { String(data: $0, encoding: .utf8) } ?? "[]" 273 + let rc: Int32 = name.withCString { namePtr in 274 + idsJson.withCString { idsPtr in 275 + if let d = description { 276 + return d.withCString { descPtr in rb_create_saved_playlist(namePtr, descPtr, idsPtr) } 277 + } else { 278 + return rb_create_saved_playlist(namePtr, nil, idsPtr) 279 + } 280 + } 281 + } 282 + if rc != 0 { throw self.playbackError("createSavedPlaylist") } 283 + } 284 + AsyncFunction("updateSavedPlaylist") { (id: String, name: String, description: String?) -> Void in 285 + let rc: Int32 = id.withCString { idPtr in 286 + name.withCString { namePtr in 287 + if let d = description { 288 + return d.withCString { descPtr in rb_update_saved_playlist(idPtr, namePtr, descPtr) } 289 + } else { 290 + return rb_update_saved_playlist(idPtr, namePtr, nil) 291 + } 292 + } 293 + } 294 + if rc != 0 { throw self.playbackError("updateSavedPlaylist") } 295 + } 296 + AsyncFunction("deleteSavedPlaylist") { (id: String) -> Void in 297 + let rc = id.withCString { rb_delete_saved_playlist($0) } 298 + if rc != 0 { throw self.playbackError("deleteSavedPlaylist") } 299 + } 300 + AsyncFunction("addTrackToPlaylist") { (playlistId: String, trackId: String) -> Void in 301 + let rc: Int32 = playlistId.withCString { pid in 302 + trackId.withCString { tid in rb_add_track_to_playlist(pid, tid) } 303 + } 304 + if rc != 0 { throw self.playbackError("addTrackToPlaylist") } 305 + } 306 + AsyncFunction("removeTrackFromPlaylist") { (playlistId: String, trackId: String) -> Void in 307 + let rc: Int32 = playlistId.withCString { pid in 308 + trackId.withCString { tid in rb_remove_track_from_playlist(pid, tid) } 309 + } 310 + if rc != 0 { throw self.playbackError("removeTrackFromPlaylist") } 311 + } 312 + AsyncFunction("getSavedPlaylistTracks") { (playlistId: String) -> Any in 313 + let raw = playlistId.withCString { rb_get_saved_playlist_tracks_json($0) } 314 + return try self.parseJsonOrThrow(takeString(raw), op: "getSavedPlaylistTracks") 315 + } 316 + AsyncFunction("playSavedPlaylist") { (playlistId: String) -> Void in 317 + let rc = playlistId.withCString { rb_play_saved_playlist($0) } 318 + if rc != 0 { throw self.playbackError("playSavedPlaylist") } 319 + } 320 + 321 + AsyncFunction("getSmartPlaylists") { () -> Any in 322 + try self.parseJsonOrThrow(takeString(rb_get_smart_playlists_json()), op: "getSmartPlaylists") 323 + } 324 + AsyncFunction("getSmartPlaylistTracks") { (id: String) -> Any in 325 + let raw = id.withCString { rb_get_smart_playlist_tracks_json($0) } 326 + return try self.parseJsonOrThrow(takeString(raw), op: "getSmartPlaylistTracks") 327 + } 328 + AsyncFunction("playSmartPlaylist") { (id: String) -> Void in 329 + let rc = id.withCString { rb_play_smart_playlist($0) } 330 + if rc != 0 { throw self.playbackError("playSmartPlaylist") } 331 + } 332 + 333 + AsyncFunction("bluetoothAvailable") { () -> Bool in 334 + return rb_bluetooth_available() == 1 335 + } 336 + AsyncFunction("getBluetoothDevices") { () -> Any in 337 + try self.parseJsonOrThrow(takeString(rb_get_bluetooth_devices_json()), op: "getBluetoothDevices") 338 + } 339 + AsyncFunction("connectBluetooth") { (address: String) -> Void in 340 + let rc = address.withCString { rb_connect_bluetooth($0) } 341 + if rc != 0 { throw self.playbackError("connectBluetooth") } 342 + } 343 + AsyncFunction("disconnectBluetooth") { (address: String) -> Void in 344 + let rc = address.withCString { rb_disconnect_bluetooth($0) } 345 + if rc != 0 { throw self.playbackError("disconnectBluetooth") } 346 + } 347 + 348 + // ── Streaming subscriptions ───────────────────────────────────────── 349 + Function("subscribeStatus") { () -> Int32 in 350 + let id = rb_subscribe_status() 351 + self.startPollLoop(subId: id, eventName: "rockbox.status") 352 + return id 353 + } 354 + Function("subscribeCurrentTrack") { () -> Int32 in 355 + let id = rb_subscribe_current_track() 356 + self.startPollLoop(subId: id, eventName: "rockbox.currentTrack") 357 + return id 358 + } 359 + Function("subscribePlaylist") { () -> Int32 in 360 + let id = rb_subscribe_playlist() 361 + self.startPollLoop(subId: id, eventName: "rockbox.playlist") 362 + return id 363 + } 364 + Function("subscribeLibrary") { () -> Int32 in 365 + let id = rb_subscribe_library() 366 + self.startPollLoop(subId: id, eventName: "rockbox.library") 367 + return id 368 + } 369 + Function("subscribeDiscovery") { (serviceName: String) -> Int32 in 370 + let id = serviceName.withCString { rb_subscribe_discovery($0) } 371 + self.startPollLoop(subId: id, eventName: "rockbox.discovery") 372 + return id 373 + } 374 + Function("unsubscribe") { (subId: Int32) -> Void in 375 + self.pollLock.lock() 376 + self.pollTokens[subId] = false 377 + self.pollLock.unlock() 378 + _ = rb_unsubscribe(subId) 379 + } 380 + } 381 + 382 + private func startPollLoop(subId: Int32, eventName: String) { 383 + pollLock.lock() 384 + pollTokens[subId] = true 385 + pollLock.unlock() 386 + 387 + pollQueue.async { [weak self] in 388 + while true { 389 + guard let self = self else { return } 390 + self.pollLock.lock() 391 + let alive = self.pollTokens[subId] ?? false 392 + self.pollLock.unlock() 393 + if !alive { return } 394 + 395 + guard let raw = rb_poll_event(subId, 1_000) else { 396 + // Timeout (no event in 1s) — keep polling. 397 + continue 398 + } 399 + let s = String(cString: raw) 400 + rb_free_string(raw) 401 + guard let data = s.data(using: .utf8), 402 + let obj = try? JSONSerialization.jsonObject(with: data) else { 403 + continue 404 + } 405 + if let dict = obj as? [String: Any] { 406 + if let err = dict["error"] as? String { 407 + self.sendEvent("rockbox.error", ["subId": subId, "error": err, "stream": eventName]) 408 + // Stop polling after an error. 409 + self.pollLock.lock() 410 + self.pollTokens[subId] = false 411 + self.pollLock.unlock() 412 + return 413 + } 414 + self.sendEvent(eventName, dict) 415 + } 416 + } 417 + } 418 + } 419 + 420 + private func playbackError(_ op: String) -> NSError { 421 + return NSError(domain: "RockboxRpc", code: -1, 422 + userInfo: [NSLocalizedDescriptionKey: "rockbox-rpc: \(op) failed"]) 423 + } 424 + 425 + fileprivate func parseJsonOrThrow(_ s: String?, op: String) throws -> Any { 426 + guard let s = s, let data = s.data(using: .utf8) else { 427 + throw playbackError(op) 428 + } 429 + let obj = try JSONSerialization.jsonObject(with: data, options: .allowFragments) 430 + if let dict = obj as? [String: Any], let err = dict["error"] as? String { 431 + throw NSError(domain: "RockboxRpc", code: -2, 432 + userInfo: [NSLocalizedDescriptionKey: "rockbox-rpc: \(op): \(err)"]) 433 + } 434 + return obj 435 + } 436 + }
+18
expo/modules/rockbox-rpc/package.json
··· 1 + { 2 + "name": "rockbox-rpc", 3 + "version": "0.1.0", 4 + "description": "Native gRPC client for the rockboxd daemon (wraps the rockbox-expo Rust crate)", 5 + "main": "src/index.ts", 6 + "types": "src/index.ts", 7 + "private": true, 8 + "keywords": ["rockbox", "expo", "grpc"], 9 + "scripts": { 10 + "build:ios": "./scripts/build-ios.sh", 11 + "build:android": "./scripts/build-android.sh" 12 + }, 13 + "peerDependencies": { 14 + "expo": "*", 15 + "react": "*", 16 + "react-native": "*" 17 + } 18 + }
+28
expo/modules/rockbox-rpc/scripts/build-android.sh
··· 1 + #!/usr/bin/env bash 2 + # Build the rockbox-expo Rust cdylib for Android via cargo-ndk and drop the 3 + # resulting .so files under android/src/main/jniLibs/<abi>/. 4 + # 5 + # Prereqs: 6 + # cargo install cargo-ndk 7 + # rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 8 + # ANDROID_NDK_HOME must point at a valid NDK r25+ install. 9 + set -euo pipefail 10 + 11 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 12 + MODULE_DIR="$(dirname "$SCRIPT_DIR")" 13 + WORKSPACE_ROOT="$(cd "$MODULE_DIR/../../.." && pwd)" 14 + 15 + PROFILE=${PROFILE:-release} 16 + JNILIBS="$MODULE_DIR/android/src/main/jniLibs" 17 + mkdir -p "$JNILIBS" 18 + 19 + cd "$WORKSPACE_ROOT" 20 + cargo ndk \ 21 + -t arm64-v8a \ 22 + -t armeabi-v7a \ 23 + -t x86_64 \ 24 + -o "$JNILIBS" \ 25 + build -p rockbox-expo $( [[ "$PROFILE" == "release" ]] && echo "--release" ) 26 + 27 + echo 28 + echo "Built $JNILIBS/<abi>/librockbox_expo.so"
+73
expo/modules/rockbox-rpc/scripts/build-ios.sh
··· 1 + #!/usr/bin/env bash 2 + # Build the rockbox-expo Rust static lib for iOS device + simulator and pack 3 + # them into an .xcframework consumed by the Pod. 4 + # 5 + # Required Rust targets (install once): 6 + # rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 7 + # 8 + # Output: expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework 9 + set -euo pipefail 10 + 11 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 12 + MODULE_DIR="$(dirname "$SCRIPT_DIR")" 13 + WORKSPACE_ROOT="$(cd "$MODULE_DIR/../../.." && pwd)" 14 + 15 + LIB_NAME="librockbox_expo.a" 16 + PROFILE=${PROFILE:-release} 17 + CARGO_FLAGS=() 18 + if [[ "$PROFILE" == "release" ]]; then 19 + CARGO_FLAGS+=(--release) 20 + fi 21 + 22 + # 1. Build for each iOS target 23 + ( cd "$WORKSPACE_ROOT" && cargo build -p rockbox-expo "${CARGO_FLAGS[@]}" --target aarch64-apple-ios ) 24 + ( cd "$WORKSPACE_ROOT" && cargo build -p rockbox-expo "${CARGO_FLAGS[@]}" --target aarch64-apple-ios-sim ) 25 + ( cd "$WORKSPACE_ROOT" && cargo build -p rockbox-expo "${CARGO_FLAGS[@]}" --target x86_64-apple-ios ) 26 + 27 + DEVICE_LIB="$WORKSPACE_ROOT/target/aarch64-apple-ios/$PROFILE/$LIB_NAME" 28 + SIM_ARM_LIB="$WORKSPACE_ROOT/target/aarch64-apple-ios-sim/$PROFILE/$LIB_NAME" 29 + SIM_X86_LIB="$WORKSPACE_ROOT/target/x86_64-apple-ios/$PROFILE/$LIB_NAME" 30 + 31 + # 2. Create a fat simulator slice (arm64 + x86_64) 32 + SIM_FAT_DIR="$WORKSPACE_ROOT/target/ios-sim-fat" 33 + mkdir -p "$SIM_FAT_DIR" 34 + SIM_FAT_LIB="$SIM_FAT_DIR/$LIB_NAME" 35 + lipo -create "$SIM_ARM_LIB" "$SIM_X86_LIB" -output "$SIM_FAT_LIB" 36 + 37 + # 3. Pack into an xcframework with a tiny module map so Swift can `import` it 38 + HEADERS_DIR="$MODULE_DIR/ios/headers" 39 + mkdir -p "$HEADERS_DIR" 40 + cat > "$HEADERS_DIR/RockboxExpo.h" <<'HDR' 41 + #pragma once 42 + #include <stdint.h> 43 + 44 + int32_t rb_set_server_url(const char *url); 45 + int32_t rb_ping(void); 46 + int32_t rb_play(void); 47 + int32_t rb_pause(void); 48 + int32_t rb_play_pause(void); 49 + int32_t rb_next(void); 50 + int32_t rb_prev(void); 51 + int32_t rb_seek(int32_t position_ms); 52 + char *rb_status_json(void); 53 + char *rb_current_track_json(void); 54 + int32_t rb_like_track(const char *id); 55 + int32_t rb_unlike_track(const char *id); 56 + void rb_free_string(char *ptr); 57 + HDR 58 + cat > "$HEADERS_DIR/module.modulemap" <<'MODMAP' 59 + module RockboxExpo { 60 + header "RockboxExpo.h" 61 + export * 62 + } 63 + MODMAP 64 + 65 + OUT="$MODULE_DIR/ios/RockboxExpo.xcframework" 66 + rm -rf "$OUT" 67 + xcodebuild -create-xcframework \ 68 + -library "$DEVICE_LIB" -headers "$HEADERS_DIR" \ 69 + -library "$SIM_FAT_LIB" -headers "$HEADERS_DIR" \ 70 + -output "$OUT" 71 + 72 + echo 73 + echo "Built $OUT"
+192
expo/modules/rockbox-rpc/src/index.ts
··· 1 + import { requireNativeModule } from "expo"; 2 + import type { EventSubscription } from "expo-modules-core"; 3 + 4 + /** 5 + * Track snapshot returned by `currentTrack()`. Mirrors the JSON shape 6 + * produced by `rb_current_track_json` in `crates/expo/src/lib.rs`. 7 + */ 8 + export type TrackSnapshot = { 9 + id: string; 10 + path: string; 11 + title: string; 12 + artist: string; 13 + album: string; 14 + album_art?: string | null; 15 + duration_ms: number; 16 + elapsed_ms: number; 17 + }; 18 + 19 + /** 20 + * Server playback status. Numeric codes mirror the rockbox internal status 21 + * enum: 0 = stopped, 1 = playing, 2 = paused. 22 + */ 23 + export type StatusSnapshot = { 24 + status: 0 | 1 | 2; 25 + }; 26 + 27 + /** Streamed playlist snapshot (queue position + tracks). */ 28 + export type PlaylistSnapshot = { 29 + index: number; 30 + amount: number; 31 + tracks: TrackSnapshot[]; 32 + }; 33 + 34 + export type StreamErrorEvent = { 35 + subId: number; 36 + stream: 37 + | "rockbox.status" 38 + | "rockbox.currentTrack" 39 + | "rockbox.playlist" 40 + | "rockbox.library" 41 + | "rockbox.discovery"; 42 + error: string; 43 + }; 44 + 45 + /** 46 + * One resolved service from the mDNS discovery stream. Mirrors the JSON shape 47 + * produced by `rb_subscribe_discovery` in `crates/expo`. 48 + */ 49 + export type DiscoveredService = { 50 + name: string; 51 + fullname: string; 52 + hostname: string; 53 + port: number; 54 + addresses: string[]; 55 + properties: Record<string, string>; 56 + }; 57 + 58 + /** Event payload map. Matches `Events(...)` declared in the native modules. */ 59 + export type RockboxRpcEvents = { 60 + "rockbox.status": (s: StatusSnapshot) => void; 61 + "rockbox.currentTrack": (t: TrackSnapshot) => void; 62 + "rockbox.playlist": (p: PlaylistSnapshot) => void; 63 + "rockbox.library": (l: unknown) => void; 64 + "rockbox.discovery": (s: DiscoveredService) => void; 65 + "rockbox.error": (e: StreamErrorEvent) => void; 66 + }; 67 + 68 + // Loose JSON shapes — proto messages are returned as-is. These are unknowns 69 + // because the proto types aren't generated on the JS side; consumers can cast 70 + // or define their own row types as needed. 71 + export type Json = unknown; 72 + 73 + type RockboxRpcNative = { 74 + /** Configure the gRPC server URL. Call once at app startup. */ 75 + setServerUrl(url: string): void; 76 + /** Round-trip Status RPC; resolves with `true` if the server replied. */ 77 + ping(): Promise<boolean>; 78 + 79 + // Playback 80 + play(): Promise<void>; 81 + pause(): Promise<void>; 82 + playPause(): Promise<void>; 83 + next(): Promise<void>; 84 + prev(): Promise<void>; 85 + seek(positionMs: number): Promise<void>; 86 + 87 + // Read 88 + status(): Promise<StatusSnapshot>; 89 + currentTrack(): Promise<TrackSnapshot>; 90 + 91 + // Library 92 + likeTrack(id: string): Promise<void>; 93 + unlikeTrack(id: string): Promise<void>; 94 + 95 + // Playback (extended) 96 + resumeTrack(): Promise<void>; 97 + playlistResume(): Promise<void>; 98 + playAllTracks(): Promise<void>; 99 + playTrack(path: string): Promise<void>; 100 + playAlbum(albumId: string, shuffle: boolean): Promise<void>; 101 + playArtistTracks(artistId: string, shuffle: boolean): Promise<void>; 102 + playDirectory( 103 + path: string, 104 + shuffle: boolean, 105 + /** -1 to omit; otherwise zero-based start position */ 106 + position: number, 107 + ): Promise<void>; 108 + 109 + // Queue 110 + jumpToQueuePosition(pos: number): Promise<void>; 111 + shufflePlaylist(): Promise<void>; 112 + insertTracks(paths: string[], position: number, shuffle: boolean): Promise<void>; 113 + insertTrackNext(path: string): Promise<void>; 114 + insertTrackLast(path: string): Promise<void>; 115 + insertDirectory(path: string, position: number): Promise<void>; 116 + removeFromQueue(pos: number): Promise<void>; 117 + getPlaylistCurrent(): Promise<Json>; 118 + 119 + // Library 120 + getTracks(): Promise<Json>; 121 + getArtists(): Promise<Json>; 122 + getAlbum(id: string): Promise<Json>; 123 + getLikedTracks(): Promise<Json>; 124 + search(term: string): Promise<Json>; 125 + 126 + // Sound 127 + adjustVolume(steps: number): Promise<void>; 128 + soundCurrent(setting: number): Promise<Json>; 129 + 130 + // Settings 131 + saveShuffle(enabled: boolean): Promise<void>; 132 + saveRepeat(mode: number): Promise<void>; 133 + getGlobalSettings(): Promise<Json>; 134 + getGlobalStatus(): Promise<Json>; 135 + 136 + // Browse 137 + treeGetEntries(path: string | null): Promise<Json>; 138 + 139 + // Saved playlists 140 + getSavedPlaylists(): Promise<Json>; 141 + createSavedPlaylist( 142 + name: string, 143 + description: string | null, 144 + trackIds: string[], 145 + ): Promise<void>; 146 + updateSavedPlaylist( 147 + id: string, 148 + name: string, 149 + description: string | null, 150 + ): Promise<void>; 151 + deleteSavedPlaylist(id: string): Promise<void>; 152 + addTrackToPlaylist(playlistId: string, trackId: string): Promise<void>; 153 + removeTrackFromPlaylist(playlistId: string, trackId: string): Promise<void>; 154 + getSavedPlaylistTracks(playlistId: string): Promise<Json>; 155 + playSavedPlaylist(playlistId: string): Promise<void>; 156 + 157 + // Smart playlists 158 + getSmartPlaylists(): Promise<Json>; 159 + getSmartPlaylistTracks(id: string): Promise<Json>; 160 + playSmartPlaylist(id: string): Promise<void>; 161 + 162 + // Bluetooth 163 + bluetoothAvailable(): Promise<boolean>; 164 + getBluetoothDevices(): Promise<Json>; 165 + connectBluetooth(address: string): Promise<void>; 166 + disconnectBluetooth(address: string): Promise<void>; 167 + 168 + // Streaming subscriptions — return an opaque numeric subscription id. 169 + // Pair with `unsubscribe(id)` to tear down. Events fire on the registered 170 + // listener channels (see `RockboxRpcEvents`). 171 + subscribeStatus(): number; 172 + subscribeCurrentTrack(): number; 173 + subscribePlaylist(): number; 174 + subscribeLibrary(): number; 175 + subscribeDiscovery(serviceName: string): number; 176 + unsubscribe(subId: number): void; 177 + 178 + // mDNS / Bonjour service-name constants exported by `rockbox-discovery`. 179 + rockboxServiceName(): string; 180 + chromecastServiceName(): string; 181 + 182 + // Event API (provided by Expo Modules' EventEmitter base). 183 + addListener<K extends keyof RockboxRpcEvents>( 184 + event: K, 185 + listener: RockboxRpcEvents[K], 186 + ): EventSubscription; 187 + removeAllListeners(event: keyof RockboxRpcEvents): void; 188 + }; 189 + 190 + const RockboxRpc = requireNativeModule<RockboxRpcNative>("RockboxRpc"); 191 + 192 + export default RockboxRpc;
+5 -2
expo/package.json
··· 9 9 "ios": "expo run:ios", 10 10 "web": "expo start --web", 11 11 "lint": "expo lint", 12 - "run:android": "expo run:android" 12 + "run:android": "expo run:android", 13 + "postinstall": "node ./scripts/link-rockbox-rpc.js", 14 + "adb:reverse": "adb reverse tcp:8081 tcp:8081" 13 15 }, 14 16 "dependencies": { 15 17 "@expo/vector-icons": "^15.0.3", ··· 39 41 "react-native-safe-area-context": "~5.6.0", 40 42 "react-native-screens": "~4.16.0", 41 43 "react-native-web": "~0.21.0", 42 - "react-native-worklets": "0.5.1" 44 + "react-native-worklets": "0.5.1", 45 + "rockbox-rpc": "file:./modules/rockbox-rpc" 43 46 }, 44 47 "devDependencies": { 45 48 "@types/react": "~19.1.0",