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 Android JNI bridge and Expo client integration

Introduce Android JNI shim and expand the Expo client surface:
- Re-export rockbox_expo C ABI for Android and extend iOS bindings
to support device/HTTP endpoints
- Add TanStack Query hooks, RockboxStreams, and server-store for
discovery, selection and real-time cache updates
- New UI/screens/components: server picker, device picker,
Bluetooth screen, persistent mini-player, playlist cover, equalizer
bars, empty-state, proto mappers, and library data layer
- Include fonts, icons, updated assets, Android multicast permissions,
and add reqwest / jni deps (Cargo.lock updated)

+4935 -890
+100
Cargo.lock
··· 1579 1579 ] 1580 1580 1581 1581 [[package]] 1582 + name = "cesu8" 1583 + version = "1.1.0" 1584 + source = "registry+https://github.com/rust-lang/crates.io-index" 1585 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 1586 + 1587 + [[package]] 1582 1588 name = "cexpr" 1583 1589 version = "0.6.0" 1584 1590 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1857 1863 checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" 1858 1864 dependencies = [ 1859 1865 "windows-sys 0.61.2", 1866 + ] 1867 + 1868 + [[package]] 1869 + name = "combine" 1870 + version = "4.6.7" 1871 + source = "registry+https://github.com/rust-lang/crates.io-index" 1872 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 1873 + dependencies = [ 1874 + "bytes", 1875 + "memchr", 1860 1876 ] 1861 1877 1862 1878 [[package]] ··· 6372 6388 checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 6373 6389 6374 6390 [[package]] 6391 + name = "jni" 6392 + version = "0.21.1" 6393 + source = "registry+https://github.com/rust-lang/crates.io-index" 6394 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 6395 + dependencies = [ 6396 + "cesu8", 6397 + "cfg-if 1.0.0", 6398 + "combine", 6399 + "jni-sys", 6400 + "log", 6401 + "thiserror 1.0.63", 6402 + "walkdir", 6403 + "windows-sys 0.45.0", 6404 + ] 6405 + 6406 + [[package]] 6375 6407 name = "jni-sys" 6376 6408 version = "0.3.0" 6377 6409 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9269 9301 version = "0.1.0" 9270 9302 dependencies = [ 9271 9303 "futures-util", 9304 + "jni", 9272 9305 "once_cell", 9273 9306 "prost", 9307 + "reqwest", 9274 9308 "rockbox-discovery", 9275 9309 "serde", 9276 9310 "serde_json", ··· 13179 13213 13180 13214 [[package]] 13181 13215 name = "windows-sys" 13216 + version = "0.45.0" 13217 + source = "registry+https://github.com/rust-lang/crates.io-index" 13218 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 13219 + dependencies = [ 13220 + "windows-targets 0.42.2", 13221 + ] 13222 + 13223 + [[package]] 13224 + name = "windows-sys" 13182 13225 version = "0.48.0" 13183 13226 source = "registry+https://github.com/rust-lang/crates.io-index" 13184 13227 checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" ··· 13215 13258 13216 13259 [[package]] 13217 13260 name = "windows-targets" 13261 + version = "0.42.2" 13262 + source = "registry+https://github.com/rust-lang/crates.io-index" 13263 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 13264 + dependencies = [ 13265 + "windows_aarch64_gnullvm 0.42.2", 13266 + "windows_aarch64_msvc 0.42.2", 13267 + "windows_i686_gnu 0.42.2", 13268 + "windows_i686_msvc 0.42.2", 13269 + "windows_x86_64_gnu 0.42.2", 13270 + "windows_x86_64_gnullvm 0.42.2", 13271 + "windows_x86_64_msvc 0.42.2", 13272 + ] 13273 + 13274 + [[package]] 13275 + name = "windows-targets" 13218 13276 version = "0.48.5" 13219 13277 source = "registry+https://github.com/rust-lang/crates.io-index" 13220 13278 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 13246 13304 13247 13305 [[package]] 13248 13306 name = "windows_aarch64_gnullvm" 13307 + version = "0.42.2" 13308 + source = "registry+https://github.com/rust-lang/crates.io-index" 13309 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 13310 + 13311 + [[package]] 13312 + name = "windows_aarch64_gnullvm" 13249 13313 version = "0.48.5" 13250 13314 source = "registry+https://github.com/rust-lang/crates.io-index" 13251 13315 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 13258 13322 13259 13323 [[package]] 13260 13324 name = "windows_aarch64_msvc" 13325 + version = "0.42.2" 13326 + source = "registry+https://github.com/rust-lang/crates.io-index" 13327 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 13328 + 13329 + [[package]] 13330 + name = "windows_aarch64_msvc" 13261 13331 version = "0.48.5" 13262 13332 source = "registry+https://github.com/rust-lang/crates.io-index" 13263 13333 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 13270 13340 13271 13341 [[package]] 13272 13342 name = "windows_i686_gnu" 13343 + version = "0.42.2" 13344 + source = "registry+https://github.com/rust-lang/crates.io-index" 13345 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 13346 + 13347 + [[package]] 13348 + name = "windows_i686_gnu" 13273 13349 version = "0.48.5" 13274 13350 source = "registry+https://github.com/rust-lang/crates.io-index" 13275 13351 checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" ··· 13285 13361 version = "0.52.6" 13286 13362 source = "registry+https://github.com/rust-lang/crates.io-index" 13287 13363 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 13364 + 13365 + [[package]] 13366 + name = "windows_i686_msvc" 13367 + version = "0.42.2" 13368 + source = "registry+https://github.com/rust-lang/crates.io-index" 13369 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 13288 13370 13289 13371 [[package]] 13290 13372 name = "windows_i686_msvc" ··· 13297 13379 version = "0.52.6" 13298 13380 source = "registry+https://github.com/rust-lang/crates.io-index" 13299 13381 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 13382 + 13383 + [[package]] 13384 + name = "windows_x86_64_gnu" 13385 + version = "0.42.2" 13386 + source = "registry+https://github.com/rust-lang/crates.io-index" 13387 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 13300 13388 13301 13389 [[package]] 13302 13390 name = "windows_x86_64_gnu" ··· 13312 13400 13313 13401 [[package]] 13314 13402 name = "windows_x86_64_gnullvm" 13403 + version = "0.42.2" 13404 + source = "registry+https://github.com/rust-lang/crates.io-index" 13405 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 13406 + 13407 + [[package]] 13408 + name = "windows_x86_64_gnullvm" 13315 13409 version = "0.48.5" 13316 13410 source = "registry+https://github.com/rust-lang/crates.io-index" 13317 13411 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" ··· 13321 13415 version = "0.52.6" 13322 13416 source = "registry+https://github.com/rust-lang/crates.io-index" 13323 13417 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 13418 + 13419 + [[package]] 13420 + name = "windows_x86_64_msvc" 13421 + version = "0.42.2" 13422 + source = "registry+https://github.com/rust-lang/crates.io-index" 13423 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 13324 13424 13325 13425 [[package]] 13326 13426 name = "windows_x86_64_msvc"
+6
crates/expo/Cargo.toml
··· 18 18 serde_json = "1" 19 19 futures-util = "0.3" 20 20 rockbox-discovery = { path = "../discovery" } 21 + # Minimal HTTP client for the cast / AirPlay device endpoints (rockboxd 22 + # exposes them over plain HTTP at the http_port — no TLS needed). 23 + reqwest = { version = "0.12", default-features = false, features = ["json"] } 24 + 25 + [target.'cfg(target_os = "android")'.dependencies] 26 + jni = "0.21" 21 27 22 28 [build-dependencies] 23 29 tonic-build = { version = "0.12", default-features = false, features = ["prost", "transport"] }
+539
crates/expo/src/jni_bridge.rs
··· 1 + //! Android JNI bridge for `expo.modules.rockboxrpc.RockboxRpcModule`. 2 + //! 3 + //! Kotlin `external fun rb_xxx(...)` declarations resolve to symbols of the 4 + //! form `Java_<package>_<class>_<method>` (every `_` in the Kotlin name is 5 + //! escaped to `_1` per the JNI spec). The C ABI exported from `lib.rs` uses 6 + //! plain `rb_xxx` names, so we add this thin shim — only on Android — to 7 + //! re-export each entry point under its JNI-mangled name and forward to the 8 + //! existing C ABI implementation. 9 + //! 10 + //! All bridges are intentionally `unsafe extern "system"` and `#[no_mangle]` 11 + //! so they show up as exported symbols in the resulting `.so`. 12 + 13 + #![cfg(target_os = "android")] 14 + #![allow(non_snake_case)] 15 + 16 + use std::ffi::{CStr, CString}; 17 + use std::os::raw::{c_char, c_int}; 18 + use std::ptr; 19 + 20 + use jni::objects::{JClass, JString}; 21 + use jni::sys::{jint, jstring}; 22 + use jni::JNIEnv; 23 + 24 + // ── String marshaling helpers ─────────────────────────────────────────────── 25 + 26 + /// Convert a (possibly null) `JString` into a `CString` we can pass over the 27 + /// C ABI. Returns `None` if the input is null OR fails to read. 28 + fn to_cstring(env: &mut JNIEnv, s: &JString) -> Option<CString> { 29 + if s.is_null() { 30 + return None; 31 + } 32 + let raw = env.get_string(s).ok()?; 33 + CString::new(raw.to_string_lossy().as_bytes()).ok() 34 + } 35 + 36 + /// Convert a heap `*mut c_char` returned by Rust into a Java `jstring`, 37 + /// freeing the original Rust allocation in the process. 38 + fn cstr_to_jstring(env: &mut JNIEnv, ptr: *mut c_char) -> jstring { 39 + if ptr.is_null() { 40 + return ptr::null_mut(); 41 + } 42 + let s = unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }; 43 + unsafe { crate::rb_free_string(ptr) }; 44 + match env.new_string(&s) { 45 + Ok(js) => js.into_raw(), 46 + Err(_) => ptr::null_mut(), 47 + } 48 + } 49 + 50 + // ── Macros for the common shapes ──────────────────────────────────────────── 51 + 52 + /// Bridge a `() -> i32` C ABI function under its JNI-mangled name. 53 + macro_rules! bridge_unit { 54 + ($jni_name:ident, $rust_fn:ident) => { 55 + #[no_mangle] 56 + pub unsafe extern "system" fn $jni_name(_env: JNIEnv, _cls: JClass) -> jint { 57 + crate::$rust_fn() as jint 58 + } 59 + }; 60 + } 61 + 62 + /// Bridge a `() -> *mut c_char` JSON-returning function. 63 + macro_rules! bridge_json { 64 + ($jni_name:ident, $rust_fn:ident) => { 65 + #[no_mangle] 66 + pub unsafe extern "system" fn $jni_name(mut env: JNIEnv, _cls: JClass) -> jstring { 67 + let p = crate::$rust_fn(); 68 + cstr_to_jstring(&mut env, p) 69 + } 70 + }; 71 + } 72 + 73 + /// Bridge a `(*const c_char) -> i32` function (single string arg). 74 + macro_rules! bridge_unit_str { 75 + ($jni_name:ident, $rust_fn:ident) => { 76 + #[no_mangle] 77 + pub unsafe extern "system" fn $jni_name( 78 + mut env: JNIEnv, 79 + _cls: JClass, 80 + arg: JString, 81 + ) -> jint { 82 + let Some(c) = to_cstring(&mut env, &arg) else { return -1 }; 83 + crate::$rust_fn(c.as_ptr()) as jint 84 + } 85 + }; 86 + } 87 + 88 + /// Bridge a `(*const c_char) -> *mut c_char` JSON function with a single 89 + /// string arg. 90 + macro_rules! bridge_json_str { 91 + ($jni_name:ident, $rust_fn:ident) => { 92 + #[no_mangle] 93 + pub unsafe extern "system" fn $jni_name( 94 + mut env: JNIEnv, 95 + _cls: JClass, 96 + arg: JString, 97 + ) -> jstring { 98 + let Some(c) = to_cstring(&mut env, &arg) else { 99 + return ptr::null_mut(); 100 + }; 101 + let p = crate::$rust_fn(c.as_ptr()); 102 + cstr_to_jstring(&mut env, p) 103 + } 104 + }; 105 + } 106 + 107 + /// Bridge a `(i32) -> i32` function. 108 + macro_rules! bridge_unit_int { 109 + ($jni_name:ident, $rust_fn:ident) => { 110 + #[no_mangle] 111 + pub unsafe extern "system" fn $jni_name( 112 + _env: JNIEnv, 113 + _cls: JClass, 114 + arg: jint, 115 + ) -> jint { 116 + crate::$rust_fn(arg as c_int) as jint 117 + } 118 + }; 119 + } 120 + 121 + /// Bridge a `(i32) -> *mut c_char` JSON function. 122 + macro_rules! bridge_json_int { 123 + ($jni_name:ident, $rust_fn:ident) => { 124 + #[no_mangle] 125 + pub unsafe extern "system" fn $jni_name( 126 + mut env: JNIEnv, 127 + _cls: JClass, 128 + arg: jint, 129 + ) -> jstring { 130 + let p = crate::$rust_fn(arg as c_int); 131 + cstr_to_jstring(&mut env, p) 132 + } 133 + }; 134 + } 135 + 136 + // ── Init / health ─────────────────────────────────────────────────────────── 137 + 138 + bridge_unit_str!( 139 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1set_1server_1url, 140 + rb_set_server_url 141 + ); 142 + bridge_unit_str!( 143 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1set_1http_1url, 144 + rb_set_http_url 145 + ); 146 + bridge_unit!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1ping, rb_ping); 147 + 148 + bridge_json!( 149 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1devices_1json, 150 + rb_get_devices_json 151 + ); 152 + bridge_unit_str!( 153 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1connect_1device, 154 + rb_connect_device 155 + ); 156 + bridge_unit_str!( 157 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1disconnect_1device, 158 + rb_disconnect_device 159 + ); 160 + 161 + // ── Playback (no args) ────────────────────────────────────────────────────── 162 + 163 + bridge_unit!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play, rb_play); 164 + bridge_unit!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1pause, rb_pause); 165 + bridge_unit!( 166 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1pause, 167 + rb_play_pause 168 + ); 169 + bridge_unit!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1next, rb_next); 170 + bridge_unit!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1prev, rb_prev); 171 + bridge_unit!( 172 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1resume_1track, 173 + rb_resume_track 174 + ); 175 + bridge_unit!( 176 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1playlist_1resume, 177 + rb_playlist_resume 178 + ); 179 + bridge_unit!( 180 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1all_1tracks, 181 + rb_play_all_tracks 182 + ); 183 + 184 + // ── Playback (1 string) ───────────────────────────────────────────────────── 185 + 186 + bridge_unit_str!( 187 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1track, 188 + rb_play_track 189 + ); 190 + 191 + // ── Seek ──────────────────────────────────────────────────────────────────── 192 + 193 + bridge_unit_int!(Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1seek, rb_seek); 194 + 195 + // ── Playback (string + int / int / int) ───────────────────────────────────── 196 + 197 + #[no_mangle] 198 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1album( 199 + mut env: JNIEnv, 200 + _cls: JClass, 201 + id: JString, 202 + shuffle: jint, 203 + ) -> jint { 204 + let Some(c) = to_cstring(&mut env, &id) else { return -1 }; 205 + crate::rb_play_album(c.as_ptr(), shuffle as c_int) as jint 206 + } 207 + 208 + #[no_mangle] 209 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1artist_1tracks( 210 + mut env: JNIEnv, 211 + _cls: JClass, 212 + id: JString, 213 + shuffle: jint, 214 + ) -> jint { 215 + let Some(c) = to_cstring(&mut env, &id) else { return -1 }; 216 + crate::rb_play_artist_tracks(c.as_ptr(), shuffle as c_int) as jint 217 + } 218 + 219 + #[no_mangle] 220 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1directory( 221 + mut env: JNIEnv, 222 + _cls: JClass, 223 + path: JString, 224 + shuffle: jint, 225 + position: jint, 226 + ) -> jint { 227 + let Some(c) = to_cstring(&mut env, &path) else { return -1 }; 228 + crate::rb_play_directory(c.as_ptr(), shuffle as c_int, position as c_int) as jint 229 + } 230 + 231 + // ── Queue ─────────────────────────────────────────────────────────────────── 232 + 233 + bridge_unit_int!( 234 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1jump_1to_1queue_1position, 235 + rb_jump_to_queue_position 236 + ); 237 + bridge_unit!( 238 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1shuffle_1playlist, 239 + rb_shuffle_playlist 240 + ); 241 + bridge_unit_int!( 242 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1remove_1from_1queue, 243 + rb_remove_from_queue 244 + ); 245 + bridge_unit_str!( 246 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1insert_1track_1next, 247 + rb_insert_track_next 248 + ); 249 + bridge_unit_str!( 250 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1insert_1track_1last, 251 + rb_insert_track_last 252 + ); 253 + 254 + #[no_mangle] 255 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1insert_1tracks( 256 + mut env: JNIEnv, 257 + _cls: JClass, 258 + paths_json: JString, 259 + position: jint, 260 + shuffle: jint, 261 + ) -> jint { 262 + let Some(c) = to_cstring(&mut env, &paths_json) else { return -1 }; 263 + crate::rb_insert_tracks(c.as_ptr(), position as c_int, shuffle as c_int) as jint 264 + } 265 + 266 + #[no_mangle] 267 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1insert_1directory( 268 + mut env: JNIEnv, 269 + _cls: JClass, 270 + path: JString, 271 + position: jint, 272 + ) -> jint { 273 + let Some(c) = to_cstring(&mut env, &path) else { return -1 }; 274 + crate::rb_insert_directory(c.as_ptr(), position as c_int) as jint 275 + } 276 + 277 + bridge_json!( 278 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1playlist_1current_1json, 279 + rb_get_playlist_current_json 280 + ); 281 + 282 + // ── Library / search ──────────────────────────────────────────────────────── 283 + 284 + bridge_json!( 285 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1tracks_1json, 286 + rb_get_tracks_json 287 + ); 288 + bridge_json!( 289 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1artists_1json, 290 + rb_get_artists_json 291 + ); 292 + bridge_json!( 293 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1albums_1json, 294 + rb_get_albums_json 295 + ); 296 + bridge_json!( 297 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1liked_1albums_1json, 298 + rb_get_liked_albums_json 299 + ); 300 + bridge_json_str!( 301 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1artist_1json, 302 + rb_get_artist_json 303 + ); 304 + bridge_json_str!( 305 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1album_1json, 306 + rb_get_album_json 307 + ); 308 + bridge_json!( 309 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1liked_1tracks_1json, 310 + rb_get_liked_tracks_json 311 + ); 312 + bridge_json_str!( 313 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1search_1json, 314 + rb_search_json 315 + ); 316 + bridge_unit_str!( 317 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1like_1track, 318 + rb_like_track 319 + ); 320 + bridge_unit_str!( 321 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1unlike_1track, 322 + rb_unlike_track 323 + ); 324 + 325 + // ── Sound / status ────────────────────────────────────────────────────────── 326 + 327 + bridge_json!( 328 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1status_1json, 329 + rb_status_json 330 + ); 331 + bridge_json!( 332 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1current_1track_1json, 333 + rb_current_track_json 334 + ); 335 + bridge_unit_int!( 336 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1adjust_1volume, 337 + rb_adjust_volume 338 + ); 339 + bridge_json_int!( 340 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1sound_1current_1json, 341 + rb_sound_current_json 342 + ); 343 + 344 + // ── Settings ──────────────────────────────────────────────────────────────── 345 + 346 + bridge_unit_int!( 347 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1save_1shuffle, 348 + rb_save_shuffle 349 + ); 350 + bridge_unit_int!( 351 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1save_1repeat, 352 + rb_save_repeat 353 + ); 354 + bridge_json!( 355 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1global_1settings_1json, 356 + rb_get_global_settings_json 357 + ); 358 + bridge_json!( 359 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1global_1status_1json, 360 + rb_get_global_status_json 361 + ); 362 + 363 + // ── Browse ────────────────────────────────────────────────────────────────── 364 + 365 + #[no_mangle] 366 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1tree_1get_1entries_1json( 367 + mut env: JNIEnv, 368 + _cls: JClass, 369 + path: JString, 370 + ) -> jstring { 371 + let owned = to_cstring(&mut env, &path); 372 + let ptr = owned.as_ref().map(|c| c.as_ptr()).unwrap_or(ptr::null()); 373 + let p = crate::rb_tree_get_entries_json(ptr); 374 + cstr_to_jstring(&mut env, p) 375 + } 376 + 377 + // ── Saved playlists ───────────────────────────────────────────────────────── 378 + 379 + bridge_json!( 380 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1saved_1playlists_1json, 381 + rb_get_saved_playlists_json 382 + ); 383 + 384 + #[no_mangle] 385 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1create_1saved_1playlist( 386 + mut env: JNIEnv, 387 + _cls: JClass, 388 + name: JString, 389 + description: JString, 390 + ids_json: JString, 391 + ) -> jint { 392 + let Some(name_c) = to_cstring(&mut env, &name) else { return -1 }; 393 + let desc_c = to_cstring(&mut env, &description); 394 + let ids_c = to_cstring(&mut env, &ids_json); 395 + let desc_ptr = desc_c.as_ref().map(|c| c.as_ptr()).unwrap_or(ptr::null()); 396 + let ids_ptr = ids_c.as_ref().map(|c| c.as_ptr()).unwrap_or(ptr::null()); 397 + crate::rb_create_saved_playlist(name_c.as_ptr(), desc_ptr, ids_ptr) as jint 398 + } 399 + 400 + #[no_mangle] 401 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1update_1saved_1playlist( 402 + mut env: JNIEnv, 403 + _cls: JClass, 404 + id: JString, 405 + name: JString, 406 + description: JString, 407 + ) -> jint { 408 + let Some(id_c) = to_cstring(&mut env, &id) else { return -1 }; 409 + let Some(name_c) = to_cstring(&mut env, &name) else { return -1 }; 410 + let desc_c = to_cstring(&mut env, &description); 411 + let desc_ptr = desc_c.as_ref().map(|c| c.as_ptr()).unwrap_or(ptr::null()); 412 + crate::rb_update_saved_playlist(id_c.as_ptr(), name_c.as_ptr(), desc_ptr) as jint 413 + } 414 + 415 + bridge_unit_str!( 416 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1delete_1saved_1playlist, 417 + rb_delete_saved_playlist 418 + ); 419 + 420 + #[no_mangle] 421 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1add_1track_1to_1playlist( 422 + mut env: JNIEnv, 423 + _cls: JClass, 424 + pid: JString, 425 + tid: JString, 426 + ) -> jint { 427 + let Some(p) = to_cstring(&mut env, &pid) else { return -1 }; 428 + let Some(t) = to_cstring(&mut env, &tid) else { return -1 }; 429 + crate::rb_add_track_to_playlist(p.as_ptr(), t.as_ptr()) as jint 430 + } 431 + 432 + #[no_mangle] 433 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1remove_1track_1from_1playlist( 434 + mut env: JNIEnv, 435 + _cls: JClass, 436 + pid: JString, 437 + tid: JString, 438 + ) -> jint { 439 + let Some(p) = to_cstring(&mut env, &pid) else { return -1 }; 440 + let Some(t) = to_cstring(&mut env, &tid) else { return -1 }; 441 + crate::rb_remove_track_from_playlist(p.as_ptr(), t.as_ptr()) as jint 442 + } 443 + 444 + bridge_json_str!( 445 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1saved_1playlist_1tracks_1json, 446 + rb_get_saved_playlist_tracks_json 447 + ); 448 + bridge_unit_str!( 449 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1saved_1playlist, 450 + rb_play_saved_playlist 451 + ); 452 + 453 + // ── Smart playlists ───────────────────────────────────────────────────────── 454 + 455 + bridge_json!( 456 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1smart_1playlists_1json, 457 + rb_get_smart_playlists_json 458 + ); 459 + bridge_json_str!( 460 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1smart_1playlist_1tracks_1json, 461 + rb_get_smart_playlist_tracks_json 462 + ); 463 + bridge_unit_str!( 464 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1play_1smart_1playlist, 465 + rb_play_smart_playlist 466 + ); 467 + 468 + // ── Bluetooth ─────────────────────────────────────────────────────────────── 469 + 470 + bridge_unit!( 471 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1bluetooth_1available, 472 + rb_bluetooth_available 473 + ); 474 + bridge_unit!( 475 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1scan_1bluetooth, 476 + rb_scan_bluetooth 477 + ); 478 + bridge_json!( 479 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1get_1bluetooth_1devices_1json, 480 + rb_get_bluetooth_devices_json 481 + ); 482 + bridge_unit_str!( 483 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1connect_1bluetooth, 484 + rb_connect_bluetooth 485 + ); 486 + bridge_unit_str!( 487 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1disconnect_1bluetooth, 488 + rb_disconnect_bluetooth 489 + ); 490 + 491 + // ── Streaming ─────────────────────────────────────────────────────────────── 492 + 493 + bridge_unit!( 494 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1subscribe_1status, 495 + rb_subscribe_status 496 + ); 497 + bridge_unit!( 498 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1subscribe_1current_1track, 499 + rb_subscribe_current_track 500 + ); 501 + bridge_unit!( 502 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1subscribe_1playlist, 503 + rb_subscribe_playlist 504 + ); 505 + bridge_unit!( 506 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1subscribe_1library, 507 + rb_subscribe_library 508 + ); 509 + bridge_unit_str!( 510 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1subscribe_1discovery, 511 + rb_subscribe_discovery 512 + ); 513 + 514 + #[no_mangle] 515 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1poll_1event( 516 + mut env: JNIEnv, 517 + _cls: JClass, 518 + sub_id: jint, 519 + timeout_ms: jint, 520 + ) -> jstring { 521 + let p = crate::rb_poll_event(sub_id as c_int, timeout_ms as c_int); 522 + cstr_to_jstring(&mut env, p) 523 + } 524 + 525 + bridge_unit_int!( 526 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1unsubscribe, 527 + rb_unsubscribe 528 + ); 529 + 530 + // ── Discovery service-name constants ──────────────────────────────────────── 531 + 532 + bridge_json!( 533 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1rockbox_1service_1name, 534 + rb_rockbox_service_name 535 + ); 536 + bridge_json!( 537 + Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1chromecast_1service_1name, 538 + rb_chromecast_service_name 539 + );
+249 -121
crates/expo/src/lib.rs
··· 34 34 } 35 35 } 36 36 37 + #[cfg(target_os = "android")] 38 + mod jni_bridge; 39 + 37 40 use api::v1alpha1::{ 38 41 bluetooth_service_client::BluetoothServiceClient, browse_service_client::BrowseServiceClient, 39 42 library_service_client::LibraryServiceClient, playback_service_client::PlaybackServiceClient, ··· 44 47 sound_service_client::SoundServiceClient, system_service_client::SystemServiceClient, 45 48 AddTracksToSavedPlaylistRequest, AdjustVolumeRequest, ConnectBluetoothDeviceRequest, 46 49 CreateSavedPlaylistRequest, CurrentTrackRequest, DeleteSavedPlaylistRequest, 47 - DisconnectBluetoothDeviceRequest, FastForwardRewindRequest, GetAlbumRequest, GetArtistsRequest, 48 - GetBluetoothDevicesRequest, GetCurrentRequest, GetGlobalSettingsRequest, 49 - GetGlobalStatusRequest, GetLikedTracksRequest, GetSavedPlaylistTracksRequest, 50 + DisconnectBluetoothDeviceRequest, FastForwardRewindRequest, GetAlbumRequest, 51 + GetAlbumsRequest, GetArtistRequest, GetArtistsRequest, GetBluetoothDevicesRequest, 52 + GetCurrentRequest, GetGlobalSettingsRequest, GetGlobalStatusRequest, 53 + GetLikedAlbumsRequest, GetLikedTracksRequest, GetSavedPlaylistTracksRequest, 50 54 GetSavedPlaylistsRequest, GetSmartPlaylistTracksRequest, GetSmartPlaylistsRequest, 51 55 GetTracksRequest, InsertDirectoryRequest, InsertTracksRequest, LikeTrackRequest, NextRequest, 52 56 PauseRequest, PlayAlbumRequest, PlayAllTracksRequest, PlayArtistTracksRequest, 53 57 PlayDirectoryRequest, PlayOrPauseRequest, PlaySavedPlaylistRequest, PlaySmartPlaylistRequest, 54 58 PlayTrackRequest, PlaylistResumeRequest, PreviousRequest, RemoveTrackFromSavedPlaylistRequest, 55 - RemoveTracksRequest, ResumeRequest, ResumeTrackRequest, SaveSettingsRequest, SearchRequest, 59 + RemoveTracksRequest, ResumeRequest, ResumeTrackRequest, SaveSettingsRequest, 60 + ScanBluetoothRequest, SearchRequest, 56 61 ShufflePlaylistRequest, SoundCurrentRequest, StartRequest, StatusRequest, 57 62 StreamCurrentTrackRequest, StreamLibraryRequest, StreamPlaylistRequest, StreamStatusRequest, 58 63 TreeGetEntriesRequest, UnlikeTrackRequest, UpdateSavedPlaylistRequest, ··· 74 79 static SERVER_URL: Lazy<RwLock<String>> = 75 80 Lazy::new(|| RwLock::new("http://127.0.0.1:6061".to_string())); 76 81 82 + static HTTP_URL: Lazy<RwLock<String>> = 83 + Lazy::new(|| RwLock::new("http://127.0.0.1:6063".to_string())); 84 + 77 85 fn url() -> String { 78 86 SERVER_URL.read().expect("server url poisoned").clone() 87 + } 88 + 89 + fn http_url() -> String { 90 + HTTP_URL.read().expect("http url poisoned").clone() 79 91 } 80 92 81 93 // ── String helpers ─────────────────────────────────────────────────────────── ··· 131 143 return -1; 132 144 }; 133 145 match SERVER_URL.write() { 146 + Ok(mut g) => { 147 + *g = s.to_string(); 148 + 0 149 + } 150 + Err(_) => -2, 151 + } 152 + } 153 + 154 + /// Configure the rockboxd HTTP base URL (the port exposing `/devices` etc.). 155 + /// Defaults to `http://127.0.0.1:6063` if never called. 156 + /// 157 + /// # Safety 158 + /// `url_ptr` must point to a valid NUL-terminated UTF-8 string. 159 + #[no_mangle] 160 + pub unsafe extern "C" fn rb_set_http_url(url_ptr: *const c_char) -> c_int { 161 + let Some(s) = cstr_to_str(url_ptr) else { 162 + return -1; 163 + }; 164 + match HTTP_URL.write() { 134 165 Ok(mut g) => { 135 166 *g = s.to_string(); 136 167 0 ··· 679 710 unwrap_or_err_string(res.map(|r| r.into_inner())) 680 711 } 681 712 713 + #[no_mangle] 714 + pub extern "C" fn rb_get_albums_json() -> *mut c_char { 715 + let res = RT.block_on(async { 716 + let mut c = LibraryServiceClient::connect(url()) 717 + .await 718 + .map_err(|e| e.to_string())?; 719 + connect_err(c.get_albums(GetAlbumsRequest {})).await 720 + }); 721 + unwrap_or_err_string(res.map(|r| r.into_inner())) 722 + } 723 + 724 + #[no_mangle] 725 + pub extern "C" fn rb_get_liked_albums_json() -> *mut c_char { 726 + let res = RT.block_on(async { 727 + let mut c = LibraryServiceClient::connect(url()) 728 + .await 729 + .map_err(|e| e.to_string())?; 730 + connect_err(c.get_liked_albums(GetLikedAlbumsRequest {})).await 731 + }); 732 + unwrap_or_err_string(res.map(|r| r.into_inner())) 733 + } 734 + 735 + /// # Safety 736 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 737 + #[no_mangle] 738 + pub unsafe extern "C" fn rb_get_artist_json(id_ptr: *const c_char) -> *mut c_char { 739 + let Some(id) = cstr_to_str(id_ptr) else { return err_string("missing id"); }; 740 + let id = id.to_string(); 741 + let res = RT.block_on(async { 742 + let mut c = LibraryServiceClient::connect(url()) 743 + .await 744 + .map_err(|e| e.to_string())?; 745 + connect_err(c.get_artist(GetArtistRequest { id })).await 746 + }); 747 + unwrap_or_err_string(res.map(|r| r.into_inner())) 748 + } 749 + 682 750 /// # Safety 683 751 /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 684 752 #[no_mangle] ··· 1083 1151 }) 1084 1152 } 1085 1153 1154 + // ── Cast / AirPlay devices (HTTP REST, mirrors gpui/src/client.rs) ────────── 1155 + // 1156 + // rockboxd exposes a small REST surface on its `http_port` (default 6063) for 1157 + // device picking — Chromecast / AirPlay / Snapcast / UPnP. The gRPC layer 1158 + // doesn't cover this, so we use a tiny `reqwest` client to talk plain HTTP. 1159 + 1160 + #[no_mangle] 1161 + pub extern "C" fn rb_get_devices_json() -> *mut c_char { 1162 + let res: Result<String, String> = RT.block_on(async { 1163 + let url = format!("{}/devices", http_url()); 1164 + let body = reqwest::get(&url) 1165 + .await 1166 + .map_err(|e| format!("get_devices: {e}"))? 1167 + .text() 1168 + .await 1169 + .map_err(|e| format!("get_devices body: {e}"))?; 1170 + Ok(body) 1171 + }); 1172 + match res { 1173 + Ok(json) => CString::new(json) 1174 + .map(|c| c.into_raw()) 1175 + .unwrap_or(std::ptr::null_mut()), 1176 + Err(e) => err_string(e), 1177 + } 1178 + } 1179 + 1180 + /// # Safety 1181 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1182 + #[no_mangle] 1183 + pub unsafe extern "C" fn rb_connect_device(id_ptr: *const c_char) -> c_int { 1184 + let Some(id) = cstr_to_str(id_ptr) else { return -1; }; 1185 + let id = id.to_string(); 1186 + let res: Result<(), String> = RT.block_on(async { 1187 + let url = format!("{}/devices/{id}/connect", http_url()); 1188 + reqwest::Client::new() 1189 + .put(&url) 1190 + .send() 1191 + .await 1192 + .map_err(|e| format!("connect_device: {e}"))?; 1193 + Ok(()) 1194 + }); 1195 + if res.is_ok() { 1196 + 0 1197 + } else { 1198 + -1 1199 + } 1200 + } 1201 + 1202 + /// # Safety 1203 + /// `id_ptr` must be a valid NUL-terminated UTF-8 string. 1204 + #[no_mangle] 1205 + pub unsafe extern "C" fn rb_disconnect_device(id_ptr: *const c_char) -> c_int { 1206 + let Some(id) = cstr_to_str(id_ptr) else { return -1; }; 1207 + let id = id.to_string(); 1208 + let res: Result<(), String> = RT.block_on(async { 1209 + let url = format!("{}/devices/{id}/disconnect", http_url()); 1210 + reqwest::Client::new() 1211 + .put(&url) 1212 + .send() 1213 + .await 1214 + .map_err(|e| format!("disconnect_device: {e}"))?; 1215 + Ok(()) 1216 + }); 1217 + if res.is_ok() { 1218 + 0 1219 + } else { 1220 + -1 1221 + } 1222 + } 1223 + 1086 1224 // ── Bluetooth ─────────────────────────────────────────────────────────────── 1225 + 1226 + /// Trigger a Bluetooth scan on the daemon. Required before `get_devices` 1227 + /// returns anything — rockboxd doesn't continuously scan, the picker has to 1228 + /// kick it. Returns 0 on success. 1229 + #[no_mangle] 1230 + pub extern "C" fn rb_scan_bluetooth() -> c_int { 1231 + let res: Result<(), tonic::Status> = RT.block_on(async { 1232 + let mut c = BluetoothServiceClient::connect(url()) 1233 + .await 1234 + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; 1235 + c.scan(ScanBluetoothRequest { timeout_secs: 8 }).await?; 1236 + Ok(()) 1237 + }); 1238 + if res.is_ok() { 1239 + 0 1240 + } else { 1241 + -1 1242 + } 1243 + } 1087 1244 1088 1245 /// 1 if the Bluetooth service is reachable and answers GetDevices, 0 otherwise. 1089 1246 #[no_mangle] ··· 1205 1362 pub extern "C" fn rb_subscribe_status() -> c_int { 1206 1363 let server_url = url(); 1207 1364 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 - } 1365 + loop { 1366 + let mut c = match PlaybackServiceClient::connect(server_url.clone()).await { 1367 + Ok(c) => c, 1368 + Err(_) => { 1369 + tokio::time::sleep(Duration::from_secs(2)).await; 1370 + continue; 1219 1371 } 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; 1372 + }; 1373 + let mut s = match c.stream_status(StreamStatusRequest {}).await { 1374 + Ok(r) => r.into_inner(), 1375 + Err(_) => { 1376 + tokio::time::sleep(Duration::from_secs(2)).await; 1377 + continue; 1229 1378 } 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; 1379 + }; 1380 + while let Ok(Some(msg)) = s.message().await { 1381 + let payload = serde_json::to_string(&StatusJson { status: msg.status }) 1382 + .unwrap_or_else(|_| "{}".into()); 1383 + if tx.send(payload).await.is_err() { 1384 + return; 1385 + } 1240 1386 } 1387 + // Stream closed; reconnect after a short backoff. 1388 + tokio::time::sleep(Duration::from_secs(1)).await; 1241 1389 } 1242 1390 }) 1243 1391 } ··· 1248 1396 pub extern "C" fn rb_subscribe_current_track() -> c_int { 1249 1397 let server_url = url(); 1250 1398 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 - } 1399 + loop { 1400 + let mut c = match PlaybackServiceClient::connect(server_url.clone()).await { 1401 + Ok(c) => c, 1402 + Err(_) => { 1403 + tokio::time::sleep(Duration::from_secs(2)).await; 1404 + continue; 1405 + } 1406 + }; 1407 + let mut s = match c.stream_current_track(StreamCurrentTrackRequest {}).await { 1408 + Ok(r) => r.into_inner(), 1409 + Err(_) => { 1410 + tokio::time::sleep(Duration::from_secs(2)).await; 1411 + continue; 1271 1412 } 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; 1413 + }; 1414 + while let Ok(Some(msg)) = s.message().await { 1415 + let payload = serde_json::to_string(&TrackJson { 1416 + id: msg.id, 1417 + path: msg.path, 1418 + title: msg.title, 1419 + artist: msg.artist, 1420 + album: msg.album, 1421 + album_art: msg.album_art.filter(|a| !a.is_empty()), 1422 + duration_ms: msg.length as i64, 1423 + elapsed_ms: msg.elapsed as i64, 1424 + }) 1425 + .unwrap_or_else(|_| "{}".into()); 1426 + if tx.send(payload).await.is_err() { 1427 + return; 1281 1428 } 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 1429 } 1430 + tokio::time::sleep(Duration::from_secs(1)).await; 1293 1431 } 1294 1432 }) 1295 1433 } ··· 1307 1445 pub extern "C" fn rb_subscribe_playlist() -> c_int { 1308 1446 let server_url = url(); 1309 1447 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 - } 1448 + loop { 1449 + let mut c = match PlaybackServiceClient::connect(server_url.clone()).await { 1450 + Ok(c) => c, 1451 + Err(_) => { 1452 + tokio::time::sleep(Duration::from_secs(2)).await; 1453 + continue; 1339 1454 } 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; 1455 + }; 1456 + let mut s = match c.stream_playlist(StreamPlaylistRequest {}).await { 1457 + Ok(r) => r.into_inner(), 1458 + Err(_) => { 1459 + tokio::time::sleep(Duration::from_secs(2)).await; 1460 + continue; 1349 1461 } 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; 1462 + }; 1463 + while let Ok(Some(msg)) = s.message().await { 1464 + let tracks: Vec<TrackJson> = msg 1465 + .tracks 1466 + .into_iter() 1467 + .map(|t| TrackJson { 1468 + id: t.id, 1469 + path: t.path, 1470 + title: t.title, 1471 + artist: t.artist, 1472 + album: t.album, 1473 + album_art: t.album_art.filter(|a| !a.is_empty()), 1474 + duration_ms: t.length as i64, 1475 + elapsed_ms: t.elapsed as i64, 1476 + }) 1477 + .collect(); 1478 + let payload = serde_json::to_string(&PlaylistEventJson { 1479 + index: msg.index, 1480 + amount: msg.amount, 1481 + tracks, 1482 + }) 1483 + .unwrap_or_else(|_| "{}".into()); 1484 + if tx.send(payload).await.is_err() { 1485 + return; 1486 + } 1360 1487 } 1488 + tokio::time::sleep(Duration::from_secs(1)).await; 1361 1489 } 1362 1490 }) 1363 1491 }
+11 -4
expo/app.json
··· 9 9 "userInterfaceStyle": "dark", 10 10 "newArchEnabled": true, 11 11 "ios": { 12 - "supportsTablet": true 12 + "supportsTablet": true, 13 + "bundleIdentifier": "com.tsirysndr.Rockbox" 13 14 }, 14 15 "android": { 15 16 "adaptiveIcon": { 16 - "backgroundColor": "#000000", 17 + "backgroundColor": "#ffc001", 17 18 "foregroundImage": "./assets/images/android-icon-foreground.png", 18 - "backgroundImage": "./assets/images/android-icon-background.png", 19 19 "monochromeImage": "./assets/images/android-icon-monochrome.png" 20 20 }, 21 21 "edgeToEdgeEnabled": true, ··· 46 46 { 47 47 "fonts": [ 48 48 "./assets/fonts/SpaceGrotesk.ttf", 49 - "./assets/fonts/JetBrainsMono.ttf" 49 + "./assets/fonts/JetBrainsMono.ttf", 50 + "./assets/fonts/RockfordSans-Light.otf", 51 + "./assets/fonts/RockfordSans-Regular.otf", 52 + "./assets/fonts/RockfordSans-RegularItalic.otf", 53 + "./assets/fonts/RockfordSans-Medium.otf", 54 + "./assets/fonts/RockfordSans-Bold.otf", 55 + "./assets/fonts/RockfordSans-BoldItalic.otf", 56 + "./assets/fonts/RockfordSans-ExtraBold.otf" 50 57 ] 51 58 } 52 59 ]
+1 -1
expo/app/(tabs)/_layout.tsx
··· 89 89 color={isFocused ? Colors.textPrimary : Colors.textMuted} 90 90 /> 91 91 <Text 92 - className={`text-[11px] font-medium ${isFocused ? "text-text-primary" : "text-text-muted"}`} 92 + className={`text-[11px] font-display-medium ${isFocused ? "text-text-primary" : "text-text-muted"}`} 93 93 > 94 94 {label} 95 95 </Text>
+137 -81
expo/app/(tabs)/index.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 - import { Image } from "expo-image"; 3 2 import { router } from "expo-router"; 4 3 import { Pressable, ScrollView, Text, View } from "react-native"; 5 4 import { SafeAreaView } from "react-native-safe-area-context"; 6 5 7 6 import { CardRow } from "@/components/card-row"; 7 + import { NotConnectedState } from "@/components/empty-state"; 8 + import { PlaylistCover } from "@/components/playlist-cover"; 8 9 import { SectionHeader } from "@/components/section-header"; 9 10 import { Colors } from "@/constants/theme"; 11 + import { useIsConnected } from "@/lib/connection"; 10 12 import { 11 - ALBUMS, 12 - MADE_FOR_YOU, 13 - PLAYLISTS, 14 - RECENTLY_PLAYED, 15 - TOP_ARTISTS, 16 - } from "@/lib/mock-data"; 13 + useLibraryAlbums, 14 + useLibraryArtists, 15 + useLibraryPlaylists, 16 + } from "@/lib/library-source"; 17 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 17 18 18 19 export default function HomeScreen() { 19 - const quickPicks = PLAYLISTS.slice(0, 6); 20 + // All sections back-by real gRPC reads. The "Recently played" / "Made 21 + // for you" / "Top artists" / "Popular albums" buckets are slices of the 22 + // real catalog for now — backend filters land later. 23 + const isConnected = useIsConnected(); 24 + const { data: albums } = useLibraryAlbums(); 25 + const { data: artists } = useLibraryArtists(); 26 + const { data: playlists } = useLibraryPlaylists(); 27 + const bottomPad = useBottomSpacing(24); 28 + 29 + const quickPicks = playlists.slice(0, 6); 30 + const recentlyPlayed = albums.slice(0, 8); 31 + const madeForYou = playlists.filter((p) => p.isSmart).slice(0, 12); 32 + const topArtists = artists.slice(0, 12); 33 + const popularAlbums = albums.slice(0, 12); 20 34 21 35 return ( 22 36 <SafeAreaView className="flex-1 bg-bg" edges={["top"]}> 23 37 <ScrollView 24 - contentContainerStyle={{ paddingBottom: 24 }} 38 + contentContainerStyle={{ paddingBottom: bottomPad }} 25 39 showsVerticalScrollIndicator={false} 26 40 > 27 41 <View className="px-4 pt-2 pb-4 flex-row items-center justify-between"> 28 - <Text className="text-text-primary text-[26px] font-extrabold font-sans"> 42 + <Text className="text-text-primary text-[26px] font-display-extra"> 29 43 Home 30 44 </Text> 31 45 <Pressable ··· 42 56 </View> 43 57 44 58 {/* Quick picks 2-column grid (Spotify-style) */} 45 - <View className="px-4 flex-row flex-wrap gap-2 mb-6"> 46 - {quickPicks.map((p) => ( 47 - <Pressable 48 - key={p.id} 49 - onPress={() => router.push(`/playlist/${p.id}`)} 50 - className="w-[48.5%] flex-row items-center bg-bg-card rounded-md overflow-hidden active:bg-bg-hover" 51 - > 52 - <Image 53 - source={p.artwork} 54 - className="w-14 h-14" 55 - contentFit="cover" 56 - /> 57 - <Text 58 - numberOfLines={2} 59 - className="flex-1 text-text-primary text-[13px] font-semibold px-2.5 font-sans" 59 + {quickPicks.length > 0 ? ( 60 + <View className="px-4 flex-row flex-wrap gap-2 mb-6"> 61 + {quickPicks.map((p) => ( 62 + <Pressable 63 + key={p.id} 64 + onPress={() => router.push(`/playlist/${p.id}`)} 65 + className="w-[48.5%] flex-row items-center bg-bg-card rounded-md overflow-hidden active:bg-bg-hover" 60 66 > 61 - {p.name} 62 - </Text> 63 - </Pressable> 64 - ))} 65 - </View> 67 + <PlaylistCover 68 + artwork={p.artwork} 69 + seed={p.id || p.name} 70 + size={56} 71 + rounded="sm" 72 + iconSize={22} 73 + /> 74 + <Text 75 + numberOfLines={2} 76 + className="flex-1 text-text-primary text-[13px] font-semibold px-2.5 font-sans" 77 + > 78 + {p.name} 79 + </Text> 80 + </Pressable> 81 + ))} 82 + </View> 83 + ) : null} 66 84 67 - <SectionHeader title="Recently played" /> 68 - <View className="mb-7"> 69 - <CardRow 70 - data={RECENTLY_PLAYED.map((a) => ({ 71 - id: a.id, 72 - title: a.title, 73 - subtitle: a.artist, 74 - image: a.artwork, 75 - }))} 76 - onPress={(item) => router.push(`/album/${item.id}`)} 77 - /> 78 - </View> 85 + {recentlyPlayed.length > 0 ? ( 86 + <> 87 + <SectionHeader title="Recently played" /> 88 + <View className="mb-7"> 89 + <CardRow 90 + data={recentlyPlayed.map((a) => ({ 91 + id: a.id, 92 + title: a.title, 93 + subtitle: a.artist, 94 + image: a.artwork, 95 + placeholderIcon: "disc", 96 + }))} 97 + onPress={(item) => router.push(`/album/${item.id}`)} 98 + /> 99 + </View> 100 + </> 101 + ) : null} 79 102 80 - <SectionHeader 81 - title="Made for you" 82 - subtitle="Curated mixes refreshed daily" 83 - /> 84 - <View className="mb-7"> 85 - <CardRow 86 - data={MADE_FOR_YOU.map((p) => ({ 87 - id: p.id, 88 - title: p.name, 89 - subtitle: p.description, 90 - image: p.artwork, 91 - }))} 92 - onPress={(item) => router.push(`/playlist/${item.id}`)} 93 - /> 94 - </View> 103 + {madeForYou.length > 0 ? ( 104 + <> 105 + <SectionHeader 106 + title="Made for you" 107 + subtitle="Smart playlists refreshed automatically" 108 + /> 109 + <View className="mb-7"> 110 + <CardRow 111 + data={madeForYou.map((p) => ({ 112 + id: p.id, 113 + title: p.name, 114 + subtitle: p.description, 115 + image: p.artwork, 116 + placeholderIcon: "flash", 117 + colorfulPlaceholder: true, 118 + }))} 119 + onPress={(item) => router.push(`/playlist/${item.id}`)} 120 + /> 121 + </View> 122 + </> 123 + ) : null} 124 + 125 + {topArtists.length > 0 ? ( 126 + <> 127 + <SectionHeader title="Your top artists" /> 128 + <View className="mb-7"> 129 + <CardRow 130 + size={130} 131 + data={topArtists.map((a) => ({ 132 + id: a.id, 133 + title: a.name, 134 + subtitle: "Artist", 135 + image: a.image, 136 + rounded: "full" as const, 137 + placeholderIcon: "person", 138 + }))} 139 + onPress={(item) => router.push(`/artist/${item.id}`)} 140 + /> 141 + </View> 142 + </> 143 + ) : null} 95 144 96 - <SectionHeader title="Your top artists" /> 97 - <View className="mb-7"> 98 - <CardRow 99 - size={130} 100 - data={TOP_ARTISTS.map((a) => ({ 101 - id: a.id, 102 - title: a.name, 103 - subtitle: "Artist", 104 - image: a.image, 105 - rounded: "full" as const, 106 - }))} 107 - onPress={(item) => router.push(`/artist/${item.id}`)} 108 - /> 109 - </View> 145 + {popularAlbums.length > 0 ? ( 146 + <> 147 + <SectionHeader title="Popular albums" /> 148 + <CardRow 149 + data={popularAlbums.map((a) => ({ 150 + id: a.id, 151 + title: a.title, 152 + subtitle: `${a.artist}${a.year ? ` • ${a.year}` : ""}`, 153 + image: a.artwork, 154 + placeholderIcon: "disc", 155 + }))} 156 + onPress={(item) => router.push(`/album/${item.id}`)} 157 + /> 158 + </> 159 + ) : null} 110 160 111 - <SectionHeader title="Popular albums" /> 112 - <CardRow 113 - data={ALBUMS.map((a) => ({ 114 - id: a.id, 115 - title: a.title, 116 - subtitle: `${a.artist}${a.year ? ` • ${a.year}` : ""}`, 117 - image: a.artwork, 118 - }))} 119 - onPress={(item) => router.push(`/album/${item.id}`)} 120 - /> 161 + {!isConnected ? ( 162 + <NotConnectedState /> 163 + ) : albums.length === 0 && 164 + artists.length === 0 && 165 + playlists.length === 0 ? ( 166 + <View className="px-6 pt-12 items-center"> 167 + <Ionicons 168 + name="musical-notes-outline" 169 + size={48} 170 + color={Colors.textMuted} 171 + /> 172 + <Text className="text-text-secondary text-[14px] text-center mt-4 font-sans"> 173 + Library is empty — wait for the daemon to finish scanning. 174 + </Text> 175 + </View> 176 + ) : null} 121 177 </ScrollView> 122 178 </SafeAreaView> 123 179 );
+114 -76
expo/app/(tabs)/library.tsx
··· 13 13 } from "react-native"; 14 14 import { SafeAreaView } from "react-native-safe-area-context"; 15 15 16 + import { NotConnectedState } from "@/components/empty-state"; 17 + import { EqualizerBars } from "@/components/equalizer-bars"; 18 + import { PlaylistCover } from "@/components/playlist-cover"; 16 19 import { TrackMenuButton } from "@/components/track-menu-button"; 20 + import { useIsConnected } from "@/lib/connection"; 21 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 17 22 import { Colors } from "@/constants/theme"; 23 + import { formatDuration } from "@/lib/mock-data"; 18 24 import { 19 - ALBUMS, 20 - ALL_SONGS, 21 - ARTISTS, 22 - PLAYLISTS, 23 - formatDuration, 24 - } from "@/lib/mock-data"; 25 + useLibraryAlbums, 26 + useLibraryArtists, 27 + useLibraryPlaylists, 28 + useLibraryTracks, 29 + } from "@/lib/library-source"; 25 30 import { usePlayer } from "@/lib/player-context"; 26 31 import type { LibrarySection } from "@/lib/types"; 27 32 ··· 38 43 const [searchOpen, setSearchOpen] = useState(false); 39 44 const [searchQuery, setSearchQuery] = useState(""); 40 45 const [createOpen, setCreateOpen] = useState(false); 41 - const { liked, jumpTo, queue, userPlaylists } = usePlayer(); 46 + const { liked, userPlaylists, playTrack, currentTrack, isPlaying } = usePlayer(); 47 + const isConnected = useIsConnected(); 48 + const bottomPad = useBottomSpacing(24); 49 + const { data: tracks } = useLibraryTracks(); 50 + const { data: albums } = useLibraryAlbums(); 51 + const { data: artists } = useLibraryArtists(); 52 + const { data: playlists } = useLibraryPlaylists(); 42 53 43 54 const q = searchQuery.trim().toLowerCase(); 44 55 const allPlaylists = useMemo( 45 - () => [...userPlaylists, ...PLAYLISTS], 46 - [userPlaylists], 56 + () => [...userPlaylists, ...playlists], 57 + [userPlaylists, playlists], 47 58 ); 48 59 const filteredPlaylists = useMemo( 49 60 () => ··· 55 66 const filteredSongs = useMemo( 56 67 () => 57 68 q 58 - ? ALL_SONGS.filter( 69 + ? tracks.filter( 59 70 (t) => 60 71 t.title.toLowerCase().includes(q) || 61 72 t.artist.toLowerCase().includes(q), 62 73 ) 63 - : ALL_SONGS, 64 - [q], 74 + : tracks, 75 + [q, tracks], 65 76 ); 66 77 const filteredAlbums = useMemo( 67 78 () => 68 79 q 69 - ? ALBUMS.filter( 80 + ? albums.filter( 70 81 (a) => 71 82 a.title.toLowerCase().includes(q) || 72 83 a.artist.toLowerCase().includes(q), 73 84 ) 74 - : ALBUMS, 75 - [q], 85 + : albums, 86 + [q, albums], 76 87 ); 77 88 const filteredArtists = useMemo( 78 - () => (q ? ARTISTS.filter((a) => a.name.toLowerCase().includes(q)) : ARTISTS), 79 - [q], 89 + () => (q ? artists.filter((a) => a.name.toLowerCase().includes(q)) : artists), 90 + [q, artists], 80 91 ); 81 92 82 93 return ( 83 94 <SafeAreaView className="flex-1 bg-bg" edges={["top"]}> 84 95 <View className="px-4 pt-2 pb-3 flex-row items-center justify-between"> 85 - <Text className="text-text-primary text-[26px] font-extrabold font-sans"> 96 + <Text className="text-text-primary text-[26px] font-display-extra"> 86 97 Your Library 87 98 </Text> 88 99 <View className="flex-row gap-3"> ··· 161 172 </ScrollView> 162 173 </View> 163 174 164 - {section === "playlists" ? ( 175 + {!isConnected ? ( 176 + <NotConnectedState /> 177 + ) : section === "playlists" ? ( 165 178 <FlatList 166 179 key="list-playlists" 167 180 data={filteredPlaylists} 168 181 keyExtractor={(p) => p.id} 169 182 contentContainerStyle={{ 170 183 paddingHorizontal: 16, 171 - paddingBottom: 24, 184 + paddingBottom: bottomPad, 172 185 gap: 8, 173 186 }} 174 187 renderItem={({ item }) => ( ··· 176 189 onPress={() => router.push(`/playlist/${item.id}`)} 177 190 className="flex-row items-center gap-3 py-1.5 active:opacity-80" 178 191 > 179 - <Image 180 - source={item.artwork} 181 - className="w-[60px] h-[60px] rounded-md" 182 - contentFit="cover" 192 + <PlaylistCover 193 + artwork={item.artwork} 194 + seed={item.id || item.name} 195 + size={60} 196 + rounded="md" 197 + iconSize={24} 183 198 /> 184 199 <View className="flex-1"> 185 200 <Text ··· 192 207 numberOfLines={1} 193 208 className="text-text-secondary text-[13px] mt-0.5 font-sans" 194 209 > 195 - {item.isSmart ? "Smart playlist" : "Playlist"} •{" "} 196 - {item.trackCount} tracks 210 + {item.isSmart ? "Smart playlist" : "Playlist"} 211 + {item.trackCount > 0 ? ` • ${item.trackCount} tracks` : ""} 197 212 </Text> 198 213 </View> 199 214 </Pressable> ··· 204 219 key="list-songs" 205 220 data={filteredSongs} 206 221 keyExtractor={(t) => t.id} 207 - contentContainerStyle={{ paddingBottom: 24 }} 208 - renderItem={({ item, index }) => ( 209 - <Pressable 210 - onPress={() => jumpTo(index)} 211 - className="flex-row items-center gap-3 px-4 py-2.5 active:bg-bg-hover" 212 - > 213 - {item.artwork ? ( 214 - <Image 215 - source={item.artwork} 216 - className="w-12 h-12 rounded" 217 - contentFit="cover" 218 - /> 219 - ) : ( 220 - <View className="w-12 h-12 bg-bg-card rounded items-center justify-center"> 221 - <Ionicons 222 - name="musical-note" 223 - size={18} 224 - color={Colors.textMuted} 222 + contentContainerStyle={{ paddingBottom: bottomPad }} 223 + renderItem={({ item }) => { 224 + const isCurrent = currentTrack?.id === item.id && !!item.id; 225 + return ( 226 + <Pressable 227 + onPress={() => playTrack(item)} 228 + className="flex-row items-center gap-3 px-4 py-2.5 active:bg-bg-hover" 229 + > 230 + {item.artwork ? ( 231 + <Image 232 + source={item.artwork} 233 + className="w-12 h-12 rounded" 234 + contentFit="cover" 225 235 /> 236 + ) : ( 237 + <View className="w-12 h-12 bg-bg-card rounded items-center justify-center"> 238 + <Ionicons 239 + name="musical-note" 240 + size={18} 241 + color={Colors.textMuted} 242 + /> 243 + </View> 244 + )} 245 + <View className="flex-1"> 246 + <Text 247 + numberOfLines={1} 248 + className={`text-sm font-medium font-sans ${isCurrent ? "text-accent" : "text-text-primary"}`} 249 + > 250 + {item.title} 251 + </Text> 252 + <Text 253 + numberOfLines={1} 254 + className="text-text-secondary text-xs font-sans" 255 + > 256 + {item.artist} 257 + </Text> 226 258 </View> 227 - )} 228 - <View className="flex-1"> 229 - <Text 230 - numberOfLines={1} 231 - className="text-text-primary text-sm font-medium font-sans" 232 - > 233 - {item.title} 234 - </Text> 235 - <Text 236 - numberOfLines={1} 237 - className="text-text-secondary text-xs font-sans" 238 - > 239 - {item.artist} 259 + <Text className="text-text-muted text-xs font-mono"> 260 + {formatDuration(item.duration)} 240 261 </Text> 241 - </View> 242 - <Text className="text-text-muted text-xs font-mono"> 243 - {formatDuration(item.duration)} 244 - </Text> 245 - <TrackMenuButton track={item} /> 246 - </Pressable> 247 - )} 262 + {isCurrent ? ( 263 + <EqualizerBars size={14} playing={isPlaying} /> 264 + ) : null} 265 + <TrackMenuButton track={item} /> 266 + </Pressable> 267 + ); 268 + }} 248 269 /> 249 270 ) : section === "albums" ? ( 250 271 <FlatList ··· 253 274 keyExtractor={(a) => a.id} 254 275 numColumns={2} 255 276 columnWrapperStyle={{ gap: 12, paddingHorizontal: 16 }} 256 - contentContainerStyle={{ paddingBottom: 24, gap: 16 }} 277 + contentContainerStyle={{ paddingBottom: bottomPad, gap: 16 }} 257 278 renderItem={({ item }) => ( 258 279 <Pressable 259 280 onPress={() => router.push(`/album/${item.id}`)} ··· 286 307 keyExtractor={(a) => a.id} 287 308 contentContainerStyle={{ 288 309 paddingHorizontal: 16, 289 - paddingBottom: 24, 310 + paddingBottom: bottomPad, 290 311 gap: 8, 291 312 }} 292 313 renderItem={({ item }) => ( ··· 294 315 onPress={() => router.push(`/artist/${item.id}`)} 295 316 className="flex-row items-center gap-3.5 py-1.5 active:opacity-80" 296 317 > 297 - <Image 298 - source={item.image} 299 - className="w-14 h-14 rounded-full" 300 - contentFit="cover" 301 - /> 318 + {item.image ? ( 319 + <Image 320 + source={item.image} 321 + className="w-14 h-14 rounded-full" 322 + contentFit="cover" 323 + /> 324 + ) : ( 325 + <View className="w-14 h-14 rounded-full bg-bg-card items-center justify-center"> 326 + <Ionicons name="person" size={20} color={Colors.textMuted} /> 327 + </View> 328 + )} 302 329 <View className="flex-1"> 303 330 <Text className="text-text-primary text-[15px] font-semibold font-sans"> 304 331 {item.name} ··· 322 349 <Ionicons name="heart" size={36} color="#FFFFFF" /> 323 350 </View> 324 351 <View className="flex-1"> 325 - <Text className="text-text-primary text-[22px] font-extrabold font-sans"> 352 + <Text className="text-text-primary text-[22px] font-display-extra"> 326 353 Liked Songs 327 354 </Text> 328 355 <Text className="text-text-secondary text-[13px] mt-1 font-sans"> ··· 338 365 </Text> 339 366 } 340 367 renderItem={({ item }) => { 341 - const idx = queue.findIndex((t) => t.id === item.id); 368 + const isCurrent = currentTrack?.id === item.id && !!item.id; 342 369 return ( 343 370 <Pressable 344 - onPress={() => idx >= 0 && jumpTo(idx)} 371 + onPress={() => playTrack(item)} 345 372 className="flex-row items-center gap-3 px-4 py-2.5 active:bg-bg-hover" 346 373 > 347 374 {item.artwork ? ( ··· 350 377 className="w-11 h-11 rounded" 351 378 contentFit="cover" 352 379 /> 353 - ) : null} 380 + ) : ( 381 + <View className="w-11 h-11 rounded bg-bg-card items-center justify-center"> 382 + <Ionicons 383 + name="musical-note" 384 + size={16} 385 + color={Colors.textMuted} 386 + /> 387 + </View> 388 + )} 354 389 <View className="flex-1"> 355 390 <Text 356 391 numberOfLines={1} 357 - className="text-text-primary text-sm font-medium font-sans" 392 + className={`text-sm font-medium font-sans ${isCurrent ? "text-accent" : "text-text-primary"}`} 358 393 > 359 394 {item.title} 360 395 </Text> ··· 365 400 {item.artist} 366 401 </Text> 367 402 </View> 403 + {isCurrent ? ( 404 + <EqualizerBars size={14} playing={isPlaying} /> 405 + ) : null} 368 406 <TrackMenuButton track={item} /> 369 407 </Pressable> 370 408 );
+141 -39
expo/app/(tabs)/search.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 + import { Image } from "expo-image"; 2 3 import { router } from "expo-router"; 3 - import { useMemo, useState } from "react"; 4 + import { useState } from "react"; 4 5 import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; 5 6 import { SafeAreaView } from "react-native-safe-area-context"; 6 7 8 + import { NotConnectedState } from "@/components/empty-state"; 9 + import { EqualizerBars } from "@/components/equalizer-bars"; 7 10 import { TrackMenuButton } from "@/components/track-menu-button"; 11 + import { useIsConnected } from "@/lib/connection"; 12 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 8 13 import { Colors } from "@/constants/theme"; 9 - import { ALL_SONGS, GENRES, formatDuration } from "@/lib/mock-data"; 14 + import { useLibrarySearch } from "@/lib/library-source"; 15 + import { GENRES, formatDuration } from "@/lib/mock-data"; 10 16 import { usePlayer } from "@/lib/player-context"; 11 17 12 18 export default function SearchScreen() { 13 19 const [query, setQuery] = useState(""); 14 - const { jumpTo, queue } = usePlayer(); 15 - 16 - const results = useMemo(() => { 17 - const q = query.trim().toLowerCase(); 18 - if (!q) return []; 19 - return ALL_SONGS.filter( 20 - (t) => 21 - t.title.toLowerCase().includes(q) || 22 - t.artist.toLowerCase().includes(q) || 23 - t.album.toLowerCase().includes(q), 24 - ); 25 - }, [query]); 20 + const { playTrack, currentTrack, isPlaying } = usePlayer(); 21 + const isConnected = useIsConnected(); 22 + const bottomPad = useBottomSpacing(24); 23 + const { data: results } = useLibrarySearch(query); 24 + const tracks = results.tracks; 26 25 27 26 return ( 28 27 <SafeAreaView className="flex-1 bg-bg" edges={["top"]}> ··· 53 52 </View> 54 53 </View> 55 54 56 - {query.trim().length > 0 ? ( 57 - <ScrollView contentContainerStyle={{ paddingBottom: 24 }}> 58 - {results.length === 0 ? ( 55 + {!isConnected ? ( 56 + <NotConnectedState message="Connect to a server to search your library." /> 57 + ) : query.trim().length > 0 ? ( 58 + <ScrollView contentContainerStyle={{ paddingBottom: bottomPad }}> 59 + {tracks.length === 0 && 60 + results.albums.length === 0 && 61 + results.artists.length === 0 ? ( 59 62 <Text className="text-text-secondary px-4 mt-6 font-sans"> 60 63 No results for &ldquo;{query}&rdquo; 61 64 </Text> 62 65 ) : ( 63 - results.map((track) => { 64 - const idx = queue.findIndex((t) => t.id === track.id); 65 - return ( 66 + <> 67 + {results.artists.length > 0 ? ( 68 + <View className="px-4 pt-2 pb-1"> 69 + <Text className="text-text-primary text-base font-display mb-1.5"> 70 + Artists 71 + </Text> 72 + </View> 73 + ) : null} 74 + {results.artists.map((a) => ( 66 75 <Pressable 67 - key={track.id} 68 - onPress={() => idx >= 0 && jumpTo(idx)} 69 - className="flex-row items-center px-4 py-2.5 gap-3 active:bg-bg-hover" 76 + key={`artist-${a.id}`} 77 + onPress={() => router.push(`/artist/${a.id}`)} 78 + className="flex-row items-center px-4 py-2 gap-3 active:bg-bg-hover" 70 79 > 71 - <View className="w-11 h-11 bg-bg-card rounded-md items-center justify-center"> 72 - <Ionicons 73 - name="musical-note" 74 - size={18} 75 - color={Colors.textMuted} 80 + {a.image ? ( 81 + <Image 82 + source={a.image} 83 + className="w-11 h-11 rounded-full" 84 + contentFit="cover" 76 85 /> 77 - </View> 86 + ) : ( 87 + <View className="w-11 h-11 bg-bg-card rounded-full items-center justify-center"> 88 + <Ionicons 89 + name="person" 90 + size={18} 91 + color={Colors.textMuted} 92 + /> 93 + </View> 94 + )} 95 + <Text 96 + numberOfLines={1} 97 + className="text-text-primary text-sm font-semibold font-sans flex-1" 98 + > 99 + {a.name} 100 + </Text> 101 + </Pressable> 102 + ))} 103 + 104 + {results.albums.length > 0 ? ( 105 + <View className="px-4 pt-3 pb-1"> 106 + <Text className="text-text-primary text-base font-display mb-1.5"> 107 + Albums 108 + </Text> 109 + </View> 110 + ) : null} 111 + {results.albums.map((al) => ( 112 + <Pressable 113 + key={`album-${al.id}`} 114 + onPress={() => router.push(`/album/${al.id}`)} 115 + className="flex-row items-center px-4 py-2 gap-3 active:bg-bg-hover" 116 + > 117 + {al.artwork ? ( 118 + <Image 119 + source={al.artwork} 120 + className="w-11 h-11 rounded" 121 + contentFit="cover" 122 + /> 123 + ) : ( 124 + <View className="w-11 h-11 bg-bg-card rounded items-center justify-center"> 125 + <Ionicons name="disc" size={18} color={Colors.textMuted} /> 126 + </View> 127 + )} 78 128 <View className="flex-1"> 79 129 <Text 80 130 numberOfLines={1} 81 131 className="text-text-primary text-sm font-semibold font-sans" 82 132 > 83 - {track.title} 133 + {al.title} 84 134 </Text> 85 135 <Text 86 136 numberOfLines={1} 87 137 className="text-text-secondary text-xs font-sans" 88 138 > 89 - {track.artist} • {track.album} 139 + {al.artist} 90 140 </Text> 91 141 </View> 92 - <Text className="text-text-muted text-xs font-mono"> 93 - {formatDuration(track.duration)} 142 + </Pressable> 143 + ))} 144 + 145 + {tracks.length > 0 ? ( 146 + <View className="px-4 pt-3 pb-1"> 147 + <Text className="text-text-primary text-base font-display mb-1.5"> 148 + Songs 94 149 </Text> 95 - <TrackMenuButton track={track} /> 96 - </Pressable> 97 - ); 98 - }) 150 + </View> 151 + ) : null} 152 + {tracks.map((track) => { 153 + const isCurrent = 154 + currentTrack?.id === track.id && !!track.id; 155 + return ( 156 + <Pressable 157 + key={`track-${track.id}`} 158 + onPress={() => playTrack(track)} 159 + className="flex-row items-center px-4 py-2.5 gap-3 active:bg-bg-hover" 160 + > 161 + {track.artwork ? ( 162 + <Image 163 + source={track.artwork} 164 + className="w-11 h-11 rounded-md" 165 + contentFit="cover" 166 + /> 167 + ) : ( 168 + <View className="w-11 h-11 bg-bg-card rounded-md items-center justify-center"> 169 + <Ionicons 170 + name="musical-note" 171 + size={18} 172 + color={Colors.textMuted} 173 + /> 174 + </View> 175 + )} 176 + <View className="flex-1"> 177 + <Text 178 + numberOfLines={1} 179 + className={`text-sm font-semibold font-sans ${isCurrent ? "text-accent" : "text-text-primary"}`} 180 + > 181 + {track.title} 182 + </Text> 183 + <Text 184 + numberOfLines={1} 185 + className="text-text-secondary text-xs font-sans" 186 + > 187 + {track.artist} • {track.album} 188 + </Text> 189 + </View> 190 + <Text className="text-text-muted text-xs font-mono"> 191 + {formatDuration(track.duration)} 192 + </Text> 193 + {isCurrent ? ( 194 + <EqualizerBars size={14} playing={isPlaying} /> 195 + ) : null} 196 + <TrackMenuButton track={track} /> 197 + </Pressable> 198 + ); 199 + })} 200 + </> 99 201 )} 100 202 </ScrollView> 101 203 ) : ( 102 204 <ScrollView 103 - contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 24 }} 205 + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: bottomPad }} 104 206 showsVerticalScrollIndicator={false} 105 207 > 106 208 <Text className="text-text-primary text-lg font-bold mb-3 font-sans"> ··· 114 216 style={{ backgroundColor: g.color }} 115 217 className="w-[48.5%] h-[100px] rounded-md p-3 overflow-hidden active:opacity-80" 116 218 > 117 - <Text className="text-white text-lg font-bold font-sans"> 219 + <Text className="text-white text-lg font-display"> 118 220 {g.name} 119 221 </Text> 120 222 </Pressable>
+34 -1
expo/app/_layout.tsx
··· 1 1 import "../global.css"; 2 2 import "@/lib/nativewind-setup"; 3 3 import { ThemeProvider } from "@react-navigation/native"; 4 + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 5 import { useFonts } from "expo-font"; 5 6 import { Stack } from "expo-router"; 6 7 import * as SplashScreen from "expo-splash-screen"; 7 8 import { StatusBar } from "expo-status-bar"; 8 - import { useEffect } from "react"; 9 + import { useEffect, useState } from "react"; 9 10 import "react-native-reanimated"; 10 11 12 + import { PersistentMiniPlayer } from "@/components/persistent-mini-player"; 11 13 import { TrackContextMenu } from "@/components/track-context-menu"; 12 14 import { Colors } from "@/constants/theme"; 13 15 import { PlayerProvider } from "@/lib/player-context"; 16 + import { RockboxStreams } from "@/lib/rockbox-streams"; 14 17 15 18 SplashScreen.preventAutoHideAsync().catch(() => {}); 16 19 ··· 40 43 const [fontsLoaded] = useFonts({ 41 44 SpaceGrotesk: require("@/assets/fonts/SpaceGrotesk.ttf"), 42 45 JetBrainsMono: require("@/assets/fonts/JetBrainsMono.ttf"), 46 + "RockfordSans-Light": require("@/assets/fonts/RockfordSans-Light.otf"), 47 + "RockfordSans-Regular": require("@/assets/fonts/RockfordSans-Regular.otf"), 48 + "RockfordSans-RegularItalic": require("@/assets/fonts/RockfordSans-RegularItalic.otf"), 49 + "RockfordSans-Medium": require("@/assets/fonts/RockfordSans-Medium.otf"), 50 + "RockfordSans-Bold": require("@/assets/fonts/RockfordSans-Bold.otf"), 51 + "RockfordSans-BoldItalic": require("@/assets/fonts/RockfordSans-BoldItalic.otf"), 52 + "RockfordSans-ExtraBold": require("@/assets/fonts/RockfordSans-ExtraBold.otf"), 43 53 }); 54 + const [queryClient] = useState( 55 + () => 56 + new QueryClient({ 57 + defaultOptions: { 58 + queries: { 59 + staleTime: 5 * 60 * 1000, 60 + retry: 1, 61 + }, 62 + }, 63 + }), 64 + ); 44 65 45 66 useEffect(() => { 46 67 if (fontsLoaded) SplashScreen.hideAsync().catch(() => {}); ··· 50 71 51 72 return ( 52 73 <ThemeProvider value={navTheme}> 74 + <QueryClientProvider client={queryClient}> 75 + <RockboxStreams /> 53 76 <PlayerProvider> 54 77 <Stack 55 78 screenOptions={{ ··· 77 100 options={{ animation: "slide_from_right" }} 78 101 /> 79 102 <Stack.Screen 103 + name="settings/server" 104 + options={{ animation: "slide_from_right" }} 105 + /> 106 + <Stack.Screen 107 + name="settings/bluetooth" 108 + options={{ animation: "slide_from_right" }} 109 + /> 110 + <Stack.Screen 80 111 name="album/[id]" 81 112 options={{ animation: "slide_from_right" }} 82 113 /> ··· 100 131 options={{ animation: "slide_from_right" }} 101 132 /> 102 133 </Stack> 134 + <PersistentMiniPlayer /> 103 135 <TrackContextMenu /> 104 136 <StatusBar style="light" /> 105 137 </PlayerProvider> 138 + </QueryClientProvider> 106 139 </ThemeProvider> 107 140 ); 108 141 }
+11 -20
expo/app/album/[id].tsx
··· 13 13 import { SafeAreaView } from "react-native-safe-area-context"; 14 14 15 15 import { ActionSheet, type ActionItem } from "@/components/action-sheet"; 16 + import { EqualizerBars } from "@/components/equalizer-bars"; 16 17 import { TrackMenuButton } from "@/components/track-menu-button"; 18 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 17 19 import { Colors } from "@/constants/theme"; 18 - import { 19 - ARTISTS, 20 - formatDuration, 21 - getAlbumById, 22 - getAlbumTracks, 23 - } from "@/lib/mock-data"; 20 + import { useAlbumDetail } from "@/lib/library-source"; 21 + import { ARTISTS, formatDuration } from "@/lib/mock-data"; 24 22 import { usePlayer } from "@/lib/player-context"; 25 23 26 24 const { width } = Dimensions.get("window"); ··· 29 27 30 28 export default function AlbumScreen() { 31 29 const { id } = useLocalSearchParams<{ id: string }>(); 32 - const album = id ? getAlbumById(id) : undefined; 33 - const tracks = useMemo(() => (id ? getAlbumTracks(id) : []), [id]); 30 + const detail = useAlbumDetail(id ?? ""); 31 + const album = detail.album; 32 + const tracks = detail.tracks; 34 33 const { playQueue, currentTrack, isPlaying, playLast } = usePlayer(); 35 34 const [menuOpen, setMenuOpen] = useState(false); 36 35 37 36 const totalDuration = tracks.reduce((s, t) => s + t.duration, 0); 38 37 const totalMinutes = Math.round(totalDuration / 60); 38 + const bottomPad = useBottomSpacing(24); 39 39 40 40 const scrollY = useMemo(() => new Animated.Value(0), []); 41 41 const headerBgOpacity = scrollY.interpolate({ ··· 81 81 [{ nativeEvent: { contentOffset: { y: scrollY } } }], 82 82 { useNativeDriver: true }, 83 83 )} 84 - contentContainerStyle={{ paddingBottom: 32 }} 84 + contentContainerStyle={{ paddingBottom: bottomPad }} 85 85 > 86 86 {/* Hero band: blurred album art behind, sharp art front-and-center */} 87 87 <View ··· 124 124 125 125 {/* Title block */} 126 126 <View className="px-5 mt-3.5"> 127 - <Text className="text-text-primary text-[26px] font-extrabold font-sans"> 127 + <Text className="text-text-primary text-[26px] font-display-extra"> 128 128 {album.title} 129 129 </Text> 130 130 <Pressable ··· 200 200 > 201 201 <View className="w-[22px] items-center"> 202 202 {isCurrent ? ( 203 - <Ionicons 204 - name={isPlaying ? "musical-notes" : "pause"} 205 - size={14} 206 - color={Colors.accent} 207 - /> 203 + <EqualizerBars size={14} playing={isPlaying} /> 208 204 ) : ( 209 205 <Text className="text-text-muted text-[13px] font-mono"> 210 206 {idx + 1} ··· 268 264 setMenuOpen(false); 269 265 tracks.forEach((t) => playLast(t)); 270 266 }, 271 - }, 272 - { 273 - icon: "heart-outline", 274 - label: "Save to Library", 275 - onPress: () => setMenuOpen(false), 276 267 }, 277 268 { 278 269 icon: "add-circle-outline",
+21 -53
expo/app/artist/[id].tsx
··· 14 14 import { SafeAreaView } from "react-native-safe-area-context"; 15 15 16 16 import { ActionSheet, type ActionItem } from "@/components/action-sheet"; 17 + import { EqualizerBars } from "@/components/equalizer-bars"; 17 18 import { TrackMenuButton } from "@/components/track-menu-button"; 19 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 18 20 import { Colors } from "@/constants/theme"; 19 - import { 20 - formatDuration, 21 - getArtistAlbums, 22 - getArtistById, 23 - getArtistTracks, 24 - } from "@/lib/mock-data"; 21 + import { useArtistDetail } from "@/lib/library-source"; 22 + import { formatDuration } from "@/lib/mock-data"; 25 23 import { usePlayer } from "@/lib/player-context"; 26 24 27 25 const { width } = Dimensions.get("window"); ··· 31 29 32 30 export default function ArtistScreen() { 33 31 const { id } = useLocalSearchParams<{ id: string }>(); 34 - const artist = id ? getArtistById(id) : undefined; 35 - const tracks = useMemo(() => (id ? getArtistTracks(id) : []), [id]); 36 - const albums = useMemo(() => (id ? getArtistAlbums(id) : []), [id]); 32 + const detail = useArtistDetail(id ?? ""); 33 + const artist = detail.artist; 34 + const tracks = detail.tracks; 35 + const albums = detail.albums; 37 36 const { playQueue, currentTrack, isPlaying, playLast } = usePlayer(); 38 37 const topTracks = tracks.slice(0, TOP_TRACK_LIMIT); 39 38 const [menuOpen, setMenuOpen] = useState(false); 40 - const [following, setFollowing] = useState(false); 39 + const bottomPad = useBottomSpacing(24); 41 40 42 41 const scrollY = useMemo(() => new Animated.Value(0), []); 43 42 const headerBgOpacity = scrollY.interpolate({ ··· 108 107 [{ nativeEvent: { contentOffset: { y: scrollY } } }], 109 108 { useNativeDriver: true }, 110 109 )} 111 - contentContainerStyle={{ paddingBottom: 32 }} 110 + contentContainerStyle={{ paddingBottom: bottomPad }} 112 111 > 113 112 <View 114 113 className="justify-end" ··· 116 115 > 117 116 <View className="px-5 pb-4"> 118 117 <Text 119 - className="text-text-primary text-3xl font-extrabold font-sans" 118 + className="text-text-primary text-3xl font-display-extra" 120 119 style={{ 121 120 textShadowColor: "rgba(0,0,0,0.6)", 122 121 textShadowRadius: 8, ··· 128 127 </View> 129 128 130 129 <View className="bg-bg pt-4"> 131 - <View className="px-5 flex-row items-center gap-3"> 132 - <Text className="text-text-secondary text-[13px] flex-1 font-sans"> 133 - {artist.followers ? `${artist.followers} followers` : ""} 134 - </Text> 135 - <Pressable 136 - hitSlop={6} 137 - onPress={() => setFollowing((v) => !v)} 138 - className={`px-3.5 h-8 rounded-full border border-text-primary items-center justify-center ${following ? "bg-text-primary" : ""}`} 139 - > 140 - <Text 141 - className={`text-xs font-bold font-sans ${following ? "text-black" : "text-text-primary"}`} 142 - > 143 - {following ? "Following" : "Follow"} 144 - </Text> 145 - </Pressable> 146 - </View> 147 - 148 - <View className="flex-row items-center px-5 mt-4 gap-4"> 130 + <View className="flex-row items-center px-5 gap-4"> 149 131 <Pressable hitSlop={6} onPress={onShuffle}> 150 132 <Ionicons name="shuffle" size={26} color={Colors.textPrimary} /> 151 133 </Pressable> ··· 189 171 onPress={() => playQueue(tracks, { startIdx: idx })} 190 172 className="flex-row items-center px-5 py-2 gap-3 active:bg-bg-hover" 191 173 > 192 - <Text className="w-[18px] text-center text-text-muted text-sm font-mono"> 193 - {idx + 1} 194 - </Text> 174 + <View className="w-[18px] items-center justify-center"> 175 + {isCurrent ? ( 176 + <EqualizerBars size={14} playing={isPlaying} /> 177 + ) : ( 178 + <Text className="text-text-muted text-sm font-mono"> 179 + {idx + 1} 180 + </Text> 181 + )} 182 + </View> 195 183 {t.artwork ? ( 196 184 <Image 197 185 source={t.artwork} ··· 216 204 <Text className="text-text-muted text-xs font-mono"> 217 205 {formatDuration(t.duration)} 218 206 </Text> 219 - {isCurrent && isPlaying ? ( 220 - <Ionicons 221 - name="musical-notes" 222 - size={14} 223 - color={Colors.accent} 224 - /> 225 - ) : null} 226 207 <TrackMenuButton track={t} /> 227 208 </Pressable> 228 209 ); ··· 305 286 topTracks.forEach((t) => playLast(t)); 306 287 }, 307 288 disabled: topTracks.length === 0, 308 - }, 309 - { 310 - icon: following ? "person-remove-outline" : "person-add-outline", 311 - label: following ? "Unfollow" : "Follow", 312 - onPress: () => { 313 - setFollowing((v) => !v); 314 - setMenuOpen(false); 315 - }, 316 - }, 317 - { 318 - icon: "share-outline", 319 - label: "Share", 320 - onPress: () => setMenuOpen(false), 321 289 }, 322 290 ] as ActionItem[] 323 291 }
+4 -2
expo/app/genre/[id].tsx
··· 13 13 import { SafeAreaView } from "react-native-safe-area-context"; 14 14 15 15 import { TrackMenuButton } from "@/components/track-menu-button"; 16 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 16 17 import { Colors } from "@/constants/theme"; 17 18 import { 18 19 formatDuration, ··· 35 36 const artists = useMemo(() => (id ? getGenreArtists(id) : []), [id]); 36 37 const { playQueue, currentTrack, isPlaying } = usePlayer(); 37 38 const topTracks = tracks.slice(0, TOP_TRACK_LIMIT); 39 + const bottomPad = useBottomSpacing(24); 38 40 39 41 const scrollY = useMemo(() => new Animated.Value(0), []); 40 42 const headerBgOpacity = scrollY.interpolate({ ··· 78 80 }} 79 81 > 80 82 <Text 81 - className="absolute right-4 bottom-4 text-white/30 text-[80px] font-extrabold font-sans" 83 + className="absolute right-4 bottom-4 text-white/30 text-[80px] font-display-extra" 82 84 style={{ transform: [{ rotate: "-12deg" }] }} 83 85 numberOfLines={1} 84 86 > ··· 102 104 [{ nativeEvent: { contentOffset: { y: scrollY } } }], 103 105 { useNativeDriver: true }, 104 106 )} 105 - contentContainerStyle={{ paddingBottom: 32 }} 107 + contentContainerStyle={{ paddingBottom: bottomPad }} 106 108 > 107 109 <View 108 110 className="justify-end"
+18 -4
expo/app/player.tsx
··· 2 2 import { BlurView } from "expo-blur"; 3 3 import { Image } from "expo-image"; 4 4 import { router } from "expo-router"; 5 + import { useState } from "react"; 5 6 import { Pressable, Text, View } from "react-native"; 6 7 import { SafeAreaView } from "react-native-safe-area-context"; 7 8 9 + import { 10 + DeviceIcon, 11 + DevicePickerSheet, 12 + useCurrentDeviceLabel, 13 + } from "@/components/device-picker"; 8 14 import { SeekBar } from "@/components/seek-bar"; 9 15 import { Colors } from "@/constants/theme"; 10 16 import { formatDuration } from "@/lib/mock-data"; ··· 28 34 cycleRepeat, 29 35 toggleLike, 30 36 } = usePlayer(); 37 + 38 + const [pickerOpen, setPickerOpen] = useState(false); 39 + const device = useCurrentDeviceLabel(); 31 40 32 41 if (!currentTrack) { 33 42 return ( ··· 121 130 <View className="flex-1 pr-3"> 122 131 <Text 123 132 numberOfLines={1} 124 - className="text-text-primary text-[22px] font-extrabold font-sans" 133 + className="text-text-primary text-[22px] font-display-extra" 125 134 > 126 135 {currentTrack.title} 127 136 </Text> ··· 211 220 </Text> 212 221 <Pressable 213 222 hitSlop={8} 223 + onPress={() => setPickerOpen(true)} 214 224 className="flex-row items-center gap-1.5" 215 225 > 216 - <Ionicons 217 - name="volume-medium-outline" 226 + <DeviceIcon 227 + spec={device.icon} 218 228 size={16} 219 229 color={Colors.textMuted} 220 230 /> 221 231 <Text className="text-text-muted text-[11px] font-sans"> 222 - This Phone 232 + {device.name} 223 233 </Text> 224 234 </Pressable> 225 235 </View> 226 236 </View> 227 237 </SafeAreaView> 238 + <DevicePickerSheet 239 + visible={pickerOpen} 240 + onClose={() => setPickerOpen(false)} 241 + /> 228 242 </View> 229 243 ); 230 244 }
+64 -45
expo/app/playlist/[id].tsx
··· 13 13 import { SafeAreaView } from "react-native-safe-area-context"; 14 14 15 15 import { ActionSheet, type ActionItem } from "@/components/action-sheet"; 16 + import { EqualizerBars } from "@/components/equalizer-bars"; 17 + import { PlaylistCover, gradientColors } from "@/components/playlist-cover"; 16 18 import { TrackMenuButton } from "@/components/track-menu-button"; 19 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 17 20 import { Colors } from "@/constants/theme"; 18 - import { 19 - formatDuration, 20 - getPlaylistById, 21 - getPlaylistTracks, 22 - } from "@/lib/mock-data"; 21 + import { usePlaylistDetail } from "@/lib/library-source"; 22 + import { formatDuration } from "@/lib/mock-data"; 23 23 import { usePlayer } from "@/lib/player-context"; 24 24 25 25 const { width } = Dimensions.get("window"); 26 - const ART_SIZE = Math.min(width * 0.6, 260); 26 + // Compact hero — playlist screens get visited from rows where the user 27 + // already saw the cover, so the title / actions / track list should sit 28 + // higher than on album / artist screens. 29 + const ART_SIZE = Math.min(width * 0.45, 190); 27 30 const HEADER_HEIGHT = 56; 28 31 29 32 export default function PlaylistScreen() { 30 33 const { id } = useLocalSearchParams<{ id: string }>(); 31 34 const { playQueue, currentTrack, isPlaying, userPlaylists, playLast } = 32 35 usePlayer(); 36 + const detail = usePlaylistDetail(id ?? ""); 33 37 const playlist = id 34 - ? userPlaylists.find((p) => p.id === id) ?? getPlaylistById(id) 38 + ? userPlaylists.find((p) => p.id === id) ?? detail.playlist 35 39 : undefined; 36 - const tracks = useMemo(() => (id ? getPlaylistTracks(id) : []), [id]); 40 + const tracks = detail.tracks; 37 41 const [menuOpen, setMenuOpen] = useState(false); 38 42 const isUserPlaylist = !!userPlaylists.find((p) => p.id === id); 43 + const bottomPad = useBottomSpacing(24); 39 44 40 45 const totalDuration = tracks.reduce((s, t) => s + t.duration, 0); 41 46 const totalMinutes = Math.round(totalDuration / 60); ··· 84 89 [{ nativeEvent: { contentOffset: { y: scrollY } } }], 85 90 { useNativeDriver: true }, 86 91 )} 87 - contentContainerStyle={{ paddingBottom: 32 }} 92 + contentContainerStyle={{ paddingBottom: bottomPad }} 88 93 > 89 94 {/* Hero band: blurred art behind, sharp art front-and-center */} 90 95 <View 91 - className="items-center overflow-hidden pb-4" 92 - style={{ paddingTop: HEADER_HEIGHT + 16 }} 96 + className="items-center overflow-hidden pb-2" 97 + style={{ paddingTop: HEADER_HEIGHT + 8 }} 93 98 > 94 - <Image 95 - source={playlist.artwork} 96 - className="absolute inset-0" 97 - contentFit="cover" 98 - blurRadius={40} 99 - /> 99 + {playlist.artwork ? ( 100 + <Image 101 + source={playlist.artwork} 102 + className="absolute inset-0" 103 + contentFit="cover" 104 + blurRadius={40} 105 + /> 106 + ) : ( 107 + <LinearGradient 108 + colors={gradientColors(playlist.id || playlist.name)} 109 + start={{ x: 0, y: 0 }} 110 + end={{ x: 1, y: 1 }} 111 + className="absolute inset-0" 112 + /> 113 + )} 100 114 <LinearGradient 101 115 colors={[ 102 116 "rgba(0,0,0,0.35)", ··· 116 130 shadowOffset: { width: 0, height: 12 }, 117 131 }} 118 132 > 119 - <Image 120 - source={playlist.artwork} 121 - className="rounded-lg" 122 - style={{ width: ART_SIZE, height: ART_SIZE }} 123 - contentFit="cover" 133 + <PlaylistCover 134 + artwork={playlist.artwork} 135 + seed={playlist.id || playlist.name} 136 + size={ART_SIZE} 137 + rounded="lg" 124 138 /> 125 139 </Animated.View> 126 140 </View> 127 141 128 142 {/* Title block */} 129 - <View className="px-5 mt-3.5"> 130 - <Text className="text-text-primary text-[26px] font-extrabold font-sans"> 143 + <View className="px-5 mt-2"> 144 + <Text className="text-text-primary text-[26px] font-display-extra"> 131 145 {playlist.name} 132 146 </Text> 133 147 {playlist.description ? ( ··· 136 150 </Text> 137 151 ) : null} 138 152 <Text className="text-text-muted text-xs mt-2 font-sans"> 139 - Playlist • {tracks.length} tracks • {totalMinutes} min 153 + {[ 154 + playlist.isSmart ? "Smart playlist" : "Playlist", 155 + tracks.length > 0 ? `${tracks.length} tracks` : null, 156 + tracks.length > 0 ? `${totalMinutes} min` : null, 157 + ] 158 + .filter(Boolean) 159 + .join(" • ")} 140 160 </Text> 141 161 </View> 142 162 143 163 {/* Action row */} 144 - <View className="flex-row items-center px-5 mt-5 gap-4"> 164 + <View className="flex-row items-center px-5 mt-3 gap-4"> 145 165 <Pressable 146 166 hitSlop={6} 147 167 onPress={onShuffle} 148 - className="w-11 h-11 rounded-full items-center justify-center" 168 + disabled={tracks.length === 0} 169 + className={`w-11 h-11 rounded-full items-center justify-center ${tracks.length === 0 ? "opacity-40" : ""}`} 149 170 > 150 171 <Ionicons name="shuffle" size={26} color={Colors.textPrimary} /> 151 172 </Pressable> 152 - <Pressable hitSlop={6}> 173 + <Pressable 174 + hitSlop={6} 175 + disabled={tracks.length === 0} 176 + className={tracks.length === 0 ? "opacity-40" : ""} 177 + > 153 178 <Ionicons 154 179 name="heart-outline" 155 180 size={26} 156 181 color={Colors.textPrimary} 157 182 /> 158 183 </Pressable> 159 - <Pressable hitSlop={6} onPress={() => setMenuOpen(true)}> 184 + <Pressable 185 + hitSlop={6} 186 + onPress={() => setMenuOpen(true)} 187 + disabled={tracks.length === 0} 188 + className={tracks.length === 0 ? "opacity-40" : ""} 189 + > 160 190 <Ionicons 161 191 name="ellipsis-horizontal" 162 192 size={26} ··· 166 196 <View className="flex-1" /> 167 197 <Pressable 168 198 onPress={onPlay} 169 - className="w-14 h-14 rounded-full items-center justify-center bg-accent active:opacity-85" 199 + disabled={tracks.length === 0} 200 + className={`w-14 h-14 rounded-full items-center justify-center bg-accent active:opacity-85 ${tracks.length === 0 ? "opacity-40" : ""}`} 170 201 style={{ 171 202 shadowColor: Colors.accent, 172 203 shadowOpacity: 0.5, ··· 184 215 </View> 185 216 186 217 {/* Track list */} 187 - <View className="mt-5"> 218 + <View className="mt-3"> 188 219 {tracks.map((t, idx) => { 189 220 const isCurrent = currentTrack?.id === t.id; 190 221 return ( ··· 225 256 <Text className="text-text-muted text-xs font-mono"> 226 257 {formatDuration(t.duration)} 227 258 </Text> 228 - {isCurrent && isPlaying ? ( 229 - <Ionicons 230 - name="musical-notes" 231 - size={14} 232 - color={Colors.accent} 233 - /> 234 - ) : null} 259 + {isCurrent ? <EqualizerBars size={14} playing={isPlaying} /> : null} 235 260 <TrackMenuButton track={t} /> 236 261 </Pressable> 237 262 ); ··· 290 315 onPress: () => setMenuOpen(false), 291 316 }, 292 317 ] 293 - : [ 294 - { 295 - icon: "heart-outline", 296 - label: "Save to Library", 297 - onPress: () => setMenuOpen(false), 298 - }, 299 - ]), 318 + : []), 300 319 { 301 320 icon: "share-outline", 302 321 label: "Share",
+21 -8
expo/app/playlist/new.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 + import { LinearGradient } from "expo-linear-gradient"; 2 3 import { router, useLocalSearchParams } from "expo-router"; 3 4 import { useState } from "react"; 4 5 import { ··· 12 13 } from "react-native"; 13 14 import { SafeAreaView } from "react-native-safe-area-context"; 14 15 16 + import { gradientColors } from "@/components/playlist-cover"; 15 17 import { Colors } from "@/constants/theme"; 16 18 import { usePlayer } from "@/lib/player-context"; 17 19 ··· 60 62 <Pressable hitSlop={10} onPress={() => router.back()}> 61 63 <Text className="text-text-primary text-sm font-sans">Cancel</Text> 62 64 </Pressable> 63 - <Text className="text-text-primary text-base font-bold font-sans"> 65 + <Text className="text-text-primary text-base font-display"> 64 66 {isSmart ? "New Smart Playlist" : "New Playlist"} 65 67 </Text> 66 68 <Pressable hitSlop={10} disabled={!canCreate} onPress={onCreate}> 67 69 <Text 68 - className={`text-sm font-bold font-sans ${canCreate ? "text-accent" : "text-text-muted"}`} 70 + className={`text-sm font-display ${canCreate ? "text-accent" : "text-text-muted"}`} 69 71 > 70 72 Create 71 73 </Text> ··· 78 80 > 79 81 <View className="items-center py-6"> 80 82 <View 81 - className="w-[140px] h-[140px] rounded-lg bg-bg-card items-center justify-center" 83 + className="w-[140px] h-[140px] rounded-lg overflow-hidden" 82 84 style={{ 83 85 shadowColor: "#000", 84 86 shadowOpacity: 0.5, ··· 86 88 shadowOffset: { width: 0, height: 8 }, 87 89 }} 88 90 > 89 - <Ionicons 90 - name={isSmart ? "flash" : "musical-notes"} 91 - size={56} 92 - color={Colors.textMuted} 93 - /> 91 + <LinearGradient 92 + colors={gradientColors(name || (isSmart ? "smart" : "new"))} 93 + start={{ x: 0, y: 0 }} 94 + end={{ x: 1, y: 1 }} 95 + style={{ 96 + flex: 1, 97 + alignItems: "center", 98 + justifyContent: "center", 99 + }} 100 + > 101 + <Ionicons 102 + name={isSmart ? "flash" : "musical-notes"} 103 + size={56} 104 + color="#FFFFFF" 105 + /> 106 + </LinearGradient> 94 107 </View> 95 108 </View> 96 109
+192 -56
expo/app/queue.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 2 import { Image } from "expo-image"; 3 3 import { router } from "expo-router"; 4 + import { useState } from "react"; 4 5 import { FlatList, Pressable, Text, View } from "react-native"; 5 - import { SafeAreaView } from "react-native-safe-area-context"; 6 + import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; 6 7 8 + import { EqualizerBars } from "@/components/equalizer-bars"; 9 + import { MiniPlayer } from "@/components/mini-player"; 7 10 import { TrackMenuButton } from "@/components/track-menu-button"; 8 11 import { Colors } from "@/constants/theme"; 9 12 import { formatDuration } from "@/lib/mock-data"; 10 13 import { usePlayer } from "@/lib/player-context"; 14 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 15 + import type { Track } from "@/lib/types"; 16 + 17 + type Tab = "next" | "history"; 11 18 12 19 export default function QueueScreen() { 13 - const { queue, currentIdx, jumpTo, removeFromQueue, isPlaying } = usePlayer(); 20 + const { queue, currentTrack, currentIdx, jumpTo, removeFromQueue, isPlaying } = 21 + usePlayer(); 22 + const [tab, setTab] = useState<Tab>("next"); 23 + const bottomPad = useBottomSpacing(24); 24 + const insets = useSafeAreaInsets(); 25 + const floatingBottom = Math.max(insets.bottom, 8) + 4; 26 + 27 + // Resolve current index — if the proto stream hasn't sent one yet, fall 28 + // back to matching by id from currentTrack. 29 + const resolvedIdx = 30 + currentIdx ?? queue.findIndex((t) => t.id === currentTrack?.id); 31 + const safeIdx = resolvedIdx >= 0 ? resolvedIdx : 0; 32 + 33 + const playingNext = queue.slice(safeIdx + 1); 34 + const history = queue.slice(0, safeIdx); 35 + const list = tab === "next" ? playingNext : history; 36 + const offset = tab === "next" ? safeIdx + 1 : 0; 14 37 15 38 return ( 16 39 <SafeAreaView className="flex-1 bg-bg"> ··· 18 41 <Pressable hitSlop={8} onPress={() => router.back()}> 19 42 <Ionicons name="chevron-down" size={26} color={Colors.textPrimary} /> 20 43 </Pressable> 21 - <Text className="text-text-primary text-base font-bold font-sans"> 44 + <Text className="text-text-primary text-base font-display"> 22 45 Queue 23 46 </Text> 24 47 <Text className="text-text-secondary text-xs font-sans"> 25 - {currentIdx + 1} / {queue.length} 48 + {safeIdx + 1} / {queue.length} 26 49 </Text> 27 50 </View> 28 51 29 - <FlatList 30 - data={queue} 31 - keyExtractor={(t, i) => `${t.id}-${i}`} 32 - renderItem={({ item, index }) => { 33 - const isCurrent = index === currentIdx; 52 + {/* Now playing strip */} 53 + {currentTrack ? ( 54 + <View className="px-4 pt-3 pb-2 border-b border-divider"> 55 + <Text className="text-text-secondary text-[11px] font-bold uppercase tracking-widest mb-2 font-sans"> 56 + Now playing 57 + </Text> 58 + <View className="flex-row items-center gap-3"> 59 + {currentTrack.artwork ? ( 60 + <Image 61 + source={currentTrack.artwork} 62 + className="w-12 h-12 rounded" 63 + contentFit="cover" 64 + /> 65 + ) : ( 66 + <View className="w-12 h-12 rounded bg-bg-card items-center justify-center"> 67 + <Ionicons name="musical-note" size={18} color={Colors.textMuted} /> 68 + </View> 69 + )} 70 + <View className="flex-1"> 71 + <Text 72 + numberOfLines={1} 73 + className="text-accent text-sm font-display" 74 + > 75 + {currentTrack.title} 76 + </Text> 77 + <Text 78 + numberOfLines={1} 79 + className="text-text-secondary text-xs mt-0.5 font-sans" 80 + > 81 + {currentTrack.artist} 82 + </Text> 83 + </View> 84 + <EqualizerBars size={16} playing={isPlaying} /> 85 + </View> 86 + </View> 87 + ) : null} 88 + 89 + {/* Tabs */} 90 + <View className="flex-row px-4 pt-3 gap-2"> 91 + {( 92 + [ 93 + { id: "next" as const, label: "Playing Next", count: playingNext.length }, 94 + { id: "history" as const, label: "History", count: history.length }, 95 + ] 96 + ).map((t) => { 97 + const active = t.id === tab; 34 98 return ( 35 99 <Pressable 36 - onPress={() => jumpTo(index)} 37 - className={`flex-row items-center gap-3 px-4 py-2.5 ${isCurrent ? "bg-accent-soft border-l-[3px] border-accent" : "active:bg-bg-hover"}`} 100 + key={t.id} 101 + onPress={() => setTab(t.id)} 102 + className={`h-8 px-3.5 rounded-full items-center justify-center flex-row gap-1.5 ${active ? "bg-accent" : "bg-bg-card"}`} 38 103 > 39 - <View className="w-7 items-center"> 40 - {isCurrent ? ( 41 - <Ionicons 42 - name={isPlaying ? "musical-notes" : "pause"} 43 - size={16} 44 - color={Colors.accent} 45 - /> 46 - ) : ( 47 - <Text className="text-text-muted text-xs font-mono"> 48 - {index + 1} 49 - </Text> 50 - )} 51 - </View> 52 - {item.artwork ? ( 53 - <Image 54 - source={item.artwork} 55 - className="w-11 h-11 rounded" 56 - contentFit="cover" 57 - /> 58 - ) : null} 59 - <View className="flex-1"> 60 - <Text 61 - numberOfLines={1} 62 - className={`text-text-primary text-sm font-sans ${isCurrent ? "font-bold" : "font-medium"}`} 63 - > 64 - {item.title} 65 - </Text> 66 - <Text 67 - numberOfLines={1} 68 - className="text-text-secondary text-xs font-sans" 69 - > 70 - {item.artist} 71 - </Text> 72 - </View> 73 - <Text className="text-text-muted text-xs font-mono"> 74 - {formatDuration(item.duration)} 104 + <Text 105 + className={`text-text-primary text-[13px] font-sans ${active ? "font-bold" : "font-medium"}`} 106 + > 107 + {t.label} 75 108 </Text> 76 - <TrackMenuButton track={item} /> 77 - <Pressable 78 - hitSlop={8} 79 - onPress={() => removeFromQueue(index)} 80 - className="p-1" 109 + <Text 110 + className={`text-[11px] font-mono ${active ? "text-white/80" : "text-text-muted"}`} 81 111 > 82 - <Ionicons name="close" size={18} color={Colors.textMuted} /> 83 - </Pressable> 112 + {t.count} 113 + </Text> 84 114 </Pressable> 85 115 ); 86 - }} 87 - /> 116 + })} 117 + </View> 118 + 119 + {list.length === 0 ? ( 120 + <View className="px-4 pt-10 items-center"> 121 + <Ionicons 122 + name={tab === "next" ? "play-skip-forward-outline" : "time-outline"} 123 + size={36} 124 + color={Colors.textMuted} 125 + /> 126 + <Text className="text-text-secondary text-sm mt-3 font-sans text-center"> 127 + {tab === "next" 128 + ? "Nothing queued after the current track yet." 129 + : "No tracks have played in this session."} 130 + </Text> 131 + </View> 132 + ) : ( 133 + <FlatList 134 + data={list} 135 + keyExtractor={(t, i) => `${t.id}-${offset + i}`} 136 + contentContainerStyle={{ paddingTop: 8, paddingBottom: bottomPad }} 137 + renderItem={({ item, index }) => { 138 + const queueIdx = offset + index; 139 + return ( 140 + <QueueRow 141 + track={item} 142 + queueIdx={queueIdx} 143 + onPlay={() => jumpTo(queueIdx)} 144 + onRemove={() => removeFromQueue(queueIdx)} 145 + /> 146 + ); 147 + }} 148 + /> 149 + )} 150 + {currentTrack ? ( 151 + <View 152 + pointerEvents="box-none" 153 + className="absolute left-2 right-2" 154 + style={{ bottom: floatingBottom }} 155 + > 156 + <View 157 + className="bg-bg-dock rounded-xl overflow-hidden" 158 + style={{ 159 + shadowColor: "#000", 160 + shadowOpacity: 0.5, 161 + shadowRadius: 16, 162 + shadowOffset: { width: 0, height: -4 }, 163 + }} 164 + > 165 + <MiniPlayer /> 166 + </View> 167 + </View> 168 + ) : null} 88 169 </SafeAreaView> 89 170 ); 90 171 } 172 + 173 + function QueueRow({ 174 + track, 175 + queueIdx, 176 + onPlay, 177 + onRemove, 178 + }: { 179 + track: Track; 180 + queueIdx: number; 181 + onPlay: () => void; 182 + onRemove: () => void; 183 + }) { 184 + return ( 185 + <Pressable 186 + onPress={onPlay} 187 + className="flex-row items-center gap-3 px-4 py-2.5 active:bg-bg-hover" 188 + > 189 + <Text className="w-7 text-center text-text-muted text-xs font-mono"> 190 + {queueIdx + 1} 191 + </Text> 192 + {track.artwork ? ( 193 + <Image 194 + source={track.artwork} 195 + className="w-11 h-11 rounded" 196 + contentFit="cover" 197 + /> 198 + ) : ( 199 + <View className="w-11 h-11 rounded bg-bg-card items-center justify-center"> 200 + <Ionicons name="musical-note" size={16} color={Colors.textMuted} /> 201 + </View> 202 + )} 203 + <View className="flex-1"> 204 + <Text 205 + numberOfLines={1} 206 + className="text-text-primary text-sm font-medium font-sans" 207 + > 208 + {track.title} 209 + </Text> 210 + <Text 211 + numberOfLines={1} 212 + className="text-text-secondary text-xs font-sans" 213 + > 214 + {track.artist} 215 + </Text> 216 + </View> 217 + <Text className="text-text-muted text-xs font-mono"> 218 + {formatDuration(track.duration)} 219 + </Text> 220 + <TrackMenuButton track={track} /> 221 + <Pressable hitSlop={8} onPress={onRemove} className="p-1"> 222 + <Ionicons name="close" size={18} color={Colors.textMuted} /> 223 + </Pressable> 224 + </Pressable> 225 + ); 226 + }
+51 -73
expo/app/settings.tsx
··· 4 4 import { Pressable, ScrollView, Switch, Text, View } from "react-native"; 5 5 import { SafeAreaView } from "react-native-safe-area-context"; 6 6 7 + import { 8 + DevicePickerSheet, 9 + useCurrentDeviceLabel, 10 + } from "@/components/device-picker"; 7 11 import { Colors } from "@/constants/theme"; 12 + import { useSelectedServer } from "@/lib/server-store"; 13 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 8 14 9 15 type Section = { 10 16 title: string; ··· 30 36 export default function SettingsScreen() { 31 37 const [crossfade, setCrossfade] = useState(true); 32 38 const [normalize, setNormalize] = useState(true); 33 - const [downloadOnWifi, setDownloadOnWifi] = useState(true); 34 - const [hapticFeedback, setHapticFeedback] = useState(true); 39 + const selectedServer = useSelectedServer(); 40 + const serverLabel = selectedServer 41 + ? `${selectedServer.label} (${selectedServer.host}:${selectedServer.grpcPort})` 42 + : "Not connected"; 43 + const [devicePickerOpen, setDevicePickerOpen] = useState(false); 44 + const currentDevice = useCurrentDeviceLabel(); 45 + const bottomPad = useBottomSpacing(24); 35 46 36 47 const sections: Section[] = [ 37 48 { 38 - title: "Account", 39 - rows: [ 40 - { 41 - kind: "link", 42 - label: "Profile", 43 - icon: "person-outline", 44 - value: "tsiry.sndr@gmail.com", 45 - }, 46 - { 47 - kind: "link", 48 - label: "Subscription", 49 - icon: "card-outline", 50 - value: "Free", 51 - }, 52 - { kind: "link", label: "Sign out", icon: "log-out-outline" }, 53 - ], 54 - }, 55 - { 56 49 title: "Playback", 57 50 rows: [ 58 51 { ··· 68 61 icon: "stats-chart-outline", 69 62 value: normalize, 70 63 onChange: setNormalize, 71 - }, 72 - { 73 - kind: "link", 74 - label: "Audio quality", 75 - icon: "musical-notes-outline", 76 - value: "High", 77 64 }, 78 65 { kind: "link", label: "Equalizer", icon: "options-outline" }, 79 66 ], 80 67 }, 81 68 { 82 - title: "Storage", 69 + title: "Devices", 83 70 rows: [ 84 71 { 85 - kind: "switch", 86 - label: "Download over Wi-Fi only", 87 - icon: "wifi-outline", 88 - value: downloadOnWifi, 89 - onChange: setDownloadOnWifi, 90 - }, 91 - { 92 72 kind: "link", 93 - label: "Download quality", 94 - icon: "cloud-download-outline", 95 - value: "Very High", 73 + label: "Bluetooth", 74 + icon: "bluetooth-outline", 75 + onPress: () => router.push("/settings/bluetooth"), 96 76 }, 97 77 { 98 78 kind: "link", 99 - label: "Storage location", 100 - icon: "folder-outline", 101 - value: "Internal", 79 + label: "AirPlay & Cast", 80 + icon: "tv-outline", 81 + value: currentDevice.name, 82 + onPress: () => setDevicePickerOpen(true), 102 83 }, 103 - ], 104 - }, 105 - { 106 - title: "Devices", 107 - rows: [ 108 - { kind: "link", label: "Connect a device", icon: "bluetooth-outline" }, 109 - { kind: "link", label: "AirPlay & Cast", icon: "tv-outline" }, 110 84 { 111 85 kind: "link", 112 86 label: "Rockbox server", 113 87 icon: "server-outline", 114 - value: "localhost:6061", 88 + value: serverLabel, 89 + onPress: () => router.push("/settings/server"), 115 90 }, 116 91 ], 117 92 }, 118 93 { 119 94 title: "App", 120 95 rows: [ 121 - { 122 - kind: "switch", 123 - label: "Haptic feedback", 124 - icon: "phone-portrait-outline", 125 - value: hapticFeedback, 126 - onChange: setHapticFeedback, 127 - }, 128 - { 129 - kind: "link", 130 - label: "Language", 131 - icon: "language-outline", 132 - value: "English", 133 - }, 134 96 { 135 97 kind: "link", 136 98 label: "About", ··· 153 115 color={Colors.textPrimary} 154 116 /> 155 117 </Pressable> 156 - <Text className="text-text-primary text-[22px] font-extrabold font-sans"> 118 + <Text className="text-text-primary text-[22px] font-display-extra"> 157 119 Settings 158 120 </Text> 159 121 </View> 160 122 161 123 <ScrollView 162 - contentContainerStyle={{ paddingBottom: 32 }} 124 + contentContainerStyle={{ paddingBottom: bottomPad }} 163 125 showsVerticalScrollIndicator={false} 164 126 > 165 127 {sections.map((section) => ( ··· 181 143 ))} 182 144 </ScrollView> 183 145 </SafeAreaView> 146 + <DevicePickerSheet 147 + visible={devicePickerOpen} 148 + onClose={() => setDevicePickerOpen(false)} 149 + /> 184 150 </> 185 151 ); 186 152 } ··· 189 155 const content = ( 190 156 <View className="flex-row items-center px-3.5 h-[52px] gap-3.5"> 191 157 <Ionicons name={row.icon} size={20} color={Colors.textSecondary} /> 192 - <Text className="flex-1 text-text-primary text-[15px] font-sans"> 158 + <Text 159 + numberOfLines={1} 160 + className="text-text-primary text-[15px] font-sans flex-shrink-0" 161 + > 193 162 {row.label} 194 163 </Text> 195 164 {row.kind === "switch" ? ( 196 - <Switch 197 - value={row.value} 198 - onValueChange={row.onChange} 199 - trackColor={{ false: Colors.bgHover, true: Colors.accent }} 200 - thumbColor="#FFFFFF" 201 - ios_backgroundColor={Colors.bgHover} 202 - /> 165 + <> 166 + <View className="flex-1" /> 167 + <Switch 168 + value={row.value} 169 + onValueChange={row.onChange} 170 + trackColor={{ false: Colors.bgHover, true: Colors.accent }} 171 + thumbColor="#FFFFFF" 172 + ios_backgroundColor={Colors.bgHover} 173 + /> 174 + </> 203 175 ) : ( 204 176 <> 205 177 {row.value ? ( 206 - <Text className="text-text-secondary text-[13px] font-sans"> 178 + <Text 179 + numberOfLines={1} 180 + ellipsizeMode="tail" 181 + className="flex-1 min-w-0 text-right text-text-secondary text-[13px] font-sans" 182 + > 207 183 {row.value} 208 184 </Text> 209 - ) : null} 185 + ) : ( 186 + <View className="flex-1" /> 187 + )} 210 188 <Ionicons 211 189 name="chevron-forward" 212 190 size={18}
+210
expo/app/settings/bluetooth.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { router } from "expo-router"; 3 + import { useCallback, useEffect, useState } from "react"; 4 + import { Pressable, RefreshControl, ScrollView, Text, View } from "react-native"; 5 + import { SafeAreaView } from "react-native-safe-area-context"; 6 + 7 + import { Colors } from "@/constants/theme"; 8 + import { useIsConnected } from "@/lib/connection"; 9 + import { 10 + useBluetoothAvailable, 11 + useBluetoothDevices, 12 + useConnectBluetooth, 13 + useDisconnectBluetooth, 14 + useScanBluetooth, 15 + } from "@/lib/queries"; 16 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 17 + 18 + type ProtoBluetoothDevice = { 19 + address?: string; 20 + name?: string; 21 + paired?: boolean; 22 + trusted?: boolean; 23 + connected?: boolean; 24 + rssi?: number | null; 25 + }; 26 + 27 + export default function BluetoothScreen() { 28 + const isConnected = useIsConnected(); 29 + const available = useBluetoothAvailable({ enabled: isConnected }); 30 + const list = useBluetoothDevices<{ devices?: ProtoBluetoothDevice[] }>({ 31 + enabled: isConnected && available.data === true, 32 + }); 33 + const connectMut = useConnectBluetooth(); 34 + const disconnectMut = useDisconnectBluetooth(); 35 + const scanMut = useScanBluetooth(); 36 + const [scanned, setScanned] = useState(false); 37 + const [refreshing, setRefreshing] = useState(false); 38 + const bottomPad = useBottomSpacing(24); 39 + 40 + const onRefresh = useCallback(async () => { 41 + if (!isConnected || available.data !== true) return; 42 + setRefreshing(true); 43 + try { 44 + await scanMut.mutateAsync(); 45 + await list.refetch(); 46 + } catch { 47 + // swallow — UI stays on whatever the last good state was 48 + } finally { 49 + setRefreshing(false); 50 + } 51 + }, [isConnected, available.data, scanMut, list]); 52 + 53 + const devices = list.data?.devices ?? []; 54 + 55 + // Trigger one scan on mount once we know Bluetooth is available — rockboxd 56 + // doesn't continuously scan, so without this `getDevices` returns nothing. 57 + useEffect(() => { 58 + if (!isConnected || available.data !== true || scanned) return; 59 + setScanned(true); 60 + scanMut.mutate(); 61 + }, [isConnected, available.data, scanned, scanMut]); 62 + 63 + return ( 64 + <SafeAreaView className="flex-1 bg-bg" edges={["top"]}> 65 + <View className="flex-row items-center px-4 py-3 gap-3 border-b border-divider"> 66 + <Pressable hitSlop={10} onPress={() => router.back()}> 67 + <Ionicons name="chevron-back" size={26} color={Colors.textPrimary} /> 68 + </Pressable> 69 + <Text className="flex-1 text-text-primary text-[18px] font-display-extra"> 70 + Bluetooth 71 + </Text> 72 + {available.data === true && isConnected ? ( 73 + <Pressable 74 + hitSlop={10} 75 + onPress={() => scanMut.mutate()} 76 + disabled={scanMut.isPending} 77 + className={`px-3 h-8 rounded-full items-center justify-center flex-row gap-1.5 ${scanMut.isPending ? "bg-bg-card" : "bg-bg-elevated active:bg-bg-hover"}`} 78 + > 79 + <Ionicons 80 + name={scanMut.isPending ? "sync" : "search"} 81 + size={14} 82 + color={Colors.textPrimary} 83 + /> 84 + <Text className="text-text-primary text-xs font-display"> 85 + {scanMut.isPending ? "Scanning…" : "Scan"} 86 + </Text> 87 + </Pressable> 88 + ) : null} 89 + </View> 90 + 91 + <ScrollView 92 + contentContainerStyle={{ paddingBottom: bottomPad }} 93 + refreshControl={ 94 + isConnected && available.data === true ? ( 95 + <RefreshControl 96 + refreshing={refreshing || scanMut.isPending} 97 + onRefresh={onRefresh} 98 + tintColor={Colors.accent} 99 + colors={[Colors.accent]} 100 + progressBackgroundColor={Colors.bgCard} 101 + /> 102 + ) : undefined 103 + } 104 + > 105 + {!isConnected ? ( 106 + <Banner 107 + icon="server-outline" 108 + text="Connect to a rockbox server first to manage Bluetooth devices." 109 + /> 110 + ) : available.isLoading ? ( 111 + <Banner icon="bluetooth-outline" text="Checking Bluetooth…" /> 112 + ) : available.data === false ? ( 113 + <Banner 114 + icon="alert-circle-outline" 115 + text="Bluetooth isn't available on this server." 116 + /> 117 + ) : null} 118 + 119 + {isConnected && available.data === true ? ( 120 + <View className="mt-4 px-4"> 121 + <Text className="text-text-secondary text-xs font-bold uppercase tracking-widest mb-2 font-sans"> 122 + Devices 123 + </Text> 124 + <View className="bg-bg-card rounded-xl overflow-hidden"> 125 + {list.isLoading && devices.length === 0 ? ( 126 + <Banner icon="bluetooth-outline" text="Scanning…" /> 127 + ) : devices.length === 0 ? ( 128 + <Banner 129 + icon="bluetooth-outline" 130 + text="No Bluetooth devices found." 131 + /> 132 + ) : ( 133 + devices.map((d, idx) => { 134 + const isOn = d.connected === true; 135 + const onToggle = () => { 136 + if (!d.address) return; 137 + if (isOn) disconnectMut.mutate(d.address); 138 + else connectMut.mutate(d.address); 139 + }; 140 + return ( 141 + <View key={d.address ?? idx}> 142 + {idx > 0 ? ( 143 + <View className="h-px bg-divider ml-12" /> 144 + ) : null} 145 + <Pressable 146 + onPress={onToggle} 147 + android_ripple={{ color: Colors.bgHover }} 148 + className="flex-row items-center px-4 py-3.5 gap-3 active:bg-bg-hover" 149 + > 150 + <View 151 + className={`w-9 h-9 rounded-full items-center justify-center ${isOn ? "bg-accent" : "bg-bg-elevated"}`} 152 + > 153 + <Ionicons 154 + name="bluetooth" 155 + size={18} 156 + color={isOn ? "#FFFFFF" : Colors.textPrimary} 157 + /> 158 + </View> 159 + <View className="flex-1 min-w-0"> 160 + <Text 161 + numberOfLines={1} 162 + className="text-text-primary text-[14px] font-semibold font-sans" 163 + > 164 + {d.name?.trim() || "(unnamed device)"} 165 + </Text> 166 + <Text 167 + numberOfLines={1} 168 + className="text-text-secondary text-[12px] mt-0.5 font-mono" 169 + > 170 + {d.address ?? "—"} 171 + {d.paired ? " • paired" : ""} 172 + {typeof d.rssi === "number" 173 + ? ` • ${d.rssi} dBm` 174 + : ""} 175 + </Text> 176 + </View> 177 + <Text 178 + className={`text-xs font-bold uppercase font-sans ${isOn ? "text-accent" : "text-text-muted"}`} 179 + > 180 + {isOn ? "Connected" : "Connect"} 181 + </Text> 182 + </Pressable> 183 + </View> 184 + ); 185 + }) 186 + )} 187 + </View> 188 + </View> 189 + ) : null} 190 + </ScrollView> 191 + </SafeAreaView> 192 + ); 193 + } 194 + 195 + function Banner({ 196 + icon, 197 + text, 198 + }: { 199 + icon: React.ComponentProps<typeof Ionicons>["name"]; 200 + text: string; 201 + }) { 202 + return ( 203 + <View className="mt-4 mx-4 px-4 py-4 bg-bg-card rounded-xl flex-row items-center gap-3"> 204 + <Ionicons name={icon} size={20} color={Colors.textSecondary} /> 205 + <Text className="flex-1 text-text-secondary text-[13px] font-sans"> 206 + {text} 207 + </Text> 208 + </View> 209 + ); 210 + }
+278
expo/app/settings/server.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { router } from "expo-router"; 3 + import { useCallback, useEffect, useMemo, useState } from "react"; 4 + import { 5 + KeyboardAvoidingView, 6 + Platform, 7 + Pressable, 8 + RefreshControl, 9 + ScrollView, 10 + Text, 11 + TextInput, 12 + View, 13 + } from "react-native"; 14 + import { SafeAreaView } from "react-native-safe-area-context"; 15 + 16 + import { Colors } from "@/constants/theme"; 17 + import { useDiscoveredServers } from "@/lib/queries"; 18 + import { useBottomSpacing } from "@/lib/use-bottom-spacing"; 19 + import { RockboxClient, type DiscoveredService } from "@/lib/rockbox-client"; 20 + import { restartDiscovery } from "@/lib/rockbox-streams"; 21 + import { 22 + manualServer, 23 + serverFromDiscovery, 24 + setSelectedServer, 25 + useSelectedServer, 26 + } from "@/lib/server-store"; 27 + 28 + export default function ServerPickerScreen() { 29 + const selected = useSelectedServer(); 30 + const { data: discovered = [] } = useDiscoveredServers(); 31 + const [host, setHost] = useState(""); 32 + const [port, setPort] = useState("6061"); 33 + const [scanning, setScanning] = useState(false); 34 + const [refreshing, setRefreshing] = useState(false); 35 + 36 + // Visible only when the native module is up — otherwise the discovery 37 + // stream and gRPC are no-ops and we just show the manual entry. 38 + const isAvailable = RockboxClient.isAvailable; 39 + const bottomPad = useBottomSpacing(24); 40 + 41 + // Show every host the discovery stream has resolved, regardless of which 42 + // flavor (grpc / graphql / http / mpd) advertised first. The mdns-sd 43 + // browser sometimes loses a flavor's `ServiceResolved` to a transient 44 + // network blip; if we filtered to grpc-only the user would just see an 45 + // empty list. Dedupe by host:port so we still get one row per machine. 46 + const grpcServices = useMemo(() => dedupeByHost(discovered), [discovered]); 47 + 48 + // Soft "scanning" indicator — true for ~3 s after entering the screen, then 49 + // hides so users see a stable list. Discovery itself runs continuously. 50 + useEffect(() => { 51 + if (!isAvailable) return; 52 + setScanning(true); 53 + const t = setTimeout(() => setScanning(false), 3000); 54 + return () => clearTimeout(t); 55 + }, [isAvailable]); 56 + 57 + const onPickDiscovered = async (svc: DiscoveredService) => { 58 + const sel = serverFromDiscovery(svc); 59 + if (!sel) return; 60 + await setSelectedServer(sel); 61 + }; 62 + 63 + const onConnectManual = async () => { 64 + const trimmed = host.trim(); 65 + if (!trimmed) return; 66 + const p = parseInt(port, 10); 67 + await setSelectedServer(manualServer(trimmed, isFinite(p) ? p : 6061)); 68 + setHost(""); 69 + }; 70 + 71 + const onClear = async () => { 72 + await setSelectedServer(null); 73 + }; 74 + 75 + const onRefresh = useCallback(async () => { 76 + setRefreshing(true); 77 + setScanning(true); 78 + // Tear down the active mdns-sd browse and start a fresh one — necessary 79 + // because mdns-sd only emits `ServiceResolved` the first time a service 80 + // is seen, so just clearing the cache would leave us with no events. 81 + restartDiscovery(); 82 + // Keep the pull-to-refresh spinner alive briefly so a fast scan still 83 + // feels like a refresh, and the "scanning…" hint lingers longer. 84 + setTimeout(() => setRefreshing(false), 1200); 85 + setTimeout(() => setScanning(false), 2500); 86 + }, []); 87 + 88 + return ( 89 + <SafeAreaView className="flex-1 bg-bg" edges={["top"]}> 90 + <View className="flex-row items-center px-4 py-3 gap-3 border-b border-divider"> 91 + <Pressable hitSlop={10} onPress={() => router.back()}> 92 + <Ionicons name="chevron-back" size={26} color={Colors.textPrimary} /> 93 + </Pressable> 94 + <Text className="text-text-primary text-[18px] font-display-extra"> 95 + Rockbox server 96 + </Text> 97 + </View> 98 + 99 + <KeyboardAvoidingView 100 + className="flex-1" 101 + behavior={Platform.OS === "ios" ? "padding" : undefined} 102 + > 103 + <ScrollView 104 + contentContainerStyle={{ paddingBottom: bottomPad }} 105 + keyboardShouldPersistTaps="handled" 106 + refreshControl={ 107 + isAvailable ? ( 108 + <RefreshControl 109 + refreshing={refreshing} 110 + onRefresh={onRefresh} 111 + tintColor={Colors.accent} 112 + colors={[Colors.accent]} 113 + progressBackgroundColor={Colors.bgCard} 114 + /> 115 + ) : undefined 116 + } 117 + > 118 + {/* Current selection */} 119 + <View className="mt-4 px-4"> 120 + <Text className="text-text-secondary text-xs font-bold uppercase tracking-widest mb-2 font-sans"> 121 + Connected to 122 + </Text> 123 + <View className="bg-bg-card rounded-xl px-4 py-4"> 124 + {selected ? ( 125 + <View> 126 + <Text className="text-text-primary text-[15px] font-display"> 127 + {selected.label} 128 + </Text> 129 + <Text className="text-text-secondary text-[13px] mt-1 font-mono"> 130 + {selected.host}:{selected.grpcPort} 131 + </Text> 132 + <Pressable 133 + hitSlop={6} 134 + onPress={onClear} 135 + className="mt-3 self-start active:opacity-70" 136 + > 137 + <Text className="text-danger text-xs font-semibold font-sans"> 138 + Disconnect 139 + </Text> 140 + </Pressable> 141 + </View> 142 + ) : ( 143 + <Text className="text-text-secondary text-[13px] font-sans"> 144 + No server selected. 145 + </Text> 146 + )} 147 + </View> 148 + </View> 149 + 150 + {/* Discovered servers */} 151 + <View className="mt-6 px-4"> 152 + <View className="flex-row items-center justify-between mb-2"> 153 + <Text className="text-text-secondary text-xs font-bold uppercase tracking-widest font-sans"> 154 + Discovered on the network 155 + </Text> 156 + {scanning && isAvailable ? ( 157 + <Text className="text-text-muted text-[11px] font-sans"> 158 + scanning… 159 + </Text> 160 + ) : null} 161 + </View> 162 + 163 + <View className="bg-bg-card rounded-xl overflow-hidden"> 164 + {grpcServices.length === 0 ? ( 165 + <View className="px-4 py-5"> 166 + <Text className="text-text-secondary text-[13px] font-sans"> 167 + {isAvailable 168 + ? "No rockbox servers found yet. Make sure the daemon is running on the same network." 169 + : "Native module not loaded — discovery is unavailable on this build."} 170 + </Text> 171 + </View> 172 + ) : ( 173 + grpcServices.map((svc, idx) => { 174 + const sel = serverFromDiscovery(svc); 175 + const isCurrent = 176 + selected !== null && 177 + sel !== null && 178 + selected.host === sel.host && 179 + selected.grpcPort === sel.grpcPort; 180 + return ( 181 + <View key={svc.fullname}> 182 + {idx > 0 ? ( 183 + <View className="h-px bg-divider ml-12" /> 184 + ) : null} 185 + <Pressable 186 + onPress={() => onPickDiscovered(svc)} 187 + android_ripple={{ color: Colors.bgHover }} 188 + className="flex-row items-center px-4 py-3.5 gap-3 active:bg-bg-hover" 189 + > 190 + <View className="w-9 h-9 rounded-full bg-bg-elevated items-center justify-center"> 191 + <Ionicons 192 + name="server-outline" 193 + size={18} 194 + color={ 195 + isCurrent ? Colors.accent : Colors.textPrimary 196 + } 197 + /> 198 + </View> 199 + <View className="flex-1"> 200 + <Text className="text-text-primary text-[14px] font-semibold font-sans"> 201 + {sel?.label ?? svc.hostname} 202 + </Text> 203 + <Text className="text-text-secondary text-[12px] mt-0.5 font-mono"> 204 + {sel?.host}:{sel?.grpcPort} 205 + </Text> 206 + </View> 207 + {isCurrent ? ( 208 + <Ionicons 209 + name="checkmark" 210 + size={20} 211 + color={Colors.accent} 212 + /> 213 + ) : null} 214 + </Pressable> 215 + </View> 216 + ); 217 + }) 218 + )} 219 + </View> 220 + </View> 221 + 222 + {/* Manual entry */} 223 + <View className="mt-6 px-4"> 224 + <Text className="text-text-secondary text-xs font-bold uppercase tracking-widest mb-2 font-sans"> 225 + Connect manually 226 + </Text> 227 + <View className="bg-bg-card rounded-xl px-4 py-3 gap-2.5"> 228 + <TextInput 229 + value={host} 230 + onChangeText={setHost} 231 + placeholder="Hostname or IP" 232 + placeholderTextColor={Colors.textMuted} 233 + autoCapitalize="none" 234 + autoCorrect={false} 235 + className="text-text-primary text-[14px] font-mono" 236 + /> 237 + <View className="h-px bg-divider" /> 238 + <View className="flex-row items-center gap-2"> 239 + <Text className="text-text-secondary text-[13px] font-sans"> 240 + Port 241 + </Text> 242 + <TextInput 243 + value={port} 244 + onChangeText={setPort} 245 + keyboardType="number-pad" 246 + className="flex-1 text-text-primary text-[14px] font-mono" 247 + /> 248 + </View> 249 + <Pressable 250 + disabled={host.trim().length === 0} 251 + onPress={onConnectManual} 252 + className={`mt-2 self-start px-4 py-2 rounded-full ${host.trim().length === 0 ? "bg-bg-hover" : "bg-accent active:opacity-80"}`} 253 + > 254 + <Text className="text-white text-[13px] font-display"> 255 + Connect 256 + </Text> 257 + </Pressable> 258 + </View> 259 + </View> 260 + </ScrollView> 261 + </KeyboardAvoidingView> 262 + </SafeAreaView> 263 + ); 264 + } 265 + 266 + function dedupeByHost(list: DiscoveredService[]): DiscoveredService[] { 267 + const seen = new Set<string>(); 268 + const out: DiscoveredService[] = []; 269 + for (const s of list) { 270 + const sel = serverFromDiscovery(s); 271 + if (!sel) continue; 272 + const key = `${sel.host}:${sel.grpcPort}`; 273 + if (seen.has(key)) continue; 274 + seen.add(key); 275 + out.push(s); 276 + } 277 + return out; 278 + }
expo/assets/fonts/RockfordSans-Bold.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-BoldItalic.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-ExtraBold.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-Light.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-Medium.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-Regular.otf

This is a binary file and will not be displayed.

expo/assets/fonts/RockfordSans-RegularItalic.otf

This is a binary file and will not be displayed.

expo/assets/images/android-icon-foreground.png

This is a binary file and will not be displayed.

expo/assets/images/android-icon-monochrome.png

This is a binary file and will not be displayed.

expo/assets/images/favicon.png

This is a binary file and will not be displayed.

expo/assets/images/icon.png

This is a binary file and will not be displayed.

expo/assets/images/splash-icon.png

This is a binary file and will not be displayed.

+47
expo/bun.lock
··· 6 6 "name": "rockbox", 7 7 "dependencies": { 8 8 "@expo/vector-icons": "^15.0.3", 9 + "@react-native-async-storage/async-storage": "2.2.0", 9 10 "@react-navigation/bottom-tabs": "^7.4.0", 10 11 "@react-navigation/elements": "^2.6.3", 11 12 "@react-navigation/native": "^7.1.8", 13 + "@tabler/icons-react-native": "^3.41.1", 14 + "@tanstack/react-query": "^5.100.8", 12 15 "expo": "~54.0.33", 13 16 "expo-blur": "^55.0.14", 14 17 "expo-constants": "~18.0.13", ··· 23 26 "expo-symbols": "~1.0.8", 24 27 "expo-system-ui": "~6.0.9", 25 28 "expo-web-browser": "~15.0.10", 29 + "jotai": "^2.19.1", 26 30 "nativewind": "^4.2.3", 27 31 "react": "19.1.0", 28 32 "react-dom": "19.1.0", ··· 31 35 "react-native-reanimated": "~4.1.1", 32 36 "react-native-safe-area-context": "~5.6.0", 33 37 "react-native-screens": "~4.16.0", 38 + "react-native-svg": "15.12.1", 34 39 "react-native-web": "~0.21.0", 35 40 "react-native-worklets": "0.5.1", 36 41 "rockbox-rpc": "file:./modules/rockbox-rpc", ··· 411 416 412 417 "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], 413 418 419 + "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="], 420 + 414 421 "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], 415 422 416 423 "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="], ··· 453 460 454 461 "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], 455 462 463 + "@tabler/icons": ["@tabler/icons@3.41.1", "", {}, "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q=="], 464 + 465 + "@tabler/icons-react-native": ["@tabler/icons-react-native@3.41.1", "", { "dependencies": { "@tabler/icons": "3.41.1" }, "peerDependencies": { "react": ">= 16.5.1", "react-native-svg": ">= 13.0.0" } }, "sha512-9q+fqREGlOkyWh6AJN2K8z9k0fi1eoUV6bMdbP4UodXOKlfb1Dkn/hkpJfsBgbLMTIaPcHj/5f1U0RX0OR9QOA=="], 466 + 467 + "@tanstack/query-core": ["@tanstack/query-core@5.100.8", "", {}, "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg=="], 468 + 469 + "@tanstack/react-query": ["@tanstack/react-query@5.100.8", "", { "dependencies": { "@tanstack/query-core": "5.100.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA=="], 470 + 456 471 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 457 472 458 473 "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], ··· 649 664 650 665 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], 651 666 667 + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], 668 + 652 669 "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], 653 670 654 671 "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], ··· 733 750 734 751 "css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="], 735 752 753 + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], 754 + 755 + "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], 756 + 757 + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], 758 + 736 759 "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], 737 760 738 761 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], ··· 775 798 776 799 "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], 777 800 801 + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], 802 + 803 + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], 804 + 805 + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], 806 + 807 + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], 808 + 778 809 "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], 779 810 780 811 "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], ··· 788 819 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 789 820 790 821 "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], 822 + 823 + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], 791 824 792 825 "env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="], 793 826 ··· 1071 1104 1072 1105 "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], 1073 1106 1107 + "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], 1108 + 1074 1109 "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], 1075 1110 1076 1111 "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], ··· 1123 1158 1124 1159 "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], 1125 1160 1161 + "jotai": ["jotai@2.19.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw=="], 1162 + 1126 1163 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 1127 1164 1128 1165 "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], ··· 1201 1238 1202 1239 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 1203 1240 1241 + "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], 1242 + 1204 1243 "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], 1244 + 1245 + "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="], 1205 1246 1206 1247 "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], 1207 1248 ··· 1285 1326 1286 1327 "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], 1287 1328 1329 + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], 1330 + 1288 1331 "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], 1289 1332 1290 1333 "ob1": ["ob1@0.83.7", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg=="], ··· 1432 1475 "react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="], 1433 1476 1434 1477 "react-native-screens": ["react-native-screens@4.16.0", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q=="], 1478 + 1479 + "react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="], 1435 1480 1436 1481 "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], 1437 1482 ··· 1866 1911 "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], 1867 1912 1868 1913 "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1914 + 1915 + "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1869 1916 1870 1917 "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1871 1918
+1 -1
expo/components/action-sheet.tsx
··· 69 69 <View className="flex-1"> 70 70 <Text 71 71 numberOfLines={1} 72 - className="text-text-primary text-[15px] font-bold font-sans" 72 + className="text-text-primary text-[15px] font-display" 73 73 > 74 74 {header.title} 75 75 </Text>
+77 -38
expo/components/card-row.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 1 2 import { Image } from "expo-image"; 3 + import { LinearGradient } from "expo-linear-gradient"; 2 4 import { FlatList, Pressable, Text, View } from "react-native"; 3 5 6 + import { gradientColors } from "@/components/playlist-cover"; 7 + import { Colors } from "@/constants/theme"; 8 + 4 9 export type CardItem = { 5 10 id: string; 6 11 title: string; 7 12 subtitle?: string; 8 13 image: string; 9 14 rounded?: "lg" | "full"; 15 + /** Icon shown in the placeholder when `image` is empty. */ 16 + placeholderIcon?: React.ComponentProps<typeof Ionicons>["name"]; 17 + /** When true and `image` is empty, render a colorful gradient backdrop. */ 18 + colorfulPlaceholder?: boolean; 10 19 }; 11 20 12 21 export function CardRow({ ··· 25 34 keyExtractor={(item) => item.id} 26 35 showsHorizontalScrollIndicator={false} 27 36 contentContainerStyle={{ paddingHorizontal: 16, gap: 14 }} 28 - renderItem={({ item }) => ( 29 - <Pressable 30 - onPress={() => onPress?.(item)} 31 - style={({ pressed }) => ({ 32 - width: size, 33 - opacity: pressed ? 0.85 : 1, 34 - })} 35 - > 36 - <View 37 - className="bg-bg-card overflow-hidden" 38 - style={{ 37 + renderItem={({ item }) => { 38 + const round = item.rounded === "full"; 39 + return ( 40 + <Pressable 41 + onPress={() => onPress?.(item)} 42 + style={({ pressed }) => ({ 39 43 width: size, 40 - height: size, 41 - borderRadius: item.rounded === "full" ? size / 2 : 8, 42 - shadowColor: "#000", 43 - shadowOffset: { width: 0, height: 6 }, 44 - shadowOpacity: 0.4, 45 - shadowRadius: 8, 46 - }} 44 + opacity: pressed ? 0.85 : 1, 45 + })} 46 + className={round ? "items-center" : undefined} 47 47 > 48 - <Image 49 - source={item.image} 50 - className="w-full h-full" 51 - contentFit="cover" 52 - transition={250} 53 - /> 54 - </View> 55 - <Text 56 - numberOfLines={1} 57 - className="text-text-primary text-sm font-semibold mt-2 font-sans" 58 - > 59 - {item.title} 60 - </Text> 61 - {item.subtitle ? ( 48 + <View 49 + className="bg-bg-card overflow-hidden items-center justify-center" 50 + style={{ 51 + width: size, 52 + height: size, 53 + borderRadius: round ? size / 2 : 8, 54 + shadowColor: "#000", 55 + shadowOffset: { width: 0, height: 6 }, 56 + shadowOpacity: 0.4, 57 + shadowRadius: 8, 58 + }} 59 + > 60 + {item.image ? ( 61 + <Image 62 + source={item.image} 63 + className="w-full h-full" 64 + contentFit="cover" 65 + transition={250} 66 + /> 67 + ) : item.colorfulPlaceholder ? ( 68 + <LinearGradient 69 + colors={gradientColors(item.id || item.title)} 70 + start={{ x: 0, y: 0 }} 71 + end={{ x: 1, y: 1 }} 72 + style={{ 73 + width: "100%", 74 + height: "100%", 75 + alignItems: "center", 76 + justifyContent: "center", 77 + }} 78 + > 79 + <Ionicons 80 + name={item.placeholderIcon ?? "musical-notes"} 81 + size={Math.round(size * 0.35)} 82 + color="#FFFFFF" 83 + /> 84 + </LinearGradient> 85 + ) : ( 86 + <Ionicons 87 + name={item.placeholderIcon ?? "musical-notes"} 88 + size={Math.round(size * 0.35)} 89 + color={Colors.textMuted} 90 + /> 91 + )} 92 + </View> 62 93 <Text 63 - numberOfLines={2} 64 - className="text-text-secondary text-xs mt-0.5 font-sans" 94 + numberOfLines={1} 95 + className={`text-text-primary text-sm font-semibold mt-2 font-sans ${round ? "text-center" : ""}`} 65 96 > 66 - {item.subtitle} 97 + {item.title} 67 98 </Text> 68 - ) : null} 69 - </Pressable> 70 - )} 99 + {item.subtitle ? ( 100 + <Text 101 + numberOfLines={2} 102 + className={`text-text-secondary text-xs mt-0.5 font-sans ${round ? "text-center" : ""}`} 103 + > 104 + {item.subtitle} 105 + </Text> 106 + ) : null} 107 + </Pressable> 108 + ); 109 + }} 71 110 /> 72 111 ); 73 112 }
+231
expo/components/device-picker.tsx
··· 1 + import { 2 + IconBluetooth, 3 + IconCast, 4 + IconCheck, 5 + IconDeviceMobile, 6 + IconHeadphones, 7 + IconMusic, 8 + IconRadio, 9 + type Icon, 10 + } from "@tabler/icons-react-native"; 11 + import { Modal, Pressable, ScrollView, Text, View } from "react-native"; 12 + import Svg, { Path, Rect } from "react-native-svg"; 13 + 14 + import { Colors } from "@/constants/theme"; 15 + import { useIsConnected } from "@/lib/connection"; 16 + import { 17 + useConnectDevice, 18 + useDisconnectDevice, 19 + useOutputDevices, 20 + } from "@/lib/queries"; 21 + 22 + export type DeviceItem = { 23 + id?: string; 24 + name?: string; 25 + ip?: string; 26 + port?: number; 27 + service?: string; 28 + app?: string; 29 + is_connected?: boolean; 30 + is_current_device?: boolean; 31 + }; 32 + 33 + export type DeviceIconSpec = 34 + | { kind: "tabler"; component: Icon } 35 + | { kind: "airplay" }; 36 + 37 + const DEVICE_ICON: Record<string, DeviceIconSpec> = { 38 + googlecast: { kind: "tabler", component: IconCast }, 39 + chromecast: { kind: "tabler", component: IconCast }, 40 + airplay: { kind: "airplay" }, 41 + snapcast: { kind: "tabler", component: IconRadio }, 42 + upnp: { kind: "tabler", component: IconMusic }, 43 + bluetooth: { kind: "tabler", component: IconBluetooth }, 44 + builtin: { kind: "tabler", component: IconDeviceMobile }, 45 + }; 46 + 47 + const DEFAULT_ICON: DeviceIconSpec = { 48 + kind: "tabler", 49 + component: IconHeadphones, 50 + }; 51 + 52 + export function iconFor(svc?: string): DeviceIconSpec { 53 + if (!svc) return DEFAULT_ICON; 54 + const key = svc.toLowerCase(); 55 + for (const k of Object.keys(DEVICE_ICON)) { 56 + if (key.includes(k)) return DEVICE_ICON[k]; 57 + } 58 + return DEFAULT_ICON; 59 + } 60 + 61 + /** AirPlay glyph — Tabler doesn't ship one, so we render the canonical 62 + * rectangle-over-triangle SVG inline. */ 63 + function AirplayIcon({ size, color }: { size: number; color: string }) { 64 + return ( 65 + <Svg width={size} height={size} viewBox="0 0 24 24" fill="none"> 66 + <Rect 67 + x={3} 68 + y={4} 69 + width={18} 70 + height={13} 71 + rx={2} 72 + ry={2} 73 + stroke={color} 74 + strokeWidth={2} 75 + strokeLinejoin="round" 76 + /> 77 + <Path 78 + d="M8 21l4-5 4 5z" 79 + fill={color} 80 + stroke={color} 81 + strokeWidth={1.5} 82 + strokeLinejoin="round" 83 + /> 84 + </Svg> 85 + ); 86 + } 87 + 88 + export function DeviceIcon({ 89 + spec, 90 + size, 91 + color, 92 + }: { 93 + spec: DeviceIconSpec; 94 + size: number; 95 + color: string; 96 + }) { 97 + if (spec.kind === "airplay") { 98 + return <AirplayIcon size={size} color={color} />; 99 + } 100 + const Component = spec.component; 101 + return <Component size={size} color={color} strokeWidth={1.75} />; 102 + } 103 + 104 + /** Small label for the player-bar trigger. Shows the active device name. */ 105 + export function useCurrentDeviceLabel(): { 106 + name: string; 107 + icon: DeviceIconSpec; 108 + } { 109 + const isConnected = useIsConnected(); 110 + const { data } = useOutputDevices<DeviceItem[]>({ enabled: isConnected }); 111 + const list: DeviceItem[] = Array.isArray(data) ? data : []; 112 + const current = list.find((d) => d.is_current_device); 113 + return { 114 + name: current?.name ?? "This Phone", 115 + icon: iconFor(current?.service), 116 + }; 117 + } 118 + 119 + export function DevicePickerSheet({ 120 + visible, 121 + onClose, 122 + }: { 123 + visible: boolean; 124 + onClose: () => void; 125 + }) { 126 + const isConnected = useIsConnected(); 127 + const { data, isLoading } = useOutputDevices<DeviceItem[]>({ 128 + enabled: isConnected && visible, 129 + }); 130 + const list: DeviceItem[] = Array.isArray(data) ? data : []; 131 + const connect = useConnectDevice(); 132 + const disconnect = useDisconnectDevice(); 133 + 134 + const onPick = (d: DeviceItem) => { 135 + if (!d.id) return; 136 + if (d.is_current_device || d.is_connected) { 137 + disconnect.mutate(d.id); 138 + } else { 139 + connect.mutate(d.id); 140 + } 141 + onClose(); 142 + }; 143 + 144 + return ( 145 + <Modal 146 + visible={visible} 147 + transparent 148 + animationType="slide" 149 + onRequestClose={onClose} 150 + > 151 + <Pressable onPress={onClose} className="flex-1 bg-black/55"> 152 + <Pressable 153 + onPress={(e) => e.stopPropagation()} 154 + className="mt-auto bg-bg-elevated rounded-t-2xl pt-2 pb-7" 155 + style={{ maxHeight: "70%" }} 156 + > 157 + <View className="self-center w-10 h-1 rounded-sm bg-border my-2" /> 158 + <Text className="text-text-primary text-base font-bold text-center py-2 font-sans"> 159 + Output devices 160 + </Text> 161 + <ScrollView> 162 + {!isConnected ? ( 163 + <Text className="text-text-secondary text-sm text-center py-6 font-sans"> 164 + Connect to a server first. 165 + </Text> 166 + ) : isLoading && list.length === 0 ? ( 167 + <Text className="text-text-secondary text-sm text-center py-6 font-sans"> 168 + Loading devices… 169 + </Text> 170 + ) : list.length === 0 ? ( 171 + <Text className="text-text-secondary text-sm text-center py-6 font-sans"> 172 + No output devices found. 173 + </Text> 174 + ) : ( 175 + list.map((d, idx) => { 176 + const active = d.is_current_device === true; 177 + const subtitle = [ 178 + d.service?.toUpperCase(), 179 + d.ip ? `${d.ip}${d.port ? `:${d.port}` : ""}` : null, 180 + ] 181 + .filter(Boolean) 182 + .join(" • "); 183 + return ( 184 + <Pressable 185 + key={d.id ?? idx} 186 + onPress={() => onPick(d)} 187 + android_ripple={{ color: Colors.bgHover }} 188 + className="flex-row items-center gap-3 px-5 py-3 active:bg-bg-hover" 189 + > 190 + <View 191 + className={`w-10 h-10 rounded-full items-center justify-center ${active ? "bg-accent" : "bg-bg-card"}`} 192 + > 193 + <DeviceIcon 194 + spec={iconFor(d.service)} 195 + size={18} 196 + color={active ? "#FFFFFF" : Colors.textPrimary} 197 + /> 198 + </View> 199 + <View className="flex-1 min-w-0"> 200 + <Text 201 + numberOfLines={1} 202 + className={`text-[14px] font-semibold font-sans ${active ? "text-accent" : "text-text-primary"}`} 203 + > 204 + {d.name?.trim() || "(unnamed)"} 205 + </Text> 206 + {subtitle ? ( 207 + <Text 208 + numberOfLines={1} 209 + className="text-text-secondary text-[12px] mt-0.5 font-mono" 210 + > 211 + {subtitle} 212 + </Text> 213 + ) : null} 214 + </View> 215 + {active ? ( 216 + <IconCheck 217 + size={20} 218 + color={Colors.accent} 219 + strokeWidth={2} 220 + /> 221 + ) : null} 222 + </Pressable> 223 + ); 224 + }) 225 + )} 226 + </ScrollView> 227 + </Pressable> 228 + </Pressable> 229 + </Modal> 230 + ); 231 + }
+34
expo/components/empty-state.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { router } from "expo-router"; 3 + import { Pressable, Text, View } from "react-native"; 4 + 5 + import { Colors } from "@/constants/theme"; 6 + 7 + /** 8 + * Standard empty-state for screens that need a connected server but don't 9 + * have one. Shows an icon, a short message, and a "Choose server" CTA. 10 + */ 11 + export function NotConnectedState({ 12 + message = "Connect to a rockbox server to see your library.", 13 + icon = "server-outline", 14 + }: { 15 + message?: string; 16 + icon?: React.ComponentProps<typeof Ionicons>["name"]; 17 + }) { 18 + return ( 19 + <View className="px-6 pt-12 items-center"> 20 + <Ionicons name={icon} size={48} color={Colors.textMuted} /> 21 + <Text className="text-text-secondary text-[14px] text-center mt-4 font-sans"> 22 + {message} 23 + </Text> 24 + <Pressable 25 + onPress={() => router.push("/settings/server")} 26 + className="mt-5 px-4 py-2 rounded-full bg-accent active:opacity-80" 27 + > 28 + <Text className="text-white text-[13px] font-bold font-sans"> 29 + Choose server 30 + </Text> 31 + </Pressable> 32 + </View> 33 + ); 34 + }
+90
expo/components/equalizer-bars.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { Animated, Easing, View } from "react-native"; 3 + 4 + import { Colors } from "@/constants/theme"; 5 + 6 + /** 7 + * Spotify/Tidal-style "now playing" indicator: three thin bars that bounce 8 + * up and down at slightly offset cadences while `playing` is true. Pauses 9 + * (collapses to 1/4 height) when `playing` is false. 10 + * 11 + * Cheap — three reanimated values, no per-frame JS work. 12 + */ 13 + export function EqualizerBars({ 14 + size = 14, 15 + color = Colors.accent, 16 + playing = true, 17 + }: { 18 + size?: number; 19 + color?: string; 20 + playing?: boolean; 21 + }) { 22 + const bars = useRef([ 23 + new Animated.Value(0.4), 24 + new Animated.Value(0.7), 25 + new Animated.Value(0.5), 26 + ]).current; 27 + 28 + useEffect(() => { 29 + if (!playing) { 30 + bars.forEach((v) => v.setValue(0.25)); 31 + return; 32 + } 33 + const animations = bars.map((v, i) => { 34 + const phase = [ 35 + { dur: 380, min: 0.2, max: 1 }, 36 + { dur: 510, min: 0.35, max: 0.9 }, 37 + { dur: 440, min: 0.25, max: 0.95 }, 38 + ][i]; 39 + const seq = () => 40 + Animated.sequence([ 41 + Animated.timing(v, { 42 + toValue: phase.max, 43 + duration: phase.dur, 44 + easing: Easing.inOut(Easing.quad), 45 + useNativeDriver: false, 46 + }), 47 + Animated.timing(v, { 48 + toValue: phase.min, 49 + duration: phase.dur, 50 + easing: Easing.inOut(Easing.quad), 51 + useNativeDriver: false, 52 + }), 53 + ]); 54 + const loop = Animated.loop(seq()); 55 + loop.start(); 56 + return loop; 57 + }); 58 + return () => animations.forEach((a) => a.stop()); 59 + }, [playing, bars]); 60 + 61 + const barWidth = Math.max(2, Math.round(size / 6)); 62 + const gap = Math.max(1, Math.round(size / 10)); 63 + 64 + return ( 65 + <View 66 + style={{ 67 + width: barWidth * 3 + gap * 2, 68 + height: size, 69 + flexDirection: "row", 70 + alignItems: "flex-end", 71 + justifyContent: "space-between", 72 + }} 73 + > 74 + {bars.map((v, i) => ( 75 + <Animated.View 76 + key={i} 77 + style={{ 78 + width: barWidth, 79 + backgroundColor: color, 80 + borderRadius: barWidth / 2, 81 + height: v.interpolate({ 82 + inputRange: [0, 1], 83 + outputRange: [size * 0.15, size], 84 + }), 85 + }} 86 + /> 87 + ))} 88 + </View> 89 + ); 90 + }
+53
expo/components/persistent-mini-player.tsx
··· 1 + import { usePathname } from "expo-router"; 2 + import { View } from "react-native"; 3 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 4 + 5 + import { MiniPlayer } from "@/components/mini-player"; 6 + import { usePlayer } from "@/lib/player-context"; 7 + 8 + /** 9 + * Floating miniplayer rendered at the root, used on screens that don't have 10 + * the bottom tab dock (album / artist / playlist / settings / etc.). On tab 11 + * routes the miniplayer lives inside the dock — so this component bows out 12 + * to avoid stacking two of them. 13 + * 14 + * Hidden entirely on `/player`, `/queue`, and `/playlist/new` since those 15 + * either own the playback UI directly or are full-screen modals. 16 + */ 17 + const HIDE_ON = new Set(["/player", "/playlist/new"]); 18 + const TAB_PATHS = new Set(["/", "/search", "/library"]); 19 + 20 + /** Approximate height the floating bar reserves at the bottom of the screen. */ 21 + export const FLOATING_MINIPLAYER_HEIGHT = 72; 22 + 23 + export function PersistentMiniPlayer() { 24 + const pathname = usePathname(); 25 + const insets = useSafeAreaInsets(); 26 + const { currentTrack } = usePlayer(); 27 + 28 + if (!currentTrack) return null; 29 + if (HIDE_ON.has(pathname)) return null; 30 + if (TAB_PATHS.has(pathname)) return null; 31 + 32 + const bottom = Math.max(insets.bottom, 8) + 4; 33 + 34 + return ( 35 + <View 36 + pointerEvents="box-none" 37 + className="absolute left-2 right-2" 38 + style={{ bottom }} 39 + > 40 + <View 41 + className="bg-bg-dock rounded-xl overflow-hidden" 42 + style={{ 43 + shadowColor: "#000", 44 + shadowOpacity: 0.5, 45 + shadowRadius: 16, 46 + shadowOffset: { width: 0, height: -4 }, 47 + }} 48 + > 49 + <MiniPlayer /> 50 + </View> 51 + </View> 52 + ); 53 + }
+86
expo/components/playlist-cover.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { Image } from "expo-image"; 3 + import { LinearGradient } from "expo-linear-gradient"; 4 + import { View } from "react-native"; 5 + 6 + const GRADIENTS: [string, string][] = [ 7 + ["#FF6B6B", "#C44569"], 8 + ["#FFA751", "#FFE259"], 9 + ["#43E97B", "#38F9D7"], 10 + ["#4FACFE", "#00F2FE"], 11 + ["#A18CD1", "#FBC2EB"], 12 + ["#5EE7DF", "#B490CA"], 13 + ["#FBAB7E", "#F7CE68"], 14 + ["#FF9A8B", "#FF6A88"], 15 + ["#667EEA", "#764BA2"], 16 + ["#F093FB", "#F5576C"], 17 + ["#30CFD0", "#330867"], 18 + ["#FA709A", "#FEE140"], 19 + ["#84FAB0", "#8FD3F4"], 20 + ["#A8EDEA", "#FED6E3"], 21 + ]; 22 + 23 + function hashSeed(seed?: string): number { 24 + if (!seed) return 0; 25 + let h = 0; 26 + for (let i = 0; i < seed.length; i++) { 27 + h = (h * 31 + seed.charCodeAt(i)) | 0; 28 + } 29 + return Math.abs(h); 30 + } 31 + 32 + export function gradientColors(seed?: string): [string, string] { 33 + return GRADIENTS[hashSeed(seed) % GRADIENTS.length]; 34 + } 35 + 36 + export function PlaylistCover({ 37 + artwork, 38 + seed, 39 + size, 40 + rounded = "md", 41 + iconSize, 42 + className, 43 + }: { 44 + artwork?: string | null; 45 + seed?: string; 46 + size: number; 47 + rounded?: "sm" | "md" | "lg" | "xl"; 48 + iconSize?: number; 49 + className?: string; 50 + }) { 51 + const radiusClass = 52 + rounded === "sm" 53 + ? "rounded" 54 + : rounded === "lg" 55 + ? "rounded-lg" 56 + : rounded === "xl" 57 + ? "rounded-2xl" 58 + : "rounded-md"; 59 + if (artwork) { 60 + return ( 61 + <Image 62 + source={artwork} 63 + style={{ width: size, height: size }} 64 + className={`${radiusClass} ${className ?? ""}`} 65 + contentFit="cover" 66 + /> 67 + ); 68 + } 69 + const colors = gradientColors(seed); 70 + const icon = iconSize ?? Math.round(size * 0.42); 71 + return ( 72 + <View 73 + style={{ width: size, height: size }} 74 + className={`${radiusClass} overflow-hidden ${className ?? ""}`} 75 + > 76 + <LinearGradient 77 + colors={colors} 78 + start={{ x: 0, y: 0 }} 79 + end={{ x: 1, y: 1 }} 80 + style={{ flex: 1, alignItems: "center", justifyContent: "center" }} 81 + > 82 + <Ionicons name="musical-notes" size={icon} color="#FFFFFF" /> 83 + </LinearGradient> 84 + </View> 85 + ); 86 + }
+1 -1
expo/components/section-header.tsx
··· 9 9 }) { 10 10 return ( 11 11 <View className="px-4 mb-3"> 12 - <Text className="text-text-primary text-[22px] font-bold font-sans"> 12 + <Text className="text-text-primary text-[22px] font-display"> 13 13 {title} 14 14 </Text> 15 15 {subtitle ? (
+93 -17
expo/components/track-context-menu.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 2 import { Image } from "expo-image"; 3 3 import { router } from "expo-router"; 4 - import { Modal, Pressable, Text, View } from "react-native"; 4 + import { useState } from "react"; 5 + import { Alert, Modal, Pressable, ScrollView, Text, View } from "react-native"; 5 6 7 + import { PlaylistCover } from "@/components/playlist-cover"; 6 8 import { Colors } from "@/constants/theme"; 7 - import { ALBUMS, ARTISTS } from "@/lib/mock-data"; 9 + import { useLibraryPlaylists } from "@/lib/library-source"; 8 10 import { usePlayer } from "@/lib/player-context"; 11 + import { RockboxClient } from "@/lib/rockbox-client"; 9 12 10 13 export function TrackContextMenu() { 11 14 const { ··· 19 22 20 23 const open = contextTrack !== null; 21 24 const track = contextTrack; 25 + const [pickerOpen, setPickerOpen] = useState(false); 26 + const { data: playlists } = useLibraryPlaylists(); 22 27 23 28 if (!track) { 24 29 return ( ··· 32 37 } 33 38 34 39 const isLiked = liked.has(track.id); 35 - const album = ALBUMS.find((a) => a.title === track.album); 36 - const artist = ARTISTS.find((a) => a.name === track.artist); 40 + const artistId = track.artistId; 41 + const albumId = track.albumId; 37 42 38 43 const items: { 39 44 icon: React.ComponentProps<typeof Ionicons>["name"]; ··· 70 75 icon: "add-circle-outline", 71 76 label: "Add to Playlist", 72 77 onPress: () => { 73 - closeContextMenu(); 78 + setPickerOpen(true); 74 79 }, 75 80 }, 76 81 { 77 82 icon: "person-outline", 78 83 label: "Go to Artist", 79 - disabled: !artist, 84 + disabled: !artistId, 80 85 onPress: () => { 81 - if (!artist) return; 86 + if (!artistId) return; 82 87 closeContextMenu(); 83 - router.push(`/artist/${artist.id}`); 88 + router.push(`/artist/${artistId}`); 84 89 }, 85 90 }, 86 91 { 87 92 icon: "disc-outline", 88 93 label: "Go to Album", 89 - disabled: !album, 94 + disabled: !albumId, 90 95 onPress: () => { 91 - if (!album) return; 96 + if (!albumId) return; 92 97 closeContextMenu(); 93 - router.push(`/album/${album.id}`); 98 + router.push(`/album/${albumId}`); 94 99 }, 95 100 }, 96 - { 97 - icon: "share-outline", 98 - label: "Share", 99 - onPress: closeContextMenu, 100 - }, 101 101 ]; 102 102 103 103 return ( ··· 135 135 <View className="flex-1"> 136 136 <Text 137 137 numberOfLines={1} 138 - className="text-text-primary text-[15px] font-bold font-sans" 138 + className="text-text-primary text-[15px] font-display" 139 139 > 140 140 {track.title} 141 141 </Text> ··· 169 169 ))} 170 170 </Pressable> 171 171 </Pressable> 172 + 173 + {/* Add-to-Playlist picker — overlays on top of the main sheet. */} 174 + <Modal 175 + visible={pickerOpen && open} 176 + transparent 177 + animationType="slide" 178 + onRequestClose={() => setPickerOpen(false)} 179 + > 180 + <Pressable 181 + onPress={() => setPickerOpen(false)} 182 + className="flex-1 bg-black/55" 183 + > 184 + <Pressable 185 + onPress={(e) => e.stopPropagation()} 186 + className="mt-auto bg-bg-elevated rounded-t-2xl pt-2 pb-7" 187 + style={{ maxHeight: "70%" }} 188 + > 189 + <View className="self-center w-10 h-1 rounded-sm bg-border my-2" /> 190 + <Text className="text-text-primary text-base font-bold text-center py-2 font-sans"> 191 + Add to playlist 192 + </Text> 193 + <ScrollView className="max-h-[60vh]"> 194 + {playlists.length === 0 ? ( 195 + <Text className="text-text-secondary text-sm text-center py-6 font-sans"> 196 + No playlists yet — create one first. 197 + </Text> 198 + ) : ( 199 + playlists.map((p) => ( 200 + <Pressable 201 + key={p.id} 202 + onPress={() => { 203 + const tid = track.id; 204 + RockboxClient.addTrackToPlaylist(p.id, tid) 205 + .then(() => { 206 + if (process.env.EXPO_OS !== "web") { 207 + Alert.alert("Added", `Added to “${p.name}”.`); 208 + } 209 + }) 210 + .catch((e: Error) => { 211 + if (process.env.EXPO_OS !== "web") { 212 + Alert.alert("Add failed", e.message); 213 + } 214 + }); 215 + setPickerOpen(false); 216 + closeContextMenu(); 217 + }} 218 + android_ripple={{ color: Colors.bgHover }} 219 + className="flex-row items-center gap-3 px-5 py-3 active:bg-bg-hover" 220 + > 221 + <PlaylistCover 222 + artwork={p.artwork} 223 + seed={p.id || p.name} 224 + size={40} 225 + rounded="sm" 226 + iconSize={18} 227 + /> 228 + <View className="flex-1"> 229 + <Text 230 + numberOfLines={1} 231 + className="text-text-primary text-sm font-semibold font-sans" 232 + > 233 + {p.name} 234 + </Text> 235 + {p.isSmart ? ( 236 + <Text className="text-text-muted text-xs font-sans"> 237 + Smart playlist 238 + </Text> 239 + ) : null} 240 + </View> 241 + </Pressable> 242 + )) 243 + )} 244 + </ScrollView> 245 + </Pressable> 246 + </Pressable> 247 + </Modal> 172 248 </Modal> 173 249 ); 174 250 }
+12
expo/lib/connection.ts
··· 1 + /** 2 + * `useIsConnected()` — true when the native gRPC module is loaded AND we 3 + * have a server selection. Components / providers branch on this to decide 4 + * whether to fetch real data or fall back to mock state. 5 + */ 6 + import { RockboxClient } from "@/lib/rockbox-client"; 7 + import { useSelectedServer } from "@/lib/server-store"; 8 + 9 + export function useIsConnected(): boolean { 10 + const server = useSelectedServer(); 11 + return RockboxClient.isAvailable && server !== null; 12 + }
+21
expo/lib/cover-url.ts
··· 1 + /** 2 + * Build the full URL for an album art asset. 3 + * 4 + * Mirrors `gpui/src/server.rs:get_covers_base()` — 5 + * `http://{host}:{graphqlPort}/covers/{album_art_id}`. The `album_art` field 6 + * on a `Track` proto is just the suffix (an id like `xxxx.jpg`); the host 7 + * and port come from the active `ServerSelection`. 8 + * 9 + * Falls back to `null` when no server is selected — UI should treat that as 10 + * "no artwork available" and render the music-note placeholder. 11 + */ 12 + import { coverUrl as base, useSelectedServer } from "@/lib/server-store"; 13 + 14 + export const coverUrl = base; 15 + 16 + /** Reactive variant — re-renders when the selected server changes. */ 17 + export function useCoverUrl(albumArtId: string | null | undefined): string | null { 18 + const server = useSelectedServer(); 19 + if (!albumArtId || !server) return null; 20 + return `http://${server.host}:${server.graphqlPort}/covers/${albumArtId}`; 21 + }
+262
expo/lib/library-source.ts
··· 1 + /** 2 + * Unified data layer for Library / Home / Search / detail screens. 3 + * 4 + * Backed by real proto-typed gRPC reads from `rockbox-rpc`. When no server is 5 + * selected (`useIsConnected()` is false) the hooks return empty arrays — 6 + * components are expected to render a "connect to a server" empty state. 7 + */ 8 + import { useMemo } from "react"; 9 + 10 + import { useIsConnected } from "@/lib/connection"; 11 + import type { 12 + ProtoAlbum, 13 + ProtoArtist, 14 + ProtoPlaylist, 15 + ProtoTrack, 16 + } from "@/lib/proto-mappers"; 17 + import { 18 + albumFromProto, 19 + artistFromProto, 20 + playlistFromProto, 21 + trackFromProto, 22 + } from "@/lib/proto-mappers"; 23 + import { 24 + useAlbum, 25 + useAlbums, 26 + useArtist, 27 + useArtists, 28 + usePlaylistCurrent, 29 + useSavedPlaylists, 30 + useSavedPlaylistTracks, 31 + useSearch, 32 + useSmartPlaylists, 33 + useSmartPlaylistTracks, 34 + useTracks, 35 + } from "@/lib/queries"; 36 + import type { Album, Artist, Playlist, Track } from "@/lib/types"; 37 + 38 + // ── Top-level lists ───────────────────────────────────────────────────────── 39 + 40 + export function useLibraryTracks() { 41 + const isConnected = useIsConnected(); 42 + const q = useTracks<{ tracks?: ProtoTrack[] }>({ enabled: isConnected }); 43 + const data: Track[] = useMemo( 44 + () => (isConnected ? (q.data?.tracks ?? []).map(trackFromProto) : []), 45 + [isConnected, q.data], 46 + ); 47 + return { data, isLoading: isConnected && q.isLoading, isConnected }; 48 + } 49 + 50 + export function useLibraryAlbums() { 51 + const isConnected = useIsConnected(); 52 + const q = useAlbums<{ albums?: ProtoAlbum[] }>({ enabled: isConnected }); 53 + const data: Album[] = useMemo( 54 + () => (isConnected ? (q.data?.albums ?? []).map(albumFromProto) : []), 55 + [isConnected, q.data], 56 + ); 57 + return { data, isLoading: isConnected && q.isLoading, isConnected }; 58 + } 59 + 60 + export function useLibraryArtists() { 61 + const isConnected = useIsConnected(); 62 + const q = useArtists<{ artists?: ProtoArtist[] }>({ enabled: isConnected }); 63 + const data: Artist[] = useMemo( 64 + () => (isConnected ? (q.data?.artists ?? []).map(artistFromProto) : []), 65 + [isConnected, q.data], 66 + ); 67 + return { data, isLoading: isConnected && q.isLoading, isConnected }; 68 + } 69 + 70 + export function useLibraryPlaylists() { 71 + const isConnected = useIsConnected(); 72 + const saved = useSavedPlaylists<{ playlists?: ProtoPlaylist[] }>({ 73 + enabled: isConnected, 74 + }); 75 + const smart = useSmartPlaylists<{ playlists?: ProtoPlaylist[] }>({ 76 + enabled: isConnected, 77 + }); 78 + const data: Playlist[] = useMemo(() => { 79 + if (!isConnected) return []; 80 + const out: Playlist[] = []; 81 + for (const p of saved.data?.playlists ?? []) out.push(playlistFromProto(p)); 82 + for (const p of smart.data?.playlists ?? []) 83 + out.push({ ...playlistFromProto(p), isSmart: true }); 84 + return out; 85 + }, [isConnected, saved.data, smart.data]); 86 + return { 87 + data, 88 + isLoading: isConnected && (saved.isLoading || smart.isLoading), 89 + isConnected, 90 + }; 91 + } 92 + 93 + // ── Detail lookups ───────────────────────────────────────────────────────── 94 + 95 + type ProtoAlbumDetail = { 96 + album?: ProtoAlbum & { tracks?: ProtoTrack[] }; 97 + tracks?: ProtoTrack[]; 98 + }; 99 + 100 + export function useAlbumDetail(id: string) { 101 + const isConnected = useIsConnected(); 102 + const q = useAlbum<ProtoAlbumDetail>(id, { enabled: isConnected }); 103 + return useMemo(() => { 104 + if (!isConnected) { 105 + return { 106 + album: undefined as Album | undefined, 107 + tracks: [] as Track[], 108 + isLoading: false, 109 + isConnected: false, 110 + }; 111 + } 112 + const inner = q.data?.album; 113 + const album = inner ? albumFromProto(inner) : undefined; 114 + const tracks = 115 + q.data?.tracks?.map(trackFromProto) ?? 116 + inner?.tracks?.map(trackFromProto) ?? 117 + []; 118 + return { album, tracks, isLoading: q.isLoading, isConnected: true }; 119 + }, [isConnected, q.data, q.isLoading]); 120 + } 121 + 122 + type ProtoArtistDetail = { 123 + artist?: ProtoArtist & { albums?: ProtoAlbum[]; tracks?: ProtoTrack[] }; 124 + albums?: ProtoAlbum[]; 125 + tracks?: ProtoTrack[]; 126 + }; 127 + 128 + export function useArtistDetail(id: string) { 129 + const isConnected = useIsConnected(); 130 + const q = useArtist<ProtoArtistDetail>(id, { enabled: isConnected }); 131 + return useMemo(() => { 132 + if (!isConnected) { 133 + return { 134 + artist: undefined as Artist | undefined, 135 + albums: [] as Album[], 136 + tracks: [] as Track[], 137 + isLoading: false, 138 + isConnected: false, 139 + }; 140 + } 141 + const inner = q.data?.artist; 142 + const artist = inner ? artistFromProto(inner) : undefined; 143 + const albums = (q.data?.albums ?? inner?.albums ?? []).map(albumFromProto); 144 + const tracks = (q.data?.tracks ?? inner?.tracks ?? []).map(trackFromProto); 145 + return { artist, albums, tracks, isLoading: q.isLoading, isConnected: true }; 146 + }, [isConnected, q.data, q.isLoading]); 147 + } 148 + 149 + /** 150 + * Resolve playlist metadata + tracks. The proto layer doesn't have a single 151 + * `getPlaylist(id)` RPC — instead we look the playlist up in the saved / 152 + * smart lists (already cached by `useLibraryPlaylists`) and fetch its track 153 + * list via the matching `getSavedPlaylistTracks` / `getSmartPlaylistTracks` 154 + * call. Track ids come back as strings, which we resolve against the 155 + * library tracks cache to get full Track objects. 156 + */ 157 + export function usePlaylistDetail(id: string) { 158 + const isConnected = useIsConnected(); 159 + const { data: playlists } = useLibraryPlaylists(); 160 + const { data: tracksLib } = useLibraryTracks(); 161 + const playlist = playlists.find((p) => p.id === id); 162 + const isSmart = playlist?.isSmart === true; 163 + 164 + const savedTracksQ = useSavedPlaylistTracks<{ 165 + track_ids?: string[]; 166 + tracks?: ProtoTrack[]; 167 + }>(id, { enabled: isConnected && !!playlist && !isSmart }); 168 + const smartTracksQ = useSmartPlaylistTracks<{ 169 + track_ids?: string[]; 170 + tracks?: ProtoTrack[]; 171 + }>(id, { enabled: isConnected && !!playlist && isSmart }); 172 + const tracksQ = isSmart ? smartTracksQ : savedTracksQ; 173 + 174 + return useMemo(() => { 175 + if (!isConnected) { 176 + return { 177 + playlist: undefined as Playlist | undefined, 178 + tracks: [] as Track[], 179 + isLoading: false, 180 + isConnected: false, 181 + }; 182 + } 183 + // Server may return the full track protos OR just an array of ids; handle 184 + // both — when only ids come back, look them up in the library cache. 185 + const inlineTracks = tracksQ.data?.tracks?.map(trackFromProto); 186 + const ids = tracksQ.data?.track_ids ?? []; 187 + const idTracks = inlineTracks 188 + ? inlineTracks 189 + : (ids 190 + .map((tid) => tracksLib.find((t) => t.id === tid)) 191 + .filter((t): t is Track => !!t)); 192 + return { 193 + playlist, 194 + tracks: idTracks, 195 + isLoading: tracksQ.isLoading, 196 + isConnected: true, 197 + }; 198 + }, [isConnected, playlist, tracksQ.data, tracksQ.isLoading, tracksLib]); 199 + } 200 + 201 + // ── Search ───────────────────────────────────────────────────────────────── 202 + 203 + type ProtoSearchResults = { 204 + tracks?: ProtoTrack[]; 205 + albums?: ProtoAlbum[]; 206 + artists?: ProtoArtist[]; 207 + playlists?: ProtoPlaylist[]; 208 + }; 209 + 210 + export type SearchResults = { 211 + tracks: Track[]; 212 + albums: Album[]; 213 + artists: Artist[]; 214 + playlists: Playlist[]; 215 + }; 216 + 217 + export function useLibrarySearch(term: string) { 218 + const isConnected = useIsConnected(); 219 + const q = useSearch<ProtoSearchResults>(term, { enabled: isConnected }); 220 + const data: SearchResults = useMemo(() => { 221 + if (!isConnected || !q.data) { 222 + return { tracks: [], albums: [], artists: [], playlists: [] }; 223 + } 224 + return { 225 + tracks: (q.data.tracks ?? []).map(trackFromProto), 226 + albums: (q.data.albums ?? []).map(albumFromProto), 227 + artists: (q.data.artists ?? []).map(artistFromProto), 228 + playlists: (q.data.playlists ?? []).map(playlistFromProto), 229 + }; 230 + }, [isConnected, q.data]); 231 + return { data, isLoading: isConnected && q.isLoading, isConnected }; 232 + } 233 + 234 + // ── Current queue ────────────────────────────────────────────────────────── 235 + 236 + type ProtoQueue = { 237 + index?: number; 238 + amount?: number; 239 + tracks?: ProtoTrack[]; 240 + }; 241 + 242 + export function useCurrentQueue() { 243 + const isConnected = useIsConnected(); 244 + const q = usePlaylistCurrent<ProtoQueue>({ enabled: isConnected }); 245 + return useMemo(() => { 246 + if (!isConnected) { 247 + return { 248 + tracks: [] as Track[], 249 + currentIdx: 0, 250 + isLoading: false, 251 + isConnected: false, 252 + }; 253 + } 254 + const tracks = (q.data?.tracks ?? []).map(trackFromProto); 255 + return { 256 + tracks, 257 + currentIdx: Math.max(0, q.data?.index ?? 0), 258 + isLoading: q.isLoading, 259 + isConnected: true, 260 + }; 261 + }, [isConnected, q.data, q.isLoading]); 262 + }
+379 -239
expo/lib/player-context.tsx
··· 1 + /** 2 + * Unified player context — feeds the mini-player / player / queue UI from 3 + * either the real gRPC streams (when a server is selected) or the bundled 4 + * mock state. The `usePlayer()` consumer surface is identical across modes. 5 + * 6 + * Real-mode wiring: 7 + * - `currentTrack` ← `useCurrentTrack` query (kept fresh by the 8 + * `rockbox.currentTrack` event in `RockboxStreams`). 9 + * - `queue` ← `usePlaylistCurrent` (refreshed by `rockbox.playlist`). 10 + * - `isPlaying` ← `useStatus` (`status === 1`). 11 + * - `position` ← snapped to `currentTrack.elapsed_ms` whenever a new server 12 + * event arrives, then interpolated locally at 1 Hz while playing so the 13 + * seek bar moves smoothly between updates. 14 + * - `shuffle` / `repeat` ← `useGlobalSettings`. 15 + * - `liked` ← `useLikedTracks`. 16 + * - All actions (`play`, `pause`, `next`, `seek`, `toggleLike`, `playAlbum` 17 + * et al.) call `RockboxClient.*` mutations. 18 + */ 19 + import { useQueryClient } from "@tanstack/react-query"; 1 20 import React, { 2 21 createContext, 3 22 useCallback, ··· 7 26 useRef, 8 27 useState, 9 28 } from "react"; 10 - import { LIKED_TRACK_IDS, QUEUE } from "./mock-data"; 29 + 30 + import { useIsConnected } from "@/lib/connection"; 31 + import { 32 + qk, 33 + useCurrentTrack, 34 + useGlobalSettings, 35 + useLikedTracks, 36 + usePlaylistCurrent, 37 + useStatus, 38 + } from "./queries"; 39 + import { trackFromProto } from "./proto-mappers"; 40 + import { RockboxClient } from "./rockbox-client"; 11 41 import type { Playlist, RepeatMode, Track } from "./types"; 12 42 13 43 type PlayerState = { ··· 63 93 64 94 const PlayerContext = createContext<PlayerContextValue | null>(null); 65 95 96 + // ── Provider ──────────────────────────────────────────────────────────────── 97 + 66 98 export function PlayerProvider({ children }: { children: React.ReactNode }) { 67 - const [queue, setQueue] = useState<Track[]>(QUEUE); 68 - const [currentIdx, setCurrentIdx] = useState(0); 69 - const [position, setPosition] = useState(0); 70 - const [isPlaying, setIsPlaying] = useState(false); 71 - const [shuffle, setShuffle] = useState(false); 72 - const [repeat, setRepeat] = useState<RepeatMode>("off"); 73 - const [volume, setVolume] = useState(0.75); 74 - const [liked, setLiked] = useState<Set<string>>(new Set(LIKED_TRACK_IDS)); 99 + const isReal = useIsConnected(); 100 + 101 + // Shared cross-mode state (lives outside the gRPC layer). 75 102 const [contextTrack, setContextTrack] = useState<Track | null>(null); 76 103 const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]); 77 104 78 - const tickRef = useRef<ReturnType<typeof setInterval> | null>(null); 79 - const currentTrack = queue[currentIdx]; 80 - 81 - // Tick position once per second while playing. 82 - useEffect(() => { 83 - if (tickRef.current) { 84 - clearInterval(tickRef.current); 85 - tickRef.current = null; 86 - } 87 - if (isPlaying && currentTrack) { 88 - tickRef.current = setInterval(() => { 89 - setPosition((p) => { 90 - if (p + 1 >= currentTrack.duration) { 91 - // auto-advance 92 - setTimeout(() => { 93 - setCurrentIdx((idx) => { 94 - if (repeat === "one") return idx; 95 - if (idx + 1 < queue.length) return idx + 1; 96 - if (repeat === "all") return 0; 97 - return idx; 98 - }); 99 - setPosition(0); 100 - if (repeat === "off" && currentIdx + 1 >= queue.length) { 101 - setIsPlaying(false); 102 - } 103 - }, 0); 104 - return 0; 105 - } 106 - return p + 1; 107 - }); 108 - }, 1000); 109 - } 110 - return () => { 111 - if (tickRef.current) { 112 - clearInterval(tickRef.current); 113 - tickRef.current = null; 114 - } 115 - }; 116 - }, [isPlaying, currentTrack, queue.length, repeat, currentIdx]); 117 - 118 - const play = useCallback(() => setIsPlaying(true), []); 119 - const pause = useCallback(() => setIsPlaying(false), []); 120 - const toggle = useCallback(() => setIsPlaying((p) => !p), []); 121 - 122 - const next = useCallback(() => { 123 - setCurrentIdx((idx) => (idx + 1 < queue.length ? idx + 1 : repeat === "all" ? 0 : idx)); 124 - setPosition(0); 125 - }, [queue.length, repeat]); 126 - 127 - const prev = useCallback(() => { 128 - setPosition((p) => { 129 - if (p > 3) return 0; 130 - setCurrentIdx((idx) => Math.max(0, idx - 1)); 131 - return 0; 132 - }); 133 - }, []); 134 - 135 - const seek = useCallback((secs: number) => setPosition(Math.max(0, secs)), []); 136 - 137 - const toggleShuffle = useCallback(() => setShuffle((s) => !s), []); 138 - 139 - const cycleRepeat = useCallback( 140 - () => 141 - setRepeat((r) => (r === "off" ? "all" : r === "all" ? "one" : "off")), 142 - [], 143 - ); 144 - 145 - const toggleLike = useCallback( 146 - (trackId: string) => 147 - setLiked((s) => { 148 - const next = new Set(s); 149 - if (next.has(trackId)) next.delete(trackId); 150 - else next.add(trackId); 151 - return next; 152 - }), 153 - [], 154 - ); 155 - 156 - const jumpTo = useCallback((idx: number) => { 157 - setCurrentIdx(idx); 158 - setPosition(0); 159 - setIsPlaying(true); 160 - }, []); 161 - 162 - const removeFromQueue = useCallback( 163 - (idx: number) => 164 - setQueue((q) => { 165 - const next = q.filter((_, i) => i !== idx); 166 - if (idx < currentIdx) setCurrentIdx((c) => c - 1); 167 - else if (idx === currentIdx) { 168 - setPosition(0); 169 - if (idx >= next.length) setCurrentIdx(Math.max(0, next.length - 1)); 170 - } 171 - return next; 172 - }), 173 - [currentIdx], 174 - ); 175 - 176 - const playTrack = useCallback( 177 - (track: Track) => 178 - setQueue((q) => { 179 - const existing = q.findIndex((t) => t.id === track.id); 180 - if (existing >= 0) { 181 - setCurrentIdx(existing); 182 - setPosition(0); 183 - setIsPlaying(true); 184 - return q; 185 - } 186 - const next = [...q, track]; 187 - setCurrentIdx(next.length - 1); 188 - setPosition(0); 189 - setIsPlaying(true); 190 - return next; 191 - }), 192 - [], 193 - ); 194 - 195 - const playNext = useCallback( 196 - (track: Track) => 197 - setQueue((q) => { 198 - const stripped = q.filter((t) => t.id !== track.id); 199 - const insertIdx = currentIdx + 1; 200 - const next = [ 201 - ...stripped.slice(0, insertIdx), 202 - track, 203 - ...stripped.slice(insertIdx), 204 - ]; 205 - return next; 206 - }), 207 - [currentIdx], 208 - ); 209 - 210 - const playLast = useCallback( 211 - (track: Track) => 212 - setQueue((q) => { 213 - if (q.find((t) => t.id === track.id)) return q; 214 - return [...q, track]; 215 - }), 216 - [], 217 - ); 218 - 219 105 const openContextMenu = useCallback( 220 - (track: Track) => setContextTrack(track), 106 + (t: Track) => setContextTrack(t), 221 107 [], 222 108 ); 223 109 const closeContextMenu = useCallback(() => setContextTrack(null), []); ··· 234 120 trackCount: 0, 235 121 artwork: `https://picsum.photos/seed/${seed}/600/600`, 236 122 }; 123 + if (isReal && !input.isSmart) { 124 + // Best-effort: also create on the server for non-smart playlists. 125 + RockboxClient.createSavedPlaylist(playlist.name, playlist.description ?? null, []).catch( 126 + () => {}, 127 + ); 128 + } 237 129 setUserPlaylists((list) => [playlist, ...list]); 238 130 return playlist; 131 + }, [isReal]); 132 + 133 + // Real-mode value drives the player when a server is selected; we still 134 + // call the hook every render with `enabled: false` when disconnected so 135 + // React's hook ordering stays stable. 136 + const realValue = useRealPlayer({ 137 + enabled: isReal, 138 + contextTrack, 139 + userPlaylists, 140 + openContextMenu, 141 + closeContextMenu, 142 + createPlaylist, 143 + }); 144 + const idleValue = useIdlePlayer({ 145 + contextTrack, 146 + userPlaylists, 147 + openContextMenu, 148 + closeContextMenu, 149 + createPlaylist, 150 + }); 151 + 152 + const value = isReal ? realValue : idleValue; 153 + 154 + return ( 155 + <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider> 156 + ); 157 + } 158 + 159 + export function usePlayer(): PlayerContextValue { 160 + const ctx = useContext(PlayerContext); 161 + if (!ctx) throw new Error("usePlayer must be used within PlayerProvider"); 162 + return ctx; 163 + } 164 + 165 + // ── Real-mode driver ──────────────────────────────────────────────────────── 166 + 167 + type SharedSlice = { 168 + enabled: boolean; 169 + contextTrack: Track | null; 170 + userPlaylists: Playlist[]; 171 + openContextMenu: (t: Track) => void; 172 + closeContextMenu: () => void; 173 + createPlaylist: (input: UserPlaylistInput) => Playlist; 174 + }; 175 + 176 + type ProtoCurrentTrack = { 177 + id?: string; 178 + path?: string; 179 + title?: string; 180 + artist?: string; 181 + artist_id?: string; 182 + album?: string; 183 + album_id?: string; 184 + album_art?: string | null; 185 + length?: number; 186 + // Streaming subscription pushes `duration_ms` / `elapsed_ms` (TrackSnapshot 187 + // shape from `lib/rockbox-client.ts`); the unary `currentTrack()` may use 188 + // either depending on what populated the cache. Tolerate both. 189 + duration_ms?: number; 190 + elapsed_ms?: number; 191 + elapsed?: number; 192 + }; 193 + 194 + type ProtoStatus = { status?: number }; 195 + 196 + type ProtoQueue = { 197 + index?: number; 198 + tracks?: Array<{ id?: string; path?: string; title?: string; artist?: string; album?: string; album_art?: string | null; length?: number }>; 199 + }; 200 + 201 + type ProtoSettings = { 202 + playlist_shuffle?: boolean; 203 + repeat_mode?: number; 204 + }; 205 + 206 + function useRealPlayer(slice: SharedSlice): PlayerContextValue { 207 + const { enabled } = slice; 208 + const qc = useQueryClient(); 209 + 210 + const trackQ = useCurrentTrack<ProtoCurrentTrack>({ enabled }); 211 + const statusQ = useStatus<ProtoStatus>({ enabled }); 212 + const queueQ = usePlaylistCurrent<ProtoQueue>({ enabled }); 213 + const settingsQ = useGlobalSettings<ProtoSettings>({ enabled }); 214 + const likedQ = useLikedTracks<{ tracks?: Array<{ id?: string }>; ids?: string[] }>({ enabled }); 215 + 216 + // Position interpolation: snap to server's elapsed_ms whenever a new track 217 + // event lands, tick locally between events while playing. 218 + const elapsedSecsFromServer = 219 + Math.floor(((trackQ.data?.elapsed_ms ?? trackQ.data?.elapsed ?? 0)) / 1000); 220 + const durationSecs = Math.max( 221 + 0, 222 + Math.floor( 223 + (trackQ.data?.duration_ms ?? 224 + trackQ.data?.length ?? 225 + 0) / 1000, 226 + ), 227 + ); 228 + const trackId = trackQ.data?.id ?? ""; 229 + const isPlaying = statusQ.data?.status === 1; 230 + 231 + const [position, setPosition] = useState(0); 232 + const lastSyncRef = useRef<{ trackId: string; elapsed: number }>({ 233 + trackId: "", 234 + elapsed: 0, 235 + }); 236 + 237 + // Snap to server elapsed when track id OR elapsed_ms changes. 238 + useEffect(() => { 239 + if (!enabled) return; 240 + const last = lastSyncRef.current; 241 + if ( 242 + last.trackId !== trackId || 243 + Math.abs(last.elapsed - elapsedSecsFromServer) >= 1 244 + ) { 245 + lastSyncRef.current = { trackId, elapsed: elapsedSecsFromServer }; 246 + setPosition(elapsedSecsFromServer); 247 + } 248 + }, [enabled, trackId, elapsedSecsFromServer]); 249 + 250 + // 1 Hz tick while playing. 251 + useEffect(() => { 252 + if (!enabled || !isPlaying) return; 253 + const id = setInterval(() => { 254 + setPosition((p) => { 255 + if (durationSecs > 0 && p + 1 > durationSecs) return durationSecs; 256 + return p + 1; 257 + }); 258 + }, 1000); 259 + return () => clearInterval(id); 260 + }, [enabled, isPlaying, durationSecs]); 261 + 262 + // Map proto data → app Track / Track[] 263 + const currentTrack: Track | undefined = useMemo(() => { 264 + if (!trackQ.data) return undefined; 265 + const t = trackFromProto({ 266 + id: trackQ.data.id, 267 + path: trackQ.data.path, 268 + title: trackQ.data.title, 269 + artist: trackQ.data.artist, 270 + artist_id: trackQ.data.artist_id, 271 + album: trackQ.data.album, 272 + album_id: trackQ.data.album_id, 273 + album_art: trackQ.data.album_art ?? undefined, 274 + length: trackQ.data.length ?? trackQ.data.duration_ms ?? 0, 275 + }); 276 + return t.id || t.title ? t : undefined; 277 + }, [trackQ.data]); 278 + 279 + const queue: Track[] = useMemo(() => { 280 + return (queueQ.data?.tracks ?? []).map((p) => trackFromProto(p)); 281 + }, [queueQ.data]); 282 + const currentIdx = Math.max(0, queueQ.data?.index ?? 0); 283 + 284 + // Settings (shuffle / repeat) 285 + const shuffle = settingsQ.data?.playlist_shuffle === true; 286 + const repeat: RepeatMode = (() => { 287 + switch (settingsQ.data?.repeat_mode ?? 0) { 288 + case 1: 289 + return "all"; 290 + case 2: 291 + case 3: 292 + return "one"; 293 + default: 294 + return "off"; 295 + } 296 + })(); 297 + 298 + // Liked set 299 + const liked: Set<string> = useMemo(() => { 300 + const ids = 301 + likedQ.data?.ids ?? 302 + (likedQ.data?.tracks ?? []).map((t) => t.id ?? "").filter(Boolean); 303 + return new Set(ids); 304 + }, [likedQ.data]); 305 + 306 + // ── Actions ────────────────────────────────────────────────────────────── 307 + 308 + const play = useCallback(() => { 309 + RockboxClient.play().catch(() => {}); 310 + }, []); 311 + const pause = useCallback(() => { 312 + RockboxClient.pause().catch(() => {}); 313 + }, []); 314 + const toggle = useCallback(() => { 315 + RockboxClient.playPause().catch(() => {}); 316 + }, []); 317 + const next = useCallback(() => { 318 + RockboxClient.next().catch(() => {}); 319 + }, []); 320 + const prev = useCallback(() => { 321 + RockboxClient.prev().catch(() => {}); 322 + }, []); 323 + const seek = useCallback((secs: number) => { 324 + setPosition(secs); // optimistic UI 325 + RockboxClient.seek(Math.max(0, Math.floor(secs * 1000))).catch(() => {}); 326 + }, []); 327 + const setVolumeAction = useCallback((_vol: number) => { 328 + // Volume is server-side adjusted in steps; the slider in the player 329 + // currently isn't wired, so just no-op here. 330 + }, []); 331 + const toggleShuffle = useCallback(() => { 332 + RockboxClient.saveShuffle(!shuffle).catch(() => {}); 333 + }, [shuffle]); 334 + const cycleRepeat = useCallback(() => { 335 + // 0 off → 1 all → 0 (gpui only toggles between these two, mirror that). 336 + RockboxClient.saveRepeat(repeat === "off" ? 1 : 0).catch(() => {}); 337 + }, [repeat]); 338 + const toggleLike = useCallback( 339 + (trackId: string) => { 340 + // Optimistic update — flip the cache immediately so the heart re-renders 341 + // before the server round-trips. We then refetch on success/error to 342 + // stay in sync with the daemon's authoritative state. 343 + const wasLiked = liked.has(trackId); 344 + qc.setQueryData<{ ids?: string[]; tracks?: { id?: string }[] }>( 345 + qk.liked(), 346 + (prev) => { 347 + const ids = prev?.ids 348 + ? [...prev.ids] 349 + : (prev?.tracks ?? []).map((t) => t.id ?? "").filter(Boolean); 350 + const next = wasLiked 351 + ? ids.filter((id) => id !== trackId) 352 + : [...ids, trackId]; 353 + return { ids: next }; 354 + }, 355 + ); 356 + const op = wasLiked 357 + ? RockboxClient.unlikeTrack(trackId) 358 + : RockboxClient.likeTrack(trackId); 359 + op.catch(() => {}).finally(() => { 360 + qc.invalidateQueries({ queryKey: qk.liked() }); 361 + }); 362 + }, 363 + [liked, qc], 364 + ); 365 + const jumpTo = useCallback((idx: number) => { 366 + RockboxClient.jumpToQueuePosition(idx).catch(() => {}); 367 + }, []); 368 + const removeFromQueue = useCallback((idx: number) => { 369 + RockboxClient.removeFromQueue(idx).catch(() => {}); 370 + }, []); 371 + 372 + const playTrack = useCallback((track: Track) => { 373 + if (track.path) RockboxClient.playTrack(track.path).catch(() => {}); 239 374 }, []); 240 375 241 376 const playQueue = useCallback( 242 - (tracks: Track[], opts?: { startIdx?: number; shuffle?: boolean }) => { 243 - if (tracks.length === 0) return; 244 - let nextQueue = tracks; 245 - let startIdx = opts?.startIdx ?? 0; 246 - if (opts?.shuffle) { 247 - const shuffled = [...tracks]; 248 - for (let i = shuffled.length - 1; i > 0; i--) { 249 - const j = Math.floor(Math.random() * (i + 1)); 250 - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 251 - } 252 - nextQueue = shuffled; 253 - startIdx = 0; 254 - setShuffle(true); 255 - } 256 - setQueue(nextQueue); 257 - setCurrentIdx(Math.max(0, Math.min(startIdx, nextQueue.length - 1))); 258 - setPosition(0); 259 - setIsPlaying(true); 377 + ( 378 + tracks: Track[], 379 + opts?: { startIdx?: number; shuffle?: boolean }, 380 + ) => { 381 + const paths = tracks.map((t) => t.path).filter((p): p is string => !!p); 382 + if (paths.length === 0) return; 383 + const startIdx = opts?.startIdx ?? 0; 384 + const shouldShuffle = opts?.shuffle === true; 385 + // Insert + start. Position 0 replaces queue head; rockbox's playlist 386 + // service handles the rest. After insert, jump to `startIdx`. 387 + RockboxClient.insertTracks(paths, 0, shouldShuffle) 388 + .then(() => RockboxClient.jumpToQueuePosition(startIdx)) 389 + .catch(() => {}); 260 390 }, 261 391 [], 262 392 ); 263 393 264 - const value = useMemo<PlayerContextValue>( 265 - () => ({ 266 - queue, 267 - currentIdx, 268 - position, 269 - isPlaying, 270 - shuffle, 271 - repeat, 272 - volume, 273 - liked, 274 - currentTrack, 275 - play, 276 - pause, 277 - toggle, 278 - next, 279 - prev, 280 - seek, 281 - setVolume, 282 - toggleShuffle, 283 - cycleRepeat, 284 - toggleLike, 285 - jumpTo, 286 - removeFromQueue, 287 - playTrack, 288 - playQueue, 289 - playNext, 290 - playLast, 291 - contextTrack, 292 - openContextMenu, 293 - closeContextMenu, 294 - userPlaylists, 295 - createPlaylist, 296 - }), 297 - [ 298 - queue, 299 - currentIdx, 300 - position, 301 - isPlaying, 302 - shuffle, 303 - repeat, 304 - volume, 305 - liked, 306 - currentTrack, 307 - play, 308 - pause, 309 - toggle, 310 - next, 311 - prev, 312 - seek, 313 - toggleShuffle, 314 - cycleRepeat, 315 - toggleLike, 316 - jumpTo, 317 - removeFromQueue, 318 - playTrack, 319 - playQueue, 320 - playNext, 321 - playLast, 322 - contextTrack, 323 - openContextMenu, 324 - closeContextMenu, 325 - userPlaylists, 326 - createPlaylist, 327 - ], 328 - ); 394 + const playNext = useCallback((track: Track) => { 395 + if (track.path) RockboxClient.insertTrackNext(track.path).catch(() => {}); 396 + }, []); 397 + const playLast = useCallback((track: Track) => { 398 + if (track.path) RockboxClient.insertTrackLast(track.path).catch(() => {}); 399 + }, []); 329 400 330 - return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>; 401 + return { 402 + queue, 403 + currentIdx, 404 + position, 405 + isPlaying, 406 + shuffle, 407 + repeat, 408 + volume: 0.75, 409 + liked, 410 + currentTrack, 411 + 412 + contextTrack: slice.contextTrack, 413 + userPlaylists: slice.userPlaylists, 414 + 415 + play, 416 + pause, 417 + toggle, 418 + next, 419 + prev, 420 + seek, 421 + setVolume: setVolumeAction, 422 + toggleShuffle, 423 + cycleRepeat, 424 + toggleLike, 425 + jumpTo, 426 + removeFromQueue, 427 + playTrack, 428 + playQueue, 429 + playNext, 430 + playLast, 431 + openContextMenu: slice.openContextMenu, 432 + closeContextMenu: slice.closeContextMenu, 433 + createPlaylist: slice.createPlaylist, 434 + }; 331 435 } 332 436 333 - export function usePlayer(): PlayerContextValue { 334 - const ctx = useContext(PlayerContext); 335 - if (!ctx) throw new Error("usePlayer must be used within PlayerProvider"); 336 - return ctx; 437 + // ── Idle driver — used when no server is selected. Everything is a no-op 438 + // and the UI is expected to render an "connect to a server" empty state. 439 + 440 + type IdleSlice = Omit<SharedSlice, "enabled">; 441 + 442 + function useIdlePlayer(slice: IdleSlice): PlayerContextValue { 443 + const noop = useCallback(() => {}, []); 444 + return { 445 + queue: [], 446 + currentIdx: 0, 447 + position: 0, 448 + isPlaying: false, 449 + shuffle: false, 450 + repeat: "off", 451 + volume: 0.75, 452 + liked: new Set<string>(), 453 + currentTrack: undefined, 454 + contextTrack: slice.contextTrack, 455 + userPlaylists: slice.userPlaylists, 456 + play: noop, 457 + pause: noop, 458 + toggle: noop, 459 + next: noop, 460 + prev: noop, 461 + seek: noop, 462 + setVolume: noop, 463 + toggleShuffle: noop, 464 + cycleRepeat: noop, 465 + toggleLike: noop, 466 + jumpTo: noop, 467 + removeFromQueue: noop, 468 + playTrack: noop, 469 + playQueue: noop, 470 + playNext: noop, 471 + playLast: noop, 472 + openContextMenu: slice.openContextMenu, 473 + closeContextMenu: slice.closeContextMenu, 474 + createPlaylist: slice.createPlaylist, 475 + }; 337 476 } 477 +
+126
expo/lib/proto-mappers.ts
··· 1 + /** 2 + * Convert proto JSON returned by `rockbox-rpc` into the app's `Track`/ 3 + * `Album`/`Artist` types. Each mapper is total — missing fields default 4 + * to empty strings / 0 / null so the UI renders a sensible placeholder. 5 + */ 6 + import { coverUrl } from "@/lib/cover-url"; 7 + import type { Album, Artist, Playlist, Track } from "@/lib/types"; 8 + 9 + export type ProtoTrack = { 10 + id?: string; 11 + path?: string; 12 + title?: string; 13 + artist?: string; 14 + album?: string; 15 + album_id?: string; 16 + artist_id?: string; 17 + album_artist?: string; 18 + genre?: string; 19 + album_art?: string | null; 20 + // length is in milliseconds (the proto field name is `length`). 21 + length?: number; 22 + }; 23 + 24 + export type ProtoAlbum = { 25 + id?: string; 26 + title?: string; 27 + artist?: string; 28 + artist_id?: string; 29 + album_art?: string | null; 30 + year?: number; 31 + year_string?: string; 32 + genre?: string; 33 + }; 34 + 35 + export type ProtoArtist = { 36 + id?: string; 37 + name?: string; 38 + image?: string | null; 39 + bio?: string; 40 + }; 41 + 42 + export type ProtoPlaylist = { 43 + id?: string; 44 + name?: string; 45 + description?: string | null; 46 + image?: string | null; 47 + track_count?: number; 48 + is_smart?: boolean; 49 + rules?: string | null; 50 + }; 51 + 52 + const fallback = (s: string | null | undefined, alt = ""): string => s ?? alt; 53 + 54 + export function trackFromProto(p: ProtoTrack | undefined | null): Track { 55 + if (!p) return blankTrack(); 56 + const durationSecs = Math.max(0, Math.floor((p.length ?? 0) / 1000)); 57 + return { 58 + id: fallback(p.id), 59 + path: fallback(p.path), 60 + title: fallback(p.title, "(unknown)"), 61 + artist: fallback(p.artist), 62 + artistId: fallback(p.artist_id) || undefined, 63 + album: fallback(p.album), 64 + albumId: fallback(p.album_id) || undefined, 65 + duration: durationSecs, 66 + artwork: coverUrl(p.album_art) ?? undefined, 67 + }; 68 + } 69 + 70 + export function albumFromProto(p: ProtoAlbum | undefined | null): Album { 71 + if (!p) return blankAlbum(); 72 + return { 73 + id: fallback(p.id), 74 + title: fallback(p.title, "(untitled)"), 75 + artist: fallback(p.artist), 76 + artwork: coverUrl(p.album_art) ?? "", 77 + year: p.year ?? (parseInt(p.year_string ?? "", 10) || undefined), 78 + genre: p.genre ?? undefined, 79 + }; 80 + } 81 + 82 + export function artistFromProto(p: ProtoArtist | undefined | null): Artist { 83 + if (!p) return blankArtist(); 84 + return { 85 + id: fallback(p.id), 86 + name: fallback(p.name, "(unknown)"), 87 + // Proto-side artist images aren't populated yet — fall back to empty 88 + // string and let the UI show a placeholder/initial. 89 + image: p.image ?? "", 90 + }; 91 + } 92 + 93 + export function playlistFromProto( 94 + p: ProtoPlaylist | undefined | null, 95 + ): Playlist { 96 + if (!p) return blankPlaylist(); 97 + return { 98 + id: fallback(p.id), 99 + name: fallback(p.name, "(untitled)"), 100 + description: p.description ?? undefined, 101 + artwork: coverUrl(p.image) ?? "", 102 + trackCount: p.track_count ?? 0, 103 + isSmart: p.is_smart === true, 104 + rules: p.rules ?? undefined, 105 + }; 106 + } 107 + 108 + function blankTrack(): Track { 109 + return { 110 + id: "", 111 + path: "", 112 + title: "", 113 + artist: "", 114 + album: "", 115 + duration: 0, 116 + }; 117 + } 118 + function blankAlbum(): Album { 119 + return { id: "", title: "", artist: "", artwork: "" }; 120 + } 121 + function blankArtist(): Artist { 122 + return { id: "", name: "", image: "" }; 123 + } 124 + function blankPlaylist(): Playlist { 125 + return { id: "", name: "", artwork: "", trackCount: 0 }; 126 + }
+591
expo/lib/queries.ts
··· 1 + /** 2 + * TanStack Query hooks wrapping the rockbox-rpc native module. 3 + * 4 + * Pattern: 5 + * - Reads → `useQuery`. Server-streaming RPCs (`subscribe*`) populate the same 6 + * cache entries via `queryClient.setQueryData`, see `RockboxStreams`. 7 + * - Writes → `useMutation`. After a write, invalidate the touched key so the 8 + * reader refetches (the stream will usually beat the refetch and update the 9 + * cache before it lands). 10 + * 11 + * Every hook short-circuits when `RockboxClient.isAvailable` is false — they 12 + * stay disabled, so the rest of the app can render the mock data without the 13 + * native module attached. 14 + */ 15 + import { 16 + useMutation, 17 + useQuery, 18 + useQueryClient, 19 + type UseMutationOptions, 20 + type UseQueryOptions, 21 + } from "@tanstack/react-query"; 22 + 23 + import { 24 + RockboxClient, 25 + type DiscoveredService, 26 + type StatusSnapshot, 27 + type TrackSnapshot, 28 + } from "@/lib/rockbox-client"; 29 + 30 + // ── Query keys ────────────────────────────────────────────────────────────── 31 + 32 + export const qk = { 33 + all: ["rockbox"] as const, 34 + status: () => [...qk.all, "status"] as const, 35 + currentTrack: () => [...qk.all, "currentTrack"] as const, 36 + playlist: () => [...qk.all, "playlist"] as const, 37 + 38 + tracks: () => [...qk.all, "tracks"] as const, 39 + artists: () => [...qk.all, "artists"] as const, 40 + album: (id: string) => [...qk.all, "album", id] as const, 41 + liked: () => [...qk.all, "liked"] as const, 42 + search: (term: string) => [...qk.all, "search", term] as const, 43 + 44 + globalSettings: () => [...qk.all, "globalSettings"] as const, 45 + globalStatus: () => [...qk.all, "globalStatus"] as const, 46 + soundCurrent: (setting: number) => 47 + [...qk.all, "soundCurrent", setting] as const, 48 + 49 + treeEntries: (path: string | null) => [...qk.all, "tree", path] as const, 50 + 51 + savedPlaylists: () => [...qk.all, "savedPlaylists"] as const, 52 + savedPlaylistTracks: (id: string) => 53 + [...qk.all, "savedPlaylist", id, "tracks"] as const, 54 + smartPlaylists: () => [...qk.all, "smartPlaylists"] as const, 55 + smartPlaylistTracks: (id: string) => 56 + [...qk.all, "smartPlaylist", id, "tracks"] as const, 57 + 58 + bluetoothAvailable: () => [...qk.all, "bluetoothAvailable"] as const, 59 + bluetoothDevices: () => [...qk.all, "bluetoothDevices"] as const, 60 + 61 + /** Cast / AirPlay output devices (HTTP REST). */ 62 + outputDevices: () => [...qk.all, "outputDevices"] as const, 63 + 64 + discovered: () => [...qk.all, "discoveredServers"] as const, 65 + }; 66 + 67 + // ── Helpers ───────────────────────────────────────────────────────────────── 68 + 69 + const enabledByDefault = () => RockboxClient.isAvailable; 70 + 71 + type ROpts<T> = Omit<UseQueryOptions<T>, "queryKey" | "queryFn">; 72 + type MOpts<TData, TVars> = UseMutationOptions<TData, Error, TVars>; 73 + 74 + function rq<T>( 75 + key: readonly unknown[], 76 + fn: () => Promise<T>, 77 + opts?: ROpts<T>, 78 + ) { 79 + return useQuery<T>({ 80 + queryKey: key, 81 + queryFn: fn, 82 + enabled: enabledByDefault(), 83 + staleTime: 5 * 60 * 1000, 84 + ...opts, 85 + }); 86 + } 87 + 88 + // ── Reads ─────────────────────────────────────────────────────────────────── 89 + 90 + // 2s polling acts as a safety net for the streams. Stream events still 91 + // drive immediate updates via `setQueryData`; the poll only kicks in if the 92 + // stream task dies (e.g. transport blip) so the UI doesn't go stale. 93 + const LIVE_REFETCH_MS = 2000; 94 + 95 + export function useStatus<T = StatusSnapshot>(opts?: ROpts<T>) { 96 + return rq(qk.status(), () => RockboxClient.status() as Promise<T>, { 97 + refetchInterval: LIVE_REFETCH_MS, 98 + staleTime: 0, 99 + ...opts, 100 + }); 101 + } 102 + 103 + export function useCurrentTrack<T = TrackSnapshot>(opts?: ROpts<T>) { 104 + return rq( 105 + qk.currentTrack(), 106 + () => RockboxClient.currentTrack() as Promise<T>, 107 + { 108 + refetchInterval: LIVE_REFETCH_MS, 109 + staleTime: 0, 110 + ...opts, 111 + }, 112 + ); 113 + } 114 + 115 + export function usePlaylistCurrent<T = unknown>(opts?: ROpts<T>) { 116 + return rq( 117 + qk.playlist(), 118 + () => RockboxClient.getPlaylistCurrent() as Promise<T>, 119 + { 120 + refetchInterval: LIVE_REFETCH_MS, 121 + staleTime: 0, 122 + ...opts, 123 + }, 124 + ); 125 + } 126 + 127 + export function useTracks<T = unknown>(opts?: ROpts<T>) { 128 + return rq(qk.tracks(), () => RockboxClient.getTracks() as Promise<T>, opts); 129 + } 130 + 131 + export function useArtists<T = unknown>(opts?: ROpts<T>) { 132 + return rq(qk.artists(), () => RockboxClient.getArtists() as Promise<T>, opts); 133 + } 134 + 135 + export function useAlbums<T = unknown>(opts?: ROpts<T>) { 136 + return rq( 137 + [...qk.all, "albums"], 138 + () => RockboxClient.getAlbums() as Promise<T>, 139 + opts, 140 + ); 141 + } 142 + 143 + export function useLikedAlbums<T = unknown>(opts?: ROpts<T>) { 144 + return rq( 145 + [...qk.all, "likedAlbums"], 146 + () => RockboxClient.getLikedAlbums() as Promise<T>, 147 + opts, 148 + ); 149 + } 150 + 151 + export function useArtist<T = unknown>(id: string, opts?: ROpts<T>) { 152 + return rq( 153 + [...qk.all, "artist", id], 154 + () => RockboxClient.getArtist(id) as Promise<T>, 155 + { enabled: enabledByDefault() && id.length > 0, ...opts }, 156 + ); 157 + } 158 + 159 + export function useAlbum<T = unknown>(id: string, opts?: ROpts<T>) { 160 + return rq(qk.album(id), () => RockboxClient.getAlbum(id) as Promise<T>, { 161 + enabled: enabledByDefault() && id.length > 0, 162 + ...opts, 163 + }); 164 + } 165 + 166 + export function useLikedTracks<T = unknown>(opts?: ROpts<T>) { 167 + return rq(qk.liked(), () => RockboxClient.getLikedTracks() as Promise<T>, opts); 168 + } 169 + 170 + export function useSearch<T = unknown>(term: string, opts?: ROpts<T>) { 171 + const t = term.trim(); 172 + return rq(qk.search(t), () => RockboxClient.search(t) as Promise<T>, { 173 + enabled: enabledByDefault() && t.length > 0, 174 + staleTime: 30_000, 175 + ...opts, 176 + }); 177 + } 178 + 179 + export function useGlobalSettings<T = unknown>(opts?: ROpts<T>) { 180 + return rq( 181 + qk.globalSettings(), 182 + () => RockboxClient.getGlobalSettings() as Promise<T>, 183 + opts, 184 + ); 185 + } 186 + 187 + export function useGlobalStatus<T = unknown>(opts?: ROpts<T>) { 188 + return rq( 189 + qk.globalStatus(), 190 + () => RockboxClient.getGlobalStatus() as Promise<T>, 191 + opts, 192 + ); 193 + } 194 + 195 + export function useSoundCurrent<T = unknown>(setting: number, opts?: ROpts<T>) { 196 + return rq( 197 + qk.soundCurrent(setting), 198 + () => RockboxClient.soundCurrent(setting) as Promise<T>, 199 + opts, 200 + ); 201 + } 202 + 203 + export function useTreeEntries<T = unknown>( 204 + path: string | null, 205 + opts?: ROpts<T>, 206 + ) { 207 + return rq( 208 + qk.treeEntries(path), 209 + () => RockboxClient.treeGetEntries(path) as Promise<T>, 210 + opts, 211 + ); 212 + } 213 + 214 + export function useSavedPlaylists<T = unknown>(opts?: ROpts<T>) { 215 + return rq( 216 + qk.savedPlaylists(), 217 + () => RockboxClient.getSavedPlaylists() as Promise<T>, 218 + opts, 219 + ); 220 + } 221 + 222 + export function useSavedPlaylistTracks<T = unknown>( 223 + id: string, 224 + opts?: ROpts<T>, 225 + ) { 226 + return rq( 227 + qk.savedPlaylistTracks(id), 228 + () => RockboxClient.getSavedPlaylistTracks(id) as Promise<T>, 229 + { enabled: enabledByDefault() && id.length > 0, ...opts }, 230 + ); 231 + } 232 + 233 + export function useSmartPlaylists<T = unknown>(opts?: ROpts<T>) { 234 + return rq( 235 + qk.smartPlaylists(), 236 + () => RockboxClient.getSmartPlaylists() as Promise<T>, 237 + opts, 238 + ); 239 + } 240 + 241 + export function useSmartPlaylistTracks<T = unknown>( 242 + id: string, 243 + opts?: ROpts<T>, 244 + ) { 245 + return rq( 246 + qk.smartPlaylistTracks(id), 247 + () => RockboxClient.getSmartPlaylistTracks(id) as Promise<T>, 248 + { enabled: enabledByDefault() && id.length > 0, ...opts }, 249 + ); 250 + } 251 + 252 + export function useBluetoothAvailable(opts?: ROpts<boolean>) { 253 + return rq( 254 + qk.bluetoothAvailable(), 255 + () => RockboxClient.bluetoothAvailable(), 256 + opts, 257 + ); 258 + } 259 + 260 + export function useBluetoothDevices<T = unknown>(opts?: ROpts<T>) { 261 + return rq( 262 + qk.bluetoothDevices(), 263 + () => RockboxClient.getBluetoothDevices() as Promise<T>, 264 + opts, 265 + ); 266 + } 267 + 268 + export function useOutputDevices<T = unknown>(opts?: ROpts<T>) { 269 + return rq( 270 + qk.outputDevices(), 271 + () => RockboxClient.getDevices() as Promise<T>, 272 + { 273 + // Devices change rarely, but the picker should feel snappy. 274 + staleTime: 30_000, 275 + ...opts, 276 + }, 277 + ); 278 + } 279 + 280 + /** 281 + * Discovered LAN servers — populated by the discovery stream rather than a 282 + * one-shot fetch. The hook just reads the cache (defaulting to `[]`). 283 + */ 284 + export function useDiscoveredServers() { 285 + return useQuery<DiscoveredService[]>({ 286 + queryKey: qk.discovered(), 287 + queryFn: () => Promise.resolve([] as DiscoveredService[]), 288 + enabled: enabledByDefault(), 289 + initialData: [], 290 + staleTime: Infinity, 291 + }); 292 + } 293 + 294 + // ── Mutations ─────────────────────────────────────────────────────────────── 295 + 296 + const invalidate = ( 297 + qc: ReturnType<typeof useQueryClient>, 298 + ...keys: readonly (readonly unknown[])[] 299 + ) => { 300 + for (const key of keys) qc.invalidateQueries({ queryKey: key as readonly unknown[] }); 301 + }; 302 + 303 + export function usePlayPause(opts?: MOpts<void, void>) { 304 + const qc = useQueryClient(); 305 + return useMutation({ 306 + mutationFn: () => RockboxClient.playPause(), 307 + onSuccess: () => invalidate(qc, qk.status()), 308 + ...opts, 309 + }); 310 + } 311 + 312 + export function useNext(opts?: MOpts<void, void>) { 313 + const qc = useQueryClient(); 314 + return useMutation({ 315 + mutationFn: () => RockboxClient.next(), 316 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist()), 317 + ...opts, 318 + }); 319 + } 320 + 321 + export function usePrev(opts?: MOpts<void, void>) { 322 + const qc = useQueryClient(); 323 + return useMutation({ 324 + mutationFn: () => RockboxClient.prev(), 325 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist()), 326 + ...opts, 327 + }); 328 + } 329 + 330 + export function useSeek(opts?: MOpts<void, number>) { 331 + const qc = useQueryClient(); 332 + return useMutation({ 333 + mutationFn: (positionMs: number) => RockboxClient.seek(positionMs), 334 + onSuccess: () => invalidate(qc, qk.currentTrack()), 335 + ...opts, 336 + }); 337 + } 338 + 339 + export function usePlayAlbum( 340 + opts?: MOpts<void, { albumId: string; shuffle?: boolean }>, 341 + ) { 342 + const qc = useQueryClient(); 343 + return useMutation({ 344 + mutationFn: ({ albumId, shuffle = false }) => 345 + RockboxClient.playAlbum(albumId, shuffle), 346 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 347 + ...opts, 348 + }); 349 + } 350 + 351 + export function usePlayArtistTracks( 352 + opts?: MOpts<void, { artistId: string; shuffle?: boolean }>, 353 + ) { 354 + const qc = useQueryClient(); 355 + return useMutation({ 356 + mutationFn: ({ artistId, shuffle = false }) => 357 + RockboxClient.playArtistTracks(artistId, shuffle), 358 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 359 + ...opts, 360 + }); 361 + } 362 + 363 + export function usePlayTrack(opts?: MOpts<void, string>) { 364 + const qc = useQueryClient(); 365 + return useMutation({ 366 + mutationFn: (path: string) => RockboxClient.playTrack(path), 367 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 368 + ...opts, 369 + }); 370 + } 371 + 372 + export function usePlayDirectory( 373 + opts?: MOpts<void, { path: string; shuffle?: boolean; position?: number }>, 374 + ) { 375 + const qc = useQueryClient(); 376 + return useMutation({ 377 + mutationFn: ({ path, shuffle = false, position = -1 }) => 378 + RockboxClient.playDirectory(path, shuffle, position), 379 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 380 + ...opts, 381 + }); 382 + } 383 + 384 + export function useJumpToQueuePosition(opts?: MOpts<void, number>) { 385 + const qc = useQueryClient(); 386 + return useMutation({ 387 + mutationFn: (pos: number) => RockboxClient.jumpToQueuePosition(pos), 388 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 389 + ...opts, 390 + }); 391 + } 392 + 393 + export function useRemoveFromQueue(opts?: MOpts<void, number>) { 394 + const qc = useQueryClient(); 395 + return useMutation({ 396 + mutationFn: (pos: number) => RockboxClient.removeFromQueue(pos), 397 + onSuccess: () => invalidate(qc, qk.playlist()), 398 + ...opts, 399 + }); 400 + } 401 + 402 + export function useInsertTrackNext(opts?: MOpts<void, string>) { 403 + const qc = useQueryClient(); 404 + return useMutation({ 405 + mutationFn: (path: string) => RockboxClient.insertTrackNext(path), 406 + onSuccess: () => invalidate(qc, qk.playlist()), 407 + ...opts, 408 + }); 409 + } 410 + 411 + export function useInsertTrackLast(opts?: MOpts<void, string>) { 412 + const qc = useQueryClient(); 413 + return useMutation({ 414 + mutationFn: (path: string) => RockboxClient.insertTrackLast(path), 415 + onSuccess: () => invalidate(qc, qk.playlist()), 416 + ...opts, 417 + }); 418 + } 419 + 420 + export function useLikeTrack(opts?: MOpts<void, string>) { 421 + const qc = useQueryClient(); 422 + return useMutation({ 423 + mutationFn: (id: string) => RockboxClient.likeTrack(id), 424 + onSuccess: () => invalidate(qc, qk.liked()), 425 + ...opts, 426 + }); 427 + } 428 + 429 + export function useUnlikeTrack(opts?: MOpts<void, string>) { 430 + const qc = useQueryClient(); 431 + return useMutation({ 432 + mutationFn: (id: string) => RockboxClient.unlikeTrack(id), 433 + onSuccess: () => invalidate(qc, qk.liked()), 434 + ...opts, 435 + }); 436 + } 437 + 438 + export function useAdjustVolume(opts?: MOpts<void, number>) { 439 + const qc = useQueryClient(); 440 + return useMutation({ 441 + mutationFn: (steps: number) => RockboxClient.adjustVolume(steps), 442 + onSuccess: () => invalidate(qc, qk.soundCurrent(0)), 443 + ...opts, 444 + }); 445 + } 446 + 447 + export function useSaveShuffle(opts?: MOpts<void, boolean>) { 448 + const qc = useQueryClient(); 449 + return useMutation({ 450 + mutationFn: (enabled: boolean) => RockboxClient.saveShuffle(enabled), 451 + onSuccess: () => invalidate(qc, qk.globalSettings(), qk.globalStatus()), 452 + ...opts, 453 + }); 454 + } 455 + 456 + export function useSaveRepeat(opts?: MOpts<void, number>) { 457 + const qc = useQueryClient(); 458 + return useMutation({ 459 + mutationFn: (mode: number) => RockboxClient.saveRepeat(mode), 460 + onSuccess: () => invalidate(qc, qk.globalSettings(), qk.globalStatus()), 461 + ...opts, 462 + }); 463 + } 464 + 465 + export function useCreateSavedPlaylist( 466 + opts?: MOpts< 467 + void, 468 + { name: string; description?: string | null; trackIds?: string[] } 469 + >, 470 + ) { 471 + const qc = useQueryClient(); 472 + return useMutation({ 473 + mutationFn: ({ name, description = null, trackIds = [] }) => 474 + RockboxClient.createSavedPlaylist(name, description, trackIds), 475 + onSuccess: () => invalidate(qc, qk.savedPlaylists()), 476 + ...opts, 477 + }); 478 + } 479 + 480 + export function useDeleteSavedPlaylist(opts?: MOpts<void, string>) { 481 + const qc = useQueryClient(); 482 + return useMutation({ 483 + mutationFn: (id: string) => RockboxClient.deleteSavedPlaylist(id), 484 + onSuccess: () => invalidate(qc, qk.savedPlaylists()), 485 + ...opts, 486 + }); 487 + } 488 + 489 + export function useUpdateSavedPlaylist( 490 + opts?: MOpts< 491 + void, 492 + { id: string; name: string; description?: string | null } 493 + >, 494 + ) { 495 + const qc = useQueryClient(); 496 + return useMutation({ 497 + mutationFn: ({ id, name, description = null }) => 498 + RockboxClient.updateSavedPlaylist(id, name, description), 499 + onSuccess: () => invalidate(qc, qk.savedPlaylists()), 500 + ...opts, 501 + }); 502 + } 503 + 504 + export function useAddTrackToPlaylist( 505 + opts?: MOpts<void, { playlistId: string; trackId: string }>, 506 + ) { 507 + const qc = useQueryClient(); 508 + return useMutation({ 509 + mutationFn: ({ playlistId, trackId }) => 510 + RockboxClient.addTrackToPlaylist(playlistId, trackId), 511 + onSuccess: (_d, { playlistId }) => 512 + invalidate(qc, qk.savedPlaylistTracks(playlistId)), 513 + ...opts, 514 + }); 515 + } 516 + 517 + export function useRemoveTrackFromPlaylist( 518 + opts?: MOpts<void, { playlistId: string; trackId: string }>, 519 + ) { 520 + const qc = useQueryClient(); 521 + return useMutation({ 522 + mutationFn: ({ playlistId, trackId }) => 523 + RockboxClient.removeTrackFromPlaylist(playlistId, trackId), 524 + onSuccess: (_d, { playlistId }) => 525 + invalidate(qc, qk.savedPlaylistTracks(playlistId)), 526 + ...opts, 527 + }); 528 + } 529 + 530 + export function usePlaySavedPlaylist(opts?: MOpts<void, string>) { 531 + const qc = useQueryClient(); 532 + return useMutation({ 533 + mutationFn: (id: string) => RockboxClient.playSavedPlaylist(id), 534 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 535 + ...opts, 536 + }); 537 + } 538 + 539 + export function usePlaySmartPlaylist(opts?: MOpts<void, string>) { 540 + const qc = useQueryClient(); 541 + return useMutation({ 542 + mutationFn: (id: string) => RockboxClient.playSmartPlaylist(id), 543 + onSuccess: () => invalidate(qc, qk.currentTrack(), qk.playlist(), qk.status()), 544 + ...opts, 545 + }); 546 + } 547 + 548 + export function useScanBluetooth(opts?: MOpts<void, void>) { 549 + const qc = useQueryClient(); 550 + return useMutation({ 551 + mutationFn: () => RockboxClient.scanBluetooth(), 552 + onSuccess: () => invalidate(qc, qk.bluetoothDevices()), 553 + ...opts, 554 + }); 555 + } 556 + 557 + export function useConnectBluetooth(opts?: MOpts<void, string>) { 558 + const qc = useQueryClient(); 559 + return useMutation({ 560 + mutationFn: (address: string) => RockboxClient.connectBluetooth(address), 561 + onSuccess: () => invalidate(qc, qk.bluetoothDevices()), 562 + ...opts, 563 + }); 564 + } 565 + 566 + export function useDisconnectBluetooth(opts?: MOpts<void, string>) { 567 + const qc = useQueryClient(); 568 + return useMutation({ 569 + mutationFn: (address: string) => RockboxClient.disconnectBluetooth(address), 570 + onSuccess: () => invalidate(qc, qk.bluetoothDevices()), 571 + ...opts, 572 + }); 573 + } 574 + 575 + export function useConnectDevice(opts?: MOpts<void, string>) { 576 + const qc = useQueryClient(); 577 + return useMutation({ 578 + mutationFn: (id: string) => RockboxClient.connectDevice(id), 579 + onSuccess: () => invalidate(qc, qk.outputDevices()), 580 + ...opts, 581 + }); 582 + } 583 + 584 + export function useDisconnectDevice(opts?: MOpts<void, string>) { 585 + const qc = useQueryClient(); 586 + return useMutation({ 587 + mutationFn: (id: string) => RockboxClient.disconnectDevice(id), 588 + onSuccess: () => invalidate(qc, qk.outputDevices()), 589 + ...opts, 590 + }); 591 + }
+26
expo/lib/rockbox-client.ts
··· 46 46 setServerUrl(url: string) { 47 47 require_().setServerUrl(url); 48 48 }, 49 + setHttpUrl(url: string) { 50 + require_().setHttpUrl(url); 51 + }, 52 + 53 + // Cast / AirPlay output devices 54 + getDevices() { 55 + return require_().getDevices(); 56 + }, 57 + connectDevice(id: string) { 58 + return require_().connectDevice(id); 59 + }, 60 + disconnectDevice(id: string) { 61 + return require_().disconnectDevice(id); 62 + }, 49 63 50 64 ping(): Promise<boolean> { 51 65 return require_().ping(); ··· 141 155 getArtists() { 142 156 return require_().getArtists(); 143 157 }, 158 + getAlbums() { 159 + return require_().getAlbums(); 160 + }, 161 + getLikedAlbums() { 162 + return require_().getLikedAlbums(); 163 + }, 164 + getArtist(id: string) { 165 + return require_().getArtist(id); 166 + }, 144 167 getAlbum(id: string) { 145 168 return require_().getAlbum(id); 146 169 }, ··· 222 245 // Bluetooth 223 246 bluetoothAvailable() { 224 247 return require_().bluetoothAvailable(); 248 + }, 249 + scanBluetooth() { 250 + return require_().scanBluetooth(); 225 251 }, 226 252 getBluetoothDevices() { 227 253 return require_().getBluetoothDevices();
+93
expo/lib/rockbox-streams.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 + import { useCallback, useEffect, useRef } from "react"; 3 + 4 + import { qk } from "@/lib/queries"; 5 + import { 6 + RockboxClient, 7 + type DiscoveredService, 8 + } from "@/lib/rockbox-client"; 9 + import { 10 + autoSelectFromDiscovery, 11 + hydrateSelectedServer, 12 + } from "@/lib/server-store"; 13 + 14 + /** 15 + * Mounts the rockbox streaming subscriptions and pipes their events into 16 + * the React Query cache. Render this once at the top of the app — the 17 + * streams stay live until unmount, the cache stays fresh in real time. 18 + */ 19 + 20 + // Module-level handle so non-React code (e.g. the server picker's 21 + // pull-to-refresh) can ask the streams component to restart discovery. 22 + let restartDiscoveryImpl: (() => void) | null = null; 23 + 24 + export function restartDiscovery() { 25 + restartDiscoveryImpl?.(); 26 + } 27 + 28 + export function RockboxStreams() { 29 + const qc = useQueryClient(); 30 + const discoveryUnsubRef = useRef<(() => void) | null>(null); 31 + 32 + const startDiscovery = useCallback(() => { 33 + // Tear down the previous browse, if any, so we get a fresh 34 + // ServiceDaemon and can re-resolve previously-cached entries. 35 + discoveryUnsubRef.current?.(); 36 + discoveryUnsubRef.current = RockboxClient.subscribeDiscovery((svc) => { 37 + qc.setQueryData<DiscoveredService[]>(qk.discovered(), (prev) => { 38 + const list = prev ?? []; 39 + if (list.some((s) => s.fullname === svc.fullname)) return list; 40 + return [...list, svc]; 41 + }); 42 + void autoSelectFromDiscovery(svc); 43 + }); 44 + }, [qc]); 45 + 46 + useEffect(() => { 47 + if (!RockboxClient.isAvailable) return; 48 + 49 + const unsubs: Array<() => void> = []; 50 + 51 + unsubs.push( 52 + RockboxClient.subscribeStatus((s) => { 53 + qc.setQueryData(qk.status(), s); 54 + }), 55 + ); 56 + unsubs.push( 57 + RockboxClient.subscribeCurrentTrack((t) => { 58 + qc.setQueryData(qk.currentTrack(), t); 59 + }), 60 + ); 61 + unsubs.push( 62 + RockboxClient.subscribePlaylist((p) => { 63 + qc.setQueryData(qk.playlist(), p); 64 + }), 65 + ); 66 + unsubs.push( 67 + RockboxClient.subscribeLibrary((snapshot) => { 68 + qc.setQueryData([...qk.all, "libraryStream"], snapshot); 69 + }), 70 + ); 71 + 72 + void hydrateSelectedServer(); 73 + startDiscovery(); 74 + 75 + // Expose the restart handle for callers outside React. 76 + // Note: we deliberately DO NOT clear the cache here — mdns-sd takes a 77 + // few seconds to re-resolve previously-seen services, so clearing first 78 + // would leave the picker empty even when servers are still online. The 79 + // dedup-by-fullname guard on each event keeps the list clean. 80 + restartDiscoveryImpl = () => { 81 + startDiscovery(); 82 + }; 83 + 84 + return () => { 85 + restartDiscoveryImpl = null; 86 + discoveryUnsubRef.current?.(); 87 + discoveryUnsubRef.current = null; 88 + for (const u of unsubs) u(); 89 + }; 90 + }, [qc, startDiscovery]); 91 + 92 + return null; 93 + }
+300
expo/lib/server-store.ts
··· 1 + /** 2 + * Persistent selection of the rockboxd server to talk to. 3 + * 4 + * Stores host + port pieces separately so we can build both the gRPC URL 5 + * (host:grpcPort) and the GraphQL/covers URL (host:graphqlPort) without 6 + * re-parsing. 7 + * 8 + * In-memory subscribers get notified on every change, so React components 9 + * can re-render via `useSelectedServer()` without going through RQ. 10 + */ 11 + import AsyncStorage from "@react-native-async-storage/async-storage"; 12 + import { useEffect, useState } from "react"; 13 + 14 + import { RockboxClient, type DiscoveredService } from "@/lib/rockbox-client"; 15 + 16 + const STORAGE_KEY = "rockbox.selectedServer"; 17 + const DEFAULT_GRPC_PORT = 6061; 18 + const DEFAULT_GRAPHQL_PORT = 6062; 19 + const DEFAULT_HTTP_PORT = 6063; 20 + 21 + export type ServerSelection = { 22 + /** Host or IP — no scheme, no port. */ 23 + host: string; 24 + grpcPort: number; 25 + /** Used to build covers/* URLs. Defaults to 6062 when not discovered. */ 26 + graphqlPort: number; 27 + /** Used by the cast/AirPlay HTTP endpoints. Defaults to 6063. */ 28 + httpPort: number; 29 + /** Friendly name for UI. */ 30 + label: string; 31 + /** Original mDNS fullname when discovered, else null for manual entries. */ 32 + fullname: string | null; 33 + }; 34 + 35 + let current: ServerSelection | null = null; 36 + let hydrated = false; 37 + const listeners = new Set<(s: ServerSelection | null) => void>(); 38 + 39 + function notify() { 40 + for (const l of listeners) l(current); 41 + } 42 + 43 + function applyToNative(s: ServerSelection | null) { 44 + if (!RockboxClient.isAvailable || !s) return; 45 + RockboxClient.setServerUrl(`http://${s.host}:${s.grpcPort}`); 46 + RockboxClient.setHttpUrl(`http://${s.host}:${s.httpPort}`); 47 + } 48 + 49 + async function persist(s: ServerSelection | null) { 50 + // Persistence is best-effort. If AsyncStorage's native module isn't 51 + // available (e.g. dev client built before the dep landed), skip silently 52 + // so the in-memory selection still works for the current session. 53 + try { 54 + if (s) await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s)); 55 + else await AsyncStorage.removeItem(STORAGE_KEY); 56 + } catch (err) { 57 + if (__DEV__) { 58 + console.warn( 59 + "[server-store] persistence skipped (AsyncStorage unavailable):", 60 + err, 61 + ); 62 + } 63 + } 64 + } 65 + 66 + export async function hydrateSelectedServer(): Promise<ServerSelection | null> { 67 + if (hydrated) return current; 68 + try { 69 + const raw = await AsyncStorage.getItem(STORAGE_KEY); 70 + if (raw) { 71 + const parsed = JSON.parse(raw) as ServerSelection; 72 + current = parsed; 73 + applyToNative(parsed); 74 + } 75 + } catch (err) { 76 + // Either AsyncStorage isn't loaded, or storage is corrupt — start clean. 77 + if (__DEV__) { 78 + console.warn( 79 + "[server-store] hydrate skipped (AsyncStorage unavailable):", 80 + err, 81 + ); 82 + } 83 + } 84 + hydrated = true; 85 + notify(); 86 + return current; 87 + } 88 + 89 + export function getSelectedServer(): ServerSelection | null { 90 + return current; 91 + } 92 + 93 + export async function setSelectedServer( 94 + s: ServerSelection | null, 95 + ): Promise<void> { 96 + current = s; 97 + applyToNative(s); 98 + await persist(s); 99 + notify(); 100 + } 101 + 102 + /** 103 + * Update only the graphql/covers port for the active selection. 104 + * Useful when one mDNS event contributes the gRPC port and a sibling 105 + * event contributes the GraphQL port (rockboxd registers both). 106 + */ 107 + export async function setGraphqlPort(host: string, graphqlPort: number) { 108 + if (!current) return; 109 + if (current.host !== host) return; 110 + if (current.graphqlPort === graphqlPort) return; 111 + current = { ...current, graphqlPort }; 112 + await persist(current); 113 + notify(); 114 + } 115 + 116 + /** Mirror of `setGraphqlPort` for the HTTP / device-picker port. */ 117 + export async function setHttpPort(host: string, httpPort: number) { 118 + if (!current) return; 119 + if (current.host !== host) return; 120 + if (current.httpPort === httpPort) return; 121 + current = { ...current, httpPort }; 122 + applyToNative(current); 123 + await persist(current); 124 + notify(); 125 + } 126 + 127 + /** Returns the base URL for cover-art fetches (`/covers/{id}` is appended). */ 128 + export function coversBaseUrl(s: ServerSelection | null = current): string | null { 129 + if (!s) return null; 130 + return `http://${s.host}:${s.graphqlPort}/covers/`; 131 + } 132 + 133 + /** 134 + * Build a fully-qualified cover URL from a track's `album_art` id (which is 135 + * the suffix the rockbox library returns). Returns null if there's no server 136 + * yet, or if the id is empty. 137 + */ 138 + export function coverUrl(albumArtId: string | null | undefined): string | null { 139 + if (!albumArtId) return null; 140 + const base = coversBaseUrl(); 141 + if (!base) return null; 142 + return `${base}${albumArtId}`; 143 + } 144 + 145 + /** 146 + * Pick a discovered service if nothing is selected yet. The rockbox-discovery 147 + * crate emits one event per service flavor (grpc-*, graphql-*, http-*, mpd-*) 148 + * sharing the same hostname; we prefer the gRPC service for the primary URL 149 + * and merge the graphql port in when the matching event arrives. 150 + */ 151 + export async function autoSelectFromDiscovery( 152 + svc: DiscoveredService, 153 + ): Promise<ServerSelection | null> { 154 + await hydrateSelectedServer(); 155 + 156 + const host = preferredHost(svc); 157 + if (!host) return null; 158 + const flavor = serviceFlavor(svc); 159 + 160 + // Already have a selection? Just merge in extra port info if it's the same host. 161 + if (current) { 162 + if (flavor === "graphql" && current.host === host) { 163 + await setGraphqlPort(host, svc.port); 164 + } else if (flavor === "http" && current.host === host) { 165 + await setHttpPort(host, svc.port); 166 + } 167 + return current; 168 + } 169 + 170 + // No selection yet — only pick when we see a grpc-* event so we hit the 171 + // right port. Other flavors (http/mpd) get ignored until grpc lands. 172 + if (flavor !== "grpc") return null; 173 + 174 + const selection: ServerSelection = { 175 + host, 176 + grpcPort: svc.port, 177 + graphqlPort: DEFAULT_GRAPHQL_PORT, 178 + httpPort: DEFAULT_HTTP_PORT, 179 + label: pretty(svc.hostname) || host, 180 + fullname: svc.fullname, 181 + }; 182 + await setSelectedServer(selection); 183 + return selection; 184 + } 185 + 186 + /** 187 + * Build a `ServerSelection` from a discovery event manually (used by the 188 + * server-picker UI when the user taps an entry). 189 + */ 190 + export function serverFromDiscovery( 191 + svc: DiscoveredService, 192 + ): ServerSelection | null { 193 + const host = preferredHost(svc); 194 + if (!host) return null; 195 + const flavor = serviceFlavor(svc); 196 + return { 197 + host, 198 + grpcPort: flavor === "grpc" ? svc.port : DEFAULT_GRPC_PORT, 199 + graphqlPort: flavor === "graphql" ? svc.port : DEFAULT_GRAPHQL_PORT, 200 + httpPort: flavor === "http" ? svc.port : DEFAULT_HTTP_PORT, 201 + label: pretty(svc.hostname) || host, 202 + fullname: svc.fullname, 203 + }; 204 + } 205 + 206 + export function manualServer(host: string, port = DEFAULT_GRPC_PORT): ServerSelection { 207 + return { 208 + host, 209 + grpcPort: port, 210 + graphqlPort: DEFAULT_GRAPHQL_PORT, 211 + httpPort: DEFAULT_HTTP_PORT, 212 + label: host, 213 + fullname: null, 214 + }; 215 + } 216 + 217 + // ── Helpers ───────────────────────────────────────────────────────────────── 218 + 219 + function preferredHost(svc: DiscoveredService): string | null { 220 + // The bridge usually delivers an array, but be defensive against malformed 221 + // platform payloads — fall back to the hostname rather than throwing. 222 + const addresses: string[] = Array.isArray(svc.addresses) ? svc.addresses : []; 223 + 224 + // Drop addresses we can never connect to from the phone. 225 + const usable = addresses.filter((a) => { 226 + if (!a) return false; 227 + if (a === "0.0.0.0" || a === "::") return false; 228 + if (a.startsWith("127.")) return false; // loopback 229 + if (a.startsWith("169.254.")) return false; // link-local 230 + return true; 231 + }); 232 + 233 + // Rank IPv4s: home LAN > corporate LAN > anything else > Docker bridges 234 + // (172.16-31.*, dished out by libmdns when rockboxd runs in Docker). 235 + // Reachable addresses come first; the 172.* range is kept as a last resort 236 + // in case it really is the LAN. 237 + const score = (a: string): number => { 238 + if (!/^\d+\.\d+\.\d+\.\d+$/.test(a)) return -1; // IPv6 etc., last 239 + if (a.startsWith("192.168.")) return 5; 240 + if (a.startsWith("10.")) return 4; 241 + if (isDockerBridge(a)) return 1; 242 + return 3; 243 + }; 244 + const ipv4Sorted = usable 245 + .filter((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) 246 + .sort((a, b) => score(b) - score(a)); 247 + 248 + if (ipv4Sorted.length > 0) return ipv4Sorted[0]; 249 + 250 + // No IPv4 left → fall back to anything usable, then the hostname. 251 + if (usable.length > 0) return usable[0]; 252 + const host = (svc.hostname ?? "").replace(/\.$/, ""); 253 + return host || null; 254 + } 255 + 256 + /** True for the Docker default bridge range 172.16.0.0/12 (172.16.* – 172.31.*). */ 257 + function isDockerBridge(ipv4: string): boolean { 258 + if (!ipv4.startsWith("172.")) return false; 259 + const second = parseInt(ipv4.split(".")[1] ?? "", 10); 260 + return Number.isFinite(second) && second >= 16 && second <= 31; 261 + } 262 + 263 + function pretty(hostname: string): string { 264 + return hostname.replace(/\.local\.?$/, "").replace(/\.$/, ""); 265 + } 266 + 267 + /** 268 + * Inspect the service name to decide which port flavor this event represents. 269 + * The rockbox-discovery crate names them `grpc-<id>`, `graphql-<id>`, `http-<id>`, 270 + * `mpd-<id>`. Falls back to `"unknown"` for arbitrary service names. 271 + */ 272 + export function serviceFlavor( 273 + svc: DiscoveredService, 274 + ): "grpc" | "graphql" | "http" | "mpd" | "unknown" { 275 + const name = svc.fullname.toLowerCase(); 276 + if (name.includes("grpc-")) return "grpc"; 277 + if (name.includes("graphql-")) return "graphql"; 278 + if (name.includes("http-")) return "http"; 279 + if (name.includes("mpd-")) return "mpd"; 280 + return "unknown"; 281 + } 282 + 283 + /** Subscribe to selection changes; returns an unsubscribe function. */ 284 + export function subscribeSelectedServer( 285 + listener: (s: ServerSelection | null) => void, 286 + ): () => void { 287 + listeners.add(listener); 288 + // Fire once with the current value so subscribers don't miss the boot state. 289 + listener(current); 290 + return () => { 291 + listeners.delete(listener); 292 + }; 293 + } 294 + 295 + /** Hook flavor — re-renders on every selection change. */ 296 + export function useSelectedServer(): ServerSelection | null { 297 + const [s, setS] = useState<ServerSelection | null>(current); 298 + useEffect(() => subscribeSelectedServer(setS), []); 299 + return s; 300 + }
+6
expo/lib/types.ts
··· 1 1 export type Track = { 2 2 id: string; 3 + /** Filesystem path on the daemon — required by `playTrack` / `insertTrack`. */ 4 + path?: string; 3 5 title: string; 4 6 artist: string; 7 + /** Empty when the proto doesn't tell us the linked artist row. */ 8 + artistId?: string; 5 9 album: string; 10 + /** Empty when the proto doesn't tell us the linked album row. */ 11 + albumId?: string; 6 12 duration: number; 7 13 artwork?: string; 8 14 liked?: boolean;
+38
expo/lib/use-bottom-spacing.ts
··· 1 + /** 2 + * Returns the right bottom padding for a scrollable view so its last items 3 + * aren't hidden by the persistent miniplayer (or any docked UI). 4 + * 5 + * - Tab routes (`/`, `/search`, `/library`): React Navigation already insets 6 + * content by the custom tab-bar height, so we just add a small breathing 7 + * margin on top. 8 + * - Detail / settings routes when a track is loaded: reserve the floating 9 + * miniplayer's height so the last row sits above it. 10 + * - Otherwise: a base safe-area-aware padding. 11 + */ 12 + import { usePathname } from "expo-router"; 13 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 14 + 15 + import { FLOATING_MINIPLAYER_HEIGHT } from "@/components/persistent-mini-player"; 16 + import { usePlayer } from "@/lib/player-context"; 17 + 18 + const TAB_PATHS = new Set(["/", "/search", "/library"]); 19 + const HIDE_MINIPLAYER_ON = new Set(["/player", "/playlist/new"]); 20 + 21 + export function useBottomSpacing(extra = 8): number { 22 + const pathname = usePathname(); 23 + const insets = useSafeAreaInsets(); 24 + const { currentTrack } = usePlayer(); 25 + 26 + // Tab routes: tab bar (with embedded miniplayer) is handled by RN 27 + // navigation's content inset; just leave a small visual margin. 28 + if (TAB_PATHS.has(pathname)) return extra + 16; 29 + 30 + const miniplayerVisible = 31 + !!currentTrack && !HIDE_MINIPLAYER_ON.has(pathname); 32 + const safeBottom = Math.max(insets.bottom, 8); 33 + 34 + if (miniplayerVisible) { 35 + return safeBottom + FLOATING_MINIPLAYER_HEIGHT + extra; 36 + } 37 + return safeBottom + extra; 38 + }
+8
expo/modules/rockbox-rpc/android/src/main/AndroidManifest.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 3 3 <uses-permission android:name="android.permission.INTERNET" /> 4 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> 5 + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> 6 + <!-- 7 + Required so mDNS / Bonjour responses are delivered to the app. 8 + Android filters multicast UDP by default to save battery — we hold 9 + a MulticastLock while a discovery subscription is active. 10 + --> 11 + <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> 4 12 </manifest>
+97 -10
expo/modules/rockbox-rpc/android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt
··· 1 1 package expo.modules.rockboxrpc 2 2 3 + import android.content.Context 4 + import android.net.wifi.WifiManager 5 + import android.util.Log 3 6 import expo.modules.kotlin.modules.Module 4 7 import expo.modules.kotlin.modules.ModuleDefinition 5 8 import kotlinx.coroutines.CoroutineScope ··· 10 13 import kotlinx.coroutines.launch 11 14 import org.json.JSONObject 12 15 import java.util.concurrent.ConcurrentHashMap 16 + import java.util.concurrent.atomic.AtomicInteger 13 17 14 18 /** 15 19 * Native module wrapping the rockbox-expo Rust crate (built into a .so per ABI ··· 26 30 } 27 31 28 32 @JvmStatic external fun rb_set_server_url(url: String): Int 33 + @JvmStatic external fun rb_set_http_url(url: String): Int 29 34 @JvmStatic external fun rb_ping(): Int 35 + 36 + @JvmStatic external fun rb_get_devices_json(): String? 37 + @JvmStatic external fun rb_connect_device(id: String): Int 38 + @JvmStatic external fun rb_disconnect_device(id: String): Int 30 39 @JvmStatic external fun rb_play(): Int 31 40 @JvmStatic external fun rb_pause(): Int 32 41 @JvmStatic external fun rb_play_pause(): Int ··· 67 76 68 77 @JvmStatic external fun rb_get_tracks_json(): String? 69 78 @JvmStatic external fun rb_get_artists_json(): String? 79 + @JvmStatic external fun rb_get_albums_json(): String? 80 + @JvmStatic external fun rb_get_liked_albums_json(): String? 81 + @JvmStatic external fun rb_get_artist_json(id: String): String? 70 82 @JvmStatic external fun rb_get_album_json(id: String): String? 71 83 @JvmStatic external fun rb_get_liked_tracks_json(): String? 72 84 @JvmStatic external fun rb_search_json(term: String): String? ··· 95 107 @JvmStatic external fun rb_play_smart_playlist(id: String): Int 96 108 97 109 @JvmStatic external fun rb_bluetooth_available(): Int 110 + @JvmStatic external fun rb_scan_bluetooth(): Int 98 111 @JvmStatic external fun rb_get_bluetooth_devices_json(): String? 99 112 @JvmStatic external fun rb_connect_bluetooth(address: String): Int 100 113 @JvmStatic external fun rb_disconnect_bluetooth(address: String): Int ··· 102 115 103 116 private val scope = CoroutineScope(Dispatchers.IO) 104 117 private val pollJobs = ConcurrentHashMap<Int, Job>() 118 + private val discoveryJobs = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>() 119 + 120 + /** 121 + * MulticastLock kept alive while at least one discovery subscription is 122 + * active. Without it, Android's Wi-Fi stack drops incoming multicast UDP 123 + * packets — meaning mDNS / Bonjour responses never reach the app and the 124 + * picker stays empty forever even though the daemon is broadcasting. 125 + */ 126 + @Volatile private var multicastLock: WifiManager.MulticastLock? = null 127 + private val discoverySubs = AtomicInteger(0) 128 + 129 + private fun acquireMulticastLock() { 130 + if (discoverySubs.getAndIncrement() != 0) return 131 + try { 132 + val ctx = appContext.reactContext?.applicationContext ?: return 133 + val wifi = ctx.getSystemService(Context.WIFI_SERVICE) as? WifiManager 134 + ?: return 135 + val lock = wifi.createMulticastLock("rockbox-rpc-mdns").apply { 136 + setReferenceCounted(false) 137 + acquire() 138 + } 139 + multicastLock = lock 140 + } catch (e: Exception) { 141 + Log.w("RockboxRpc", "MulticastLock acquire failed", e) 142 + } 143 + } 144 + 145 + private fun releaseMulticastLock() { 146 + val remaining = discoverySubs.updateAndGet { (it - 1).coerceAtLeast(0) } 147 + if (remaining > 0) return 148 + try { 149 + multicastLock?.takeIf { it.isHeld }?.release() 150 + } catch (e: Exception) { 151 + Log.w("RockboxRpc", "MulticastLock release failed", e) 152 + } finally { 153 + multicastLock = null 154 + } 155 + } 105 156 106 157 override fun definition() = ModuleDefinition { 107 158 Name("RockboxRpc") ··· 125 176 Function("setServerUrl") { url: String -> 126 177 rb_set_server_url(url) 127 178 } 179 + Function("setHttpUrl") { url: String -> 180 + rb_set_http_url(url) 181 + } 182 + AsyncFunction("getDevices") { 183 + parseJsonOrThrow(rb_get_devices_json(), "getDevices") 184 + } 185 + AsyncFunction("connectDevice") { id: String -> 186 + if (rb_connect_device(id) != 0) throw RpcError("connectDevice") 187 + } 188 + AsyncFunction("disconnectDevice") { id: String -> 189 + if (rb_disconnect_device(id) != 0) throw RpcError("disconnectDevice") 190 + } 128 191 129 192 AsyncFunction("ping") { 130 193 rb_ping() == 0 ··· 208 271 AsyncFunction("getArtists") { 209 272 parseJsonOrThrow(rb_get_artists_json(), "getArtists") 210 273 } 274 + AsyncFunction("getAlbums") { 275 + parseJsonOrThrow(rb_get_albums_json(), "getAlbums") 276 + } 277 + AsyncFunction("getLikedAlbums") { 278 + parseJsonOrThrow(rb_get_liked_albums_json(), "getLikedAlbums") 279 + } 280 + AsyncFunction("getArtist") { id: String -> 281 + parseJsonOrThrow(rb_get_artist_json(id), "getArtist") 282 + } 211 283 AsyncFunction("getAlbum") { id: String -> 212 284 parseJsonOrThrow(rb_get_album_json(id), "getAlbum") 213 285 } ··· 281 353 } 282 354 283 355 AsyncFunction("bluetoothAvailable") { rb_bluetooth_available() == 1 } 356 + AsyncFunction("scanBluetooth") { 357 + if (rb_scan_bluetooth() != 0) throw RpcError("scanBluetooth") 358 + } 284 359 AsyncFunction("getBluetoothDevices") { 285 360 parseJsonOrThrow(rb_get_bluetooth_devices_json(), "getBluetoothDevices") 286 361 } ··· 313 388 id 314 389 } 315 390 Function("subscribeDiscovery") { serviceName: String -> 391 + acquireMulticastLock() 316 392 val id = rb_subscribe_discovery(serviceName) 317 - startPollLoop(id, "rockbox.discovery") 393 + startPollLoop(id, "rockbox.discovery", isDiscovery = true) 318 394 id 319 395 } 320 396 Function("unsubscribe") { subId: Int -> 321 - pollJobs.remove(subId)?.cancel() 397 + val tag = pollJobs.remove(subId) 398 + tag?.cancel() 399 + // If this was a discovery subscription, drop our multicast hold. 400 + if (discoveryJobs.remove(subId)) releaseMulticastLock() 322 401 rb_unsubscribe(subId) 323 402 } 324 403 325 404 OnDestroy { 326 405 pollJobs.values.forEach { it.cancel() } 327 406 pollJobs.clear() 407 + // Drop every outstanding discovery hold so we don't leak the lock 408 + // beyond the module's lifetime. 409 + while (discoveryJobs.isNotEmpty()) { 410 + discoveryJobs.iterator().next().let { discoveryJobs.remove(it) } 411 + releaseMulticastLock() 412 + } 328 413 scope.cancel() 329 414 } 330 415 } 331 416 332 - private fun startPollLoop(subId: Int, eventName: String) { 417 + private fun startPollLoop( 418 + subId: Int, 419 + eventName: String, 420 + isDiscovery: Boolean = false, 421 + ) { 422 + if (isDiscovery) discoveryJobs.add(subId) 333 423 val job = scope.launch { 334 424 while (isActive) { 335 425 val raw = rb_poll_event(subId, 1_000) ?: continue ··· 343 433 )) 344 434 return@launch 345 435 } 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) 436 + // Recurse so nested JSONArray / JSONObject instances bridge as 437 + // proper JS arrays / objects (otherwise `addresses.find(...)` etc. 438 + // blows up on the JS side). 439 + sendEvent(eventName, jsonObjectToMap(obj)) 353 440 } catch (e: Exception) { 354 441 // Bad JSON — skip and keep polling. 355 442 }
+36
expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift
··· 4 4 // C symbols exported by the rockbox_expo Rust crate (built from 5 5 // `crates/expo/`). The static library is linked via the .podspec. 6 6 @_silgen_name("rb_set_server_url") private func rb_set_server_url(_ url: UnsafePointer<CChar>) -> Int32 7 + @_silgen_name("rb_set_http_url") private func rb_set_http_url(_ url: UnsafePointer<CChar>) -> Int32 7 8 @_silgen_name("rb_ping") private func rb_ping() -> Int32 9 + 10 + @_silgen_name("rb_get_devices_json") private func rb_get_devices_json() -> UnsafeMutablePointer<CChar>? 11 + @_silgen_name("rb_connect_device") private func rb_connect_device(_ id: UnsafePointer<CChar>) -> Int32 12 + @_silgen_name("rb_disconnect_device") private func rb_disconnect_device(_ id: UnsafePointer<CChar>) -> Int32 8 13 @_silgen_name("rb_play") private func rb_play() -> Int32 9 14 @_silgen_name("rb_pause") private func rb_pause() -> Int32 10 15 @_silgen_name("rb_play_pause") private func rb_play_pause() -> Int32 ··· 46 51 47 52 @_silgen_name("rb_get_tracks_json") private func rb_get_tracks_json() -> UnsafeMutablePointer<CChar>? 48 53 @_silgen_name("rb_get_artists_json") private func rb_get_artists_json() -> UnsafeMutablePointer<CChar>? 54 + @_silgen_name("rb_get_albums_json") private func rb_get_albums_json() -> UnsafeMutablePointer<CChar>? 55 + @_silgen_name("rb_get_liked_albums_json") private func rb_get_liked_albums_json() -> UnsafeMutablePointer<CChar>? 56 + @_silgen_name("rb_get_artist_json") private func rb_get_artist_json(_ id: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 49 57 @_silgen_name("rb_get_album_json") private func rb_get_album_json(_ id: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 50 58 @_silgen_name("rb_get_liked_tracks_json") private func rb_get_liked_tracks_json() -> UnsafeMutablePointer<CChar>? 51 59 @_silgen_name("rb_search_json") private func rb_search_json(_ term: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? ··· 74 82 @_silgen_name("rb_play_smart_playlist") private func rb_play_smart_playlist(_ id: UnsafePointer<CChar>) -> Int32 75 83 76 84 @_silgen_name("rb_bluetooth_available") private func rb_bluetooth_available() -> Int32 85 + @_silgen_name("rb_scan_bluetooth") private func rb_scan_bluetooth() -> Int32 77 86 @_silgen_name("rb_get_bluetooth_devices_json") private func rb_get_bluetooth_devices_json() -> UnsafeMutablePointer<CChar>? 78 87 @_silgen_name("rb_connect_bluetooth") private func rb_connect_bluetooth(_ a: UnsafePointer<CChar>) -> Int32 79 88 @_silgen_name("rb_disconnect_bluetooth") private func rb_disconnect_bluetooth(_ a: UnsafePointer<CChar>) -> Int32 ··· 123 132 Function("setServerUrl") { (url: String) in 124 133 url.withCString { _ = rb_set_server_url($0) } 125 134 } 135 + Function("setHttpUrl") { (url: String) in 136 + url.withCString { _ = rb_set_http_url($0) } 137 + } 138 + AsyncFunction("getDevices") { () -> Any in 139 + try self.parseJsonOrThrow(takeString(rb_get_devices_json()), op: "getDevices") 140 + } 141 + AsyncFunction("connectDevice") { (id: String) -> Void in 142 + let rc = id.withCString { rb_connect_device($0) } 143 + if rc != 0 { throw self.playbackError("connectDevice") } 144 + } 145 + AsyncFunction("disconnectDevice") { (id: String) -> Void in 146 + let rc = id.withCString { rb_disconnect_device($0) } 147 + if rc != 0 { throw self.playbackError("disconnectDevice") } 148 + } 126 149 127 150 AsyncFunction("ping") { () -> Bool in 128 151 return rb_ping() == 0 ··· 222 245 AsyncFunction("getArtists") { () -> Any in 223 246 try self.parseJsonOrThrow(takeString(rb_get_artists_json()), op: "getArtists") 224 247 } 248 + AsyncFunction("getAlbums") { () -> Any in 249 + try self.parseJsonOrThrow(takeString(rb_get_albums_json()), op: "getAlbums") 250 + } 251 + AsyncFunction("getLikedAlbums") { () -> Any in 252 + try self.parseJsonOrThrow(takeString(rb_get_liked_albums_json()), op: "getLikedAlbums") 253 + } 254 + AsyncFunction("getArtist") { (id: String) -> Any in 255 + let raw = id.withCString { rb_get_artist_json($0) } 256 + return try self.parseJsonOrThrow(takeString(raw), op: "getArtist") 257 + } 225 258 AsyncFunction("getAlbum") { (id: String) -> Any in 226 259 let raw = id.withCString { rb_get_album_json($0) } 227 260 return try self.parseJsonOrThrow(takeString(raw), op: "getAlbum") ··· 332 365 333 366 AsyncFunction("bluetoothAvailable") { () -> Bool in 334 367 return rb_bluetooth_available() == 1 368 + } 369 + AsyncFunction("scanBluetooth") { () -> Void in 370 + if rb_scan_bluetooth() != 0 { throw self.playbackError("scanBluetooth") } 335 371 } 336 372 AsyncFunction("getBluetoothDevices") { () -> Any in 337 373 try self.parseJsonOrThrow(takeString(rb_get_bluetooth_devices_json()), op: "getBluetoothDevices")
+11
expo/modules/rockbox-rpc/src/index.ts
··· 73 73 type RockboxRpcNative = { 74 74 /** Configure the gRPC server URL. Call once at app startup. */ 75 75 setServerUrl(url: string): void; 76 + /** Configure the rockboxd HTTP base URL (used by `getDevices` etc.). */ 77 + setHttpUrl(url: string): void; 76 78 /** Round-trip Status RPC; resolves with `true` if the server replied. */ 77 79 ping(): Promise<boolean>; 78 80 ··· 119 121 // Library 120 122 getTracks(): Promise<Json>; 121 123 getArtists(): Promise<Json>; 124 + getAlbums(): Promise<Json>; 125 + getLikedAlbums(): Promise<Json>; 126 + getArtist(id: string): Promise<Json>; 122 127 getAlbum(id: string): Promise<Json>; 123 128 getLikedTracks(): Promise<Json>; 124 129 search(term: string): Promise<Json>; ··· 161 166 162 167 // Bluetooth 163 168 bluetoothAvailable(): Promise<boolean>; 169 + scanBluetooth(): Promise<void>; 164 170 getBluetoothDevices(): Promise<Json>; 165 171 connectBluetooth(address: string): Promise<void>; 166 172 disconnectBluetooth(address: string): Promise<void>; 173 + 174 + // Cast / AirPlay output devices (HTTP REST under the hood). 175 + getDevices(): Promise<Json>; 176 + connectDevice(id: string): Promise<void>; 177 + disconnectDevice(id: string): Promise<void>; 167 178 168 179 // Streaming subscriptions — return an opaque numeric subscription id. 169 180 // Pair with `unsubscribe(id)` to tear down. Events fire on the registered
+5
expo/package.json
··· 15 15 }, 16 16 "dependencies": { 17 17 "@expo/vector-icons": "^15.0.3", 18 + "@react-native-async-storage/async-storage": "2.2.0", 18 19 "@react-navigation/bottom-tabs": "^7.4.0", 19 20 "@react-navigation/elements": "^2.6.3", 20 21 "@react-navigation/native": "^7.1.8", 22 + "@tabler/icons-react-native": "^3.41.1", 23 + "@tanstack/react-query": "^5.100.8", 21 24 "expo": "~54.0.33", 22 25 "expo-blur": "^55.0.14", 23 26 "expo-constants": "~18.0.13", ··· 32 35 "expo-symbols": "~1.0.8", 33 36 "expo-system-ui": "~6.0.9", 34 37 "expo-web-browser": "~15.0.10", 38 + "jotai": "^2.19.1", 35 39 "nativewind": "^4.2.3", 36 40 "react": "19.1.0", 37 41 "react-dom": "19.1.0", ··· 40 44 "react-native-reanimated": "~4.1.1", 41 45 "react-native-safe-area-context": "~5.6.0", 42 46 "react-native-screens": "~4.16.0", 47 + "react-native-svg": "15.12.1", 43 48 "react-native-web": "~0.21.0", 44 49 "react-native-worklets": "0.5.1", 45 50 "rockbox-rpc": "file:./modules/rockbox-rpc"
+9
expo/tailwind.config.js
··· 37 37 fontFamily: { 38 38 sans: ["SpaceGrotesk"], 39 39 mono: ["JetBrainsMono"], 40 + // RockfordSans — used for titles, headings, and bottom-bar labels. 41 + // Each weight is a distinct family because RN doesn't auto-pick 42 + // weights from a single family on Android. The weights here lean 43 + // slightly lighter than the typographic name suggests so headings 44 + // don't feel chunky. 45 + "display-light": ["RockfordSans-Regular"], 46 + display: ["RockfordSans-Medium"], 47 + "display-medium": ["RockfordSans-Regular"], 48 + "display-extra": ["RockfordSans-Bold"], 40 49 }, 41 50 }, 42 51 },