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 server discovery and selection UI

Implement mDNS-based discovery and runtime server switching across the
GUI and macOS app. Add gpui/server, ServerInfo/ServerManager (macOS),
scan_mdns() and discovery registration in the daemon, plus a Server
Picker UI and notification to re-run one-shot syncs on server switch.
Also improve Typesense health-check logging and messages.

+1348 -667
+65 -5
crates/cli/src/lib.rs
··· 34 34 let port = std::env::var("RB_TYPESENSE_PORT").unwrap_or_else(|_| "8109".to_string()); 35 35 let url = format!("http://localhost:{}/health", port); 36 36 let client = reqwest::Client::new(); 37 + info!( 38 + "Waiting for Typesense to accept connections on port {}...", 39 + port 40 + ); 37 41 for attempt in 1..=30 { 38 42 match client.get(&url).send().await { 39 43 Ok(r) if r.status().is_success() => { 40 - info!("Typesense ready after {} attempt(s)", attempt); 44 + info!("Typesense is ready (took {} attempt(s))", attempt); 41 45 return; 42 46 } 43 - _ => { 47 + Ok(r) => { 48 + tracing::debug!( 49 + "Typesense health check attempt {}: HTTP {}", 50 + attempt, 51 + r.status() 52 + ); 53 + tokio::time::sleep(Duration::from_secs(1)).await; 54 + } 55 + Err(e) => { 56 + tracing::debug!("Typesense health check attempt {}: {}", attempt, e); 44 57 tokio::time::sleep(Duration::from_secs(1)).await; 45 58 } 46 59 } 47 60 } 48 - warn!("Typesense did not become ready in time; proceeding anyway"); 61 + warn!("Typesense did not become ready within 30s; proceeding anyway"); 49 62 } 50 63 51 64 /// SIGTERM/SIGINT handler: kill the typesense child then _exit immediately. ··· 148 161 let path = rockbox_settings::get_music_dir().unwrap_or(format!("{}/Music", home)); 149 162 let rt = tokio::runtime::Runtime::new().unwrap(); 150 163 rt.block_on(async { 164 + info!("Setting up Typesense search engine..."); 151 165 rockbox_typesense::setup()?; 152 166 153 167 // Wait for Typesense to accept connections before any HTTP calls. ··· 155 169 // may not be listening yet when we reach the first collection call. 156 170 wait_for_typesense().await; 157 171 172 + info!("Connecting to library database..."); 158 173 let pool = create_connection_pool().await?; 159 174 let tracks = repo::track::all(pool.clone()).await?; 160 175 if tracks.is_empty() || update_library { 176 + if tracks.is_empty() { 177 + info!( 178 + "Library is empty — starting first-time audio scan of: {}", 179 + path 180 + ); 181 + } else { 182 + info!( 183 + "ROCKBOX_UPDATE_LIBRARY set — rescanning audio library at: {}", 184 + path 185 + ); 186 + } 161 187 match scan_audio_files(pool.clone(), path.into()).await { 162 - Ok(_) => info!("Finished scanning audio files"), 188 + Ok(_) => info!("Audio scan complete"), 163 189 Err(e) => error!("Failed to scan audio files: {}", e), 164 190 } 165 191 let tracks = repo::track::all(pool.clone()).await?; 166 192 let albums = repo::album::all(pool.clone()).await?; 167 193 let artists = repo::artist::all(pool.clone()).await?; 168 194 195 + info!( 196 + "Indexing {} tracks, {} albums, {} artists into Typesense...", 197 + tracks.len(), 198 + albums.len(), 199 + artists.len() 200 + ); 201 + 202 + info!("Creating Typesense collections..."); 169 203 create_tracks_collection().await?; 170 204 create_albums_collection().await?; 171 205 create_artists_collection().await?; 172 206 207 + info!("Inserting {} tracks...", tracks.len()); 173 208 insert_tracks(tracks.into_iter().map(Track::from).collect()).await?; 209 + info!("Inserting {} artists...", artists.len()); 174 210 insert_artists(artists.into_iter().map(Artist::from).collect()).await?; 211 + info!("Inserting {} albums...", albums.len()); 175 212 insert_albums(albums.into_iter().map(Album::from).collect()).await?; 213 + 214 + info!("Search index build complete."); 215 + } else { 216 + info!( 217 + "Library already indexed ({} tracks); skipping scan.", 218 + tracks.len() 219 + ); 176 220 } 177 221 222 + info!("Setting up playlists collection..."); 178 223 create_playlists_collection().await?; 179 224 let playlist_store = PlaylistStore::new(pool.clone()); 180 225 let saved = playlist_store.list().await.unwrap_or_default(); ··· 202 247 })) 203 248 .collect(); 204 249 if !ts_playlists.is_empty() { 250 + info!( 251 + "Indexing {} playlist(s) into Typesense...", 252 + ts_playlists.len() 253 + ); 205 254 insert_playlists(ts_playlists).await?; 255 + info!("Playlist index complete."); 256 + } else { 257 + info!("No playlists to index."); 206 258 } 207 259 Ok::<(), Error>(()) 208 260 }) ··· 294 346 }); 295 347 } 296 348 349 + info!( 350 + "Starting typesense-server (binary: {}, port: {}, data: {})", 351 + ts_bin.display(), 352 + port, 353 + data_dir.display() 354 + ); 297 355 let mut child = cmd.spawn()?; 298 - TYPESENSE_PID.store(child.id() as i32, Ordering::SeqCst); 356 + let pid = child.id(); 357 + TYPESENSE_PID.store(pid as i32, Ordering::SeqCst); 358 + info!("typesense-server started with PID {}", pid); 299 359 300 360 if let Some(stdout) = child.stdout.take() { 301 361 thread::spawn(move || {
+2 -2
crates/discovery/src/lib.rs
··· 43 43 let mpd_service = format!("mpd-{}", device_id); 44 44 45 45 thread::spawn(move || { 46 - let http_port = env::var("ROCKBOX_HTTP_PORT").unwrap_or("6061".to_string()); 46 + let grpc_port = env::var("ROCKBOX_PORT").unwrap_or("6061".to_string()); 47 47 let graphql_port = env::var("ROCKBOX_GRAPHQL_PORT").unwrap_or("6062".to_string()); 48 - let grpc_port = env::var("ROCKBOX_PORT").unwrap_or("6063".to_string()); 48 + let http_port = env::var("ROCKBOX_TCP_PORT").unwrap_or("6063".to_string()); 49 49 let mpd_port = env::var("ROCKBOX_MPD_PORT").unwrap_or("6600".to_string()); 50 50 let mut responder = MdnsResponder::new(); 51 51 responder.register_service(&http_service, http_port.parse::<u16>().unwrap());
+2
crates/server/src/lib.rs
··· 298 298 // Wait for the rpc server to start 299 299 thread::sleep(std::time::Duration::from_millis(500)); 300 300 301 + rockbox_discovery::register_services(); 302 + 301 303 #[cfg(target_os = "linux")] 302 304 { 303 305 use rockbox_mpris::MprisServer;
+41 -7
crates/typesense/src/lib.rs
··· 2 2 fs, 3 3 process::{Command, Stdio}, 4 4 }; 5 - use tracing::info; 5 + use tracing::{info, warn}; 6 6 7 7 pub mod client; 8 8 pub mod types; ··· 17 17 homedir.display(), 18 18 ".rockbox/bin" 19 19 ); 20 + 21 + info!("Checking for typesense-server binary (PATH includes ~/.rockbox/bin)..."); 20 22 let mut cmd = Command::new("sh") 21 23 .arg("-c") 22 24 .arg("command -v typesense-server") ··· 26 28 .spawn()?; 27 29 28 30 let data_dir = homedir.join(".config/rockbox.org/typesense"); 31 + info!("Ensuring Typesense data directory: {}", data_dir.display()); 29 32 fs::create_dir_all(&data_dir)?; 30 33 31 34 if !data_dir.join("api-key").exists() { 32 35 let api_key = uuid::Uuid::new_v4().to_string(); 33 36 fs::write(data_dir.join("api-key"), &api_key)?; 34 - info!("Generated new Typesense API key: {}", api_key); 37 + info!( 38 + "Generated new Typesense API key (saved to {})", 39 + data_dir.join("api-key").display() 40 + ); 35 41 if std::env::var("RB_TYPESENSE_API_KEY").is_err() { 36 42 std::env::set_var("RB_TYPESENSE_API_KEY", &api_key); 37 43 } 38 44 } else { 39 45 let api_key = fs::read_to_string(data_dir.join("api-key"))?; 40 - info!("Using existing Typesense API key: {}", api_key); 46 + info!("Loaded existing Typesense API key from disk"); 41 47 if std::env::var("RB_TYPESENSE_API_KEY").is_err() { 42 48 std::env::set_var("RB_TYPESENSE_API_KEY", &api_key); 43 49 } 44 50 } 45 51 46 52 if cmd.wait()?.success() { 47 - info!("Typesense server is already installed and available in PATH."); 53 + info!("typesense-server already installed; skipping download."); 48 54 return Ok(()); 49 55 } 50 56 ··· 70 76 ); 71 77 let filename = format!("typesense-server-{version}-{os}-{arch}.tar.gz"); 72 78 73 - Command::new("curl") 79 + info!( 80 + "typesense-server not found. Downloading v{} for {}/{}...", 81 + version, os, arch 82 + ); 83 + info!("Download URL: {}", url); 84 + 85 + let status = Command::new("curl") 74 86 .arg("-L") 87 + .arg("--progress-bar") 75 88 .arg(&url) 76 89 .arg("-o") 77 90 .arg(&filename) ··· 79 92 .stderr(Stdio::inherit()) 80 93 .status()?; 81 94 82 - Command::new("tar") 95 + if !status.success() { 96 + return Err(anyhow::anyhow!("curl exited with {}", status)); 97 + } 98 + info!("Download complete: {}", filename); 99 + 100 + info!("Extracting {}...", filename); 101 + let status = Command::new("tar") 83 102 .arg("xzf") 84 103 .arg(&filename) 85 104 .stdout(Stdio::inherit()) 86 105 .stderr(Stdio::inherit()) 87 106 .status()?; 88 107 89 - Command::new("sh") 108 + if !status.success() { 109 + return Err(anyhow::anyhow!("tar exited with {}", status)); 110 + } 111 + info!("Extraction complete."); 112 + 113 + info!("Installing typesense-server to ~/.rockbox/bin/..."); 114 + let status = Command::new("sh") 90 115 .arg("-c") 91 116 .arg("mkdir -p ~/.rockbox/bin && cp typesense-server ~/.rockbox/bin && chmod +x ~/.rockbox/bin/typesense-server && rm -f typesense-server typesense-server-*.tar.gz typesense-server.md5.txt") 92 117 .stdout(Stdio::inherit()) 93 118 .stderr(Stdio::inherit()) 94 119 .status()?; 120 + 121 + if !status.success() { 122 + warn!( 123 + "Install script exited with {}; binary may not be in place", 124 + status 125 + ); 126 + } else { 127 + info!("typesense-server installed to ~/.rockbox/bin/typesense-server"); 128 + } 95 129 96 130 Ok(()) 97 131 }
+65 -3
gpui/Cargo.lock
··· 12 12 "gpui", 13 13 "http", 14 14 "log", 15 + "mdns-sd", 15 16 "prost", 16 17 "reqwest", 17 18 "rust-embed", ··· 350 351 "futures-io", 351 352 "futures-lite 2.6.1", 352 353 "parking", 353 - "polling", 354 + "polling 3.11.0", 354 355 "rustix 1.1.4", 355 356 "slab", 356 357 "windows-sys 0.61.2", ··· 852 853 dependencies = [ 853 854 "bitflags 2.11.1", 854 855 "log", 855 - "polling", 856 + "polling 3.11.0", 856 857 "rustix 0.38.44", 857 858 "slab", 858 859 "thiserror 1.0.69", ··· 1849 1850 version = "1.0.0" 1850 1851 source = "registry+https://github.com/rust-lang/crates.io-index" 1851 1852 checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" 1853 + 1854 + [[package]] 1855 + name = "flume" 1856 + version = "0.10.14" 1857 + source = "registry+https://github.com/rust-lang/crates.io-index" 1858 + checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" 1859 + dependencies = [ 1860 + "futures-core", 1861 + "futures-sink", 1862 + "pin-project", 1863 + "spin", 1864 + ] 1852 1865 1853 1866 [[package]] 1854 1867 name = "flume" ··· 2283 2296 "embed-resource", 2284 2297 "etagere", 2285 2298 "filedescriptor", 2286 - "flume", 2299 + "flume 0.11.1", 2287 2300 "foreign-types 0.5.0", 2288 2301 "futures", 2289 2302 "gpui-macros", ··· 2872 2885 ] 2873 2886 2874 2887 [[package]] 2888 + name = "if-addrs" 2889 + version = "0.7.0" 2890 + source = "registry+https://github.com/rust-lang/crates.io-index" 2891 + checksum = "cbc0fa01ffc752e9dbc72818cdb072cd028b86be5e09dd04c5a643704fe101a9" 2892 + dependencies = [ 2893 + "libc", 2894 + "winapi", 2895 + ] 2896 + 2897 + [[package]] 2875 2898 name = "image" 2876 2899 version = "0.25.10" 2877 2900 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3366 3389 ] 3367 3390 3368 3391 [[package]] 3392 + name = "mdns-sd" 3393 + version = "0.5.10" 3394 + source = "registry+https://github.com/rust-lang/crates.io-index" 3395 + checksum = "709cba29c9d7334db28706bc2767db2531934fd2e55781cba82c930fb7f22b47" 3396 + dependencies = [ 3397 + "flume 0.10.14", 3398 + "if-addrs", 3399 + "log", 3400 + "polling 2.8.0", 3401 + "socket2 0.4.10", 3402 + ] 3403 + 3404 + [[package]] 3369 3405 name = "memchr" 3370 3406 version = "2.8.0" 3371 3407 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4081 4117 "fdeflate", 4082 4118 "flate2", 4083 4119 "miniz_oxide", 4120 + ] 4121 + 4122 + [[package]] 4123 + name = "polling" 4124 + version = "2.8.0" 4125 + source = "registry+https://github.com/rust-lang/crates.io-index" 4126 + checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" 4127 + dependencies = [ 4128 + "autocfg", 4129 + "bitflags 1.3.2", 4130 + "cfg-if", 4131 + "concurrent-queue", 4132 + "libc", 4133 + "log", 4134 + "pin-project-lite", 4135 + "windows-sys 0.48.0", 4084 4136 ] 4085 4137 4086 4138 [[package]] ··· 5269 5321 version = "0.2.2" 5270 5322 source = "registry+https://github.com/rust-lang/crates.io-index" 5271 5323 checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" 5324 + 5325 + [[package]] 5326 + name = "socket2" 5327 + version = "0.4.10" 5328 + source = "registry+https://github.com/rust-lang/crates.io-index" 5329 + checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" 5330 + dependencies = [ 5331 + "libc", 5332 + "winapi", 5333 + ] 5272 5334 5273 5335 [[package]] 5274 5336 name = "socket2"
+1
gpui/Cargo.toml
··· 27 27 tonic-web = "0.12.3" 28 28 http = "1.4.0" 29 29 souvlaki = "0.8" 30 + mdns-sd = "0.5.9" 30 31 31 32 [build-dependencies] 32 33 tonic-build = "0.12.3"
+2
gpui/src/app.rs
··· 23 23 .with_assets(assets.clone()) 24 24 .run(move |cx| { 25 25 cx.set_global(crate::state::TokioHandle(tokio_handle)); 26 + cx.set_global(crate::ui::components::ServerPickerOpen(false)); 27 + cx.set_global(crate::ui::components::DiscoveredServers::default()); 26 28 let bounds = Bounds::centered(None, size(px(1280.0), px(760.0)), cx); 27 29 assets.load_fonts(cx).expect("failed to load fonts"); 28 30 // Theme is set as a global inside StartupGate / Rockbox::new.
+90 -70
gpui/src/client.rs
··· 24 24 use anyhow::Result; 25 25 use tokio::sync::mpsc::Sender; 26 26 27 - const URL: &str = "http://127.0.0.1:6061"; 28 - const HTTP_URL: &str = "http://127.0.0.1:6063"; 27 + fn url() -> String { 28 + crate::server::get_grpc_url() 29 + } 30 + fn http_url() -> String { 31 + crate::server::get_http_url() 32 + } 29 33 30 34 // ── Library ─────────────────────────────────────────────────────────────────── 31 35 32 36 pub async fn fetch_tracks() -> Result<Vec<Track>> { 33 - let mut c = LibraryServiceClient::connect(URL).await?; 37 + let mut c = LibraryServiceClient::connect(url()).await?; 34 38 let resp = c.get_tracks(GetTracksRequest {}).await?; 35 39 Ok(resp 36 40 .into_inner() ··· 43 47 pub async fn get_album( 44 48 id: &str, 45 49 ) -> Result<(String, Option<String>)> { 46 - let mut c = LibraryServiceClient::connect(URL).await?; 50 + let mut c = LibraryServiceClient::connect(url()).await?; 47 51 let resp = c.get_album(GetAlbumRequest { id: id.to_string() }).await?; 48 52 let album = resp.into_inner().album; 49 53 Ok(album ··· 74 78 // ── Playback control ────────────────────────────────────────────────────────── 75 79 76 80 pub async fn resume() -> Result<()> { 77 - let mut c = PlaybackServiceClient::connect(URL).await?; 81 + let mut c = PlaybackServiceClient::connect(url()).await?; 78 82 c.resume(ResumeRequest {}).await?; 79 83 Ok(()) 80 84 } 81 85 82 86 // Resume from saved state after a daemon restart (playlist_resume + resume_track). 83 87 pub async fn resume_track() -> Result<()> { 84 - let mut c = PlaylistServiceClient::connect(URL).await?; 88 + let mut c = PlaylistServiceClient::connect(url()).await?; 85 89 c.resume_track(ResumeTrackRequest { 86 90 start_index: 0, 87 91 crc: 0, ··· 93 97 } 94 98 95 99 pub async fn pause() -> Result<()> { 96 - let mut c = PlaybackServiceClient::connect(URL).await?; 100 + let mut c = PlaybackServiceClient::connect(url()).await?; 97 101 c.pause(PauseRequest {}).await?; 98 102 Ok(()) 99 103 } 100 104 101 105 /// Seek to `new_time_ms` milliseconds from the start of the current track. 102 106 pub async fn seek(new_time_ms: i32) -> Result<()> { 103 - let mut c = PlaybackServiceClient::connect(URL).await?; 107 + let mut c = PlaybackServiceClient::connect(url()).await?; 104 108 c.fast_forward_rewind(FastForwardRewindRequest { 105 109 new_time: new_time_ms, 106 110 }) ··· 109 113 } 110 114 111 115 pub async fn next() -> Result<()> { 112 - let mut c = PlaybackServiceClient::connect(URL).await?; 116 + let mut c = PlaybackServiceClient::connect(url()).await?; 113 117 c.next(NextRequest {}).await?; 114 118 Ok(()) 115 119 } 116 120 117 121 pub async fn prev() -> Result<()> { 118 - let mut c = PlaybackServiceClient::connect(URL).await?; 122 + let mut c = PlaybackServiceClient::connect(url()).await?; 119 123 c.previous(PreviousRequest {}).await?; 120 124 Ok(()) 121 125 } 122 126 123 127 pub async fn play_track(path: String) -> Result<()> { 124 - let mut c = PlaybackServiceClient::connect(URL).await?; 128 + let mut c = PlaybackServiceClient::connect(url()).await?; 125 129 c.play_track(PlayTrackRequest { path }).await?; 126 130 Ok(()) 127 131 } 128 132 129 133 pub async fn play_album(album_id: String, shuffle: bool) -> Result<()> { 130 - let mut c = PlaybackServiceClient::connect(URL).await?; 134 + let mut c = PlaybackServiceClient::connect(url()).await?; 131 135 c.play_album(PlayAlbumRequest { 132 136 album_id, 133 137 shuffle: Some(shuffle), ··· 138 142 } 139 143 140 144 pub async fn play_artist_tracks(artist_id: String, shuffle: bool) -> Result<()> { 141 - let mut c = PlaybackServiceClient::connect(URL).await?; 145 + let mut c = PlaybackServiceClient::connect(url()).await?; 142 146 c.play_artist_tracks(PlayArtistTracksRequest { 143 147 artist_id, 144 148 shuffle: Some(shuffle), ··· 149 153 } 150 154 151 155 pub async fn play_all_tracks() -> Result<()> { 152 - let mut c = PlaybackServiceClient::connect(URL).await?; 156 + let mut c = PlaybackServiceClient::connect(url()).await?; 153 157 c.play_all_tracks(PlayAllTracksRequest { 154 158 shuffle: Some(false), 155 159 position: Some(0), ··· 161 165 // ── Queue / Playlist ────────────────────────────────────────────────────────── 162 166 163 167 pub async fn jump_to_queue_position(pos: i32) -> Result<()> { 164 - let mut c = PlaylistServiceClient::connect(URL).await?; 168 + let mut c = PlaylistServiceClient::connect(url()).await?; 165 169 c.start(StartRequest { 166 170 start_index: Some(pos), 167 171 elapsed: Some(0), ··· 172 176 } 173 177 174 178 pub async fn insert_track_next(path: String) -> Result<()> { 175 - let mut c = PlaylistServiceClient::connect(URL).await?; 179 + let mut c = PlaylistServiceClient::connect(url()).await?; 176 180 c.insert_tracks(InsertTracksRequest { 177 181 playlist_id: None, 178 182 position: INSERT_FIRST, ··· 184 188 } 185 189 186 190 pub async fn insert_track_last(path: String) -> Result<()> { 187 - let mut c = PlaylistServiceClient::connect(URL).await?; 191 + let mut c = PlaylistServiceClient::connect(url()).await?; 188 192 c.insert_tracks(InsertTracksRequest { 189 193 playlist_id: None, 190 194 position: INSERT_LAST, ··· 196 200 } 197 201 198 202 pub async fn insert_tracks(paths: Vec<String>, position: i32, shuffle: bool) -> Result<()> { 199 - let mut c = PlaylistServiceClient::connect(URL).await?; 203 + let mut c = PlaylistServiceClient::connect(url()).await?; 200 204 c.insert_tracks(InsertTracksRequest { 201 205 playlist_id: None, 202 206 position, ··· 208 212 } 209 213 210 214 pub async fn search(term: String) -> Result<SearchResults> { 211 - let mut c = LibraryServiceClient::connect(URL).await?; 215 + let mut c = LibraryServiceClient::connect(url()).await?; 212 216 let resp = c.search(SearchRequest { term }).await?; 213 217 let resp = resp.into_inner(); 214 218 let tracks = resp.tracks.into_iter().map(track_from_proto).collect(); ··· 254 258 } 255 259 256 260 pub async fn fetch_queue(tx: Sender<StateUpdate>) { 257 - match PlaylistServiceClient::connect(URL).await { 261 + match PlaylistServiceClient::connect(url()).await { 258 262 Ok(mut c) => match c.get_current(GetCurrentRequest {}).await { 259 263 Ok(resp) => { 260 264 let resp = resp.into_inner(); ··· 293 297 } 294 298 295 299 pub async fn play_liked_tracks(paths: Vec<String>, shuffle: bool) -> Result<()> { 296 - let mut c = PlaylistServiceClient::connect(URL).await?; 300 + let mut c = PlaylistServiceClient::connect(url()).await?; 297 301 c.insert_tracks(InsertTracksRequest { 298 302 playlist_id: None, 299 303 position: 0, ··· 317 321 // ── Queue mutation ──────────────────────────────────────────────────────────── 318 322 319 323 pub async fn remove_from_queue(position: i32) -> Result<()> { 320 - let mut c = PlaylistServiceClient::connect(URL).await?; 324 + let mut c = PlaylistServiceClient::connect(url()).await?; 321 325 c.remove_tracks(RemoveTracksRequest { 322 326 positions: vec![position], 323 327 }) ··· 328 332 // ── Sound / Volume ──────────────────────────────────────────────────────────── 329 333 330 334 pub async fn adjust_volume(steps: i32) -> Result<()> { 331 - let mut c = SoundServiceClient::connect(URL).await?; 335 + let mut c = SoundServiceClient::connect(url()).await?; 332 336 c.adjust_volume(AdjustVolumeRequest { steps }).await?; 333 337 Ok(()) 334 338 } 335 339 336 340 pub async fn get_current_volume() -> Result<i32> { 337 341 const SOUND_VOLUME: i32 = 0; 338 - let mut c = SoundServiceClient::connect(URL).await?; 342 + let mut c = SoundServiceClient::connect(url()).await?; 339 343 let resp = c 340 344 .sound_current(SoundCurrentRequest { 341 345 setting: SOUND_VOLUME, ··· 347 351 // ── Settings (shuffle, repeat) ──────────────────────────────────────────────── 348 352 349 353 pub async fn save_shuffle(enabled: bool) -> Result<()> { 350 - let mut c = SettingsServiceClient::connect(URL).await?; 354 + let mut c = SettingsServiceClient::connect(url()).await?; 351 355 c.save_settings(SaveSettingsRequest { 352 356 playlist_shuffle: Some(enabled), 353 357 ..Default::default() ··· 357 361 } 358 362 359 363 pub async fn save_repeat(repeat_mode: i32) -> Result<()> { 360 - let mut c = SettingsServiceClient::connect(URL).await?; 364 + let mut c = SettingsServiceClient::connect(url()).await?; 361 365 c.save_settings(SaveSettingsRequest { 362 366 repeat_mode: Some(repeat_mode), 363 367 ..Default::default() ··· 369 373 // Fetch the saved resume position on startup so the progress bar shows the 370 374 // right value before the user presses play. 371 375 pub async fn run_resume_info_sync(tx: Sender<StateUpdate>) { 372 - match SystemServiceClient::connect(URL).await { 376 + match SystemServiceClient::connect(url()).await { 373 377 Ok(mut c) => match c.get_global_status(GetGlobalStatusRequest {}).await { 374 378 Ok(resp) => { 375 379 let s = resp.into_inner(); ··· 386 390 387 391 // The status stream only fires on changes; fetch the initial status once so 388 392 // the Now Playing widget shows correctly when the app opens with a paused track. 389 - match PlaybackServiceClient::connect(URL).await { 393 + match PlaybackServiceClient::connect(url()).await { 390 394 Ok(mut c) => match c.status(StatusRequest {}).await { 391 395 Ok(resp) => { 392 396 let s = resp.into_inner(); ··· 408 412 pub async fn run_settings_sync(tx: Sender<StateUpdate>) { 409 413 let live_volume = get_current_volume().await.ok(); 410 414 411 - match SettingsServiceClient::connect(URL).await { 415 + match SettingsServiceClient::connect(url()).await { 412 416 Ok(mut c) => match c.get_global_settings(GetGlobalSettingsRequest {}).await { 413 417 Ok(resp) => { 414 418 let s = resp.into_inner(); ··· 430 434 // ── Likes ───────────────────────────────────────────────────────────────────── 431 435 432 436 pub async fn fetch_liked_tracks() -> Result<Vec<String>> { 433 - let mut c = LibraryServiceClient::connect(URL).await?; 437 + let mut c = LibraryServiceClient::connect(url()).await?; 434 438 let resp = c.get_liked_tracks(GetLikedTracksRequest {}).await?; 435 439 Ok(resp.into_inner().tracks.into_iter().map(|t| t.id).collect()) 436 440 } 437 441 438 442 pub async fn like_track(id: String) -> Result<()> { 439 - let mut c = LibraryServiceClient::connect(URL).await?; 443 + let mut c = LibraryServiceClient::connect(url()).await?; 440 444 c.like_track(LikeTrackRequest { id }).await?; 441 445 Ok(()) 442 446 } 443 447 444 448 pub async fn unlike_track(id: String) -> Result<()> { 445 - let mut c = LibraryServiceClient::connect(URL).await?; 449 + let mut c = LibraryServiceClient::connect(url()).await?; 446 450 c.unlike_track(UnlikeTrackRequest { id }).await?; 447 451 Ok(()) 448 452 } ··· 460 464 } 461 465 462 466 pub async fn run_library_stream(tx: Sender<StateUpdate>) { 467 + let notify = crate::server::server_notify(); 463 468 loop { 464 - if let Err(e) = library_stream_inner(&tx).await { 465 - log::warn!("library stream: {e}"); 469 + tokio::select! { 470 + result = library_stream_inner(&tx) => { 471 + if let Err(e) = result { log::warn!("library stream: {e}"); } 472 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 473 + } 474 + _ = notify.notified() => {} // server changed — drop connection and reconnect 466 475 } 467 - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 468 476 } 469 477 } 470 478 471 479 async fn library_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 472 - let mut c = LibraryServiceClient::connect(URL).await?; 480 + let mut c = LibraryServiceClient::connect(url()).await?; 473 481 let resp = c.stream_library(StreamLibraryRequest {}).await?; 474 482 let mut stream = resp.into_inner(); 475 483 loop { ··· 503 511 } 504 512 505 513 pub async fn run_artist_images_sync(tx: Sender<StateUpdate>) { 506 - match LibraryServiceClient::connect(URL).await { 514 + match LibraryServiceClient::connect(url()).await { 507 515 Ok(mut c) => match c.get_artists(GetArtistsRequest {}).await { 508 516 Ok(resp) => { 509 517 let images: ArtistImages = resp ··· 521 529 } 522 530 523 531 pub async fn run_status_stream(tx: Sender<StateUpdate>) { 532 + let notify = crate::server::server_notify(); 524 533 loop { 525 - if let Err(e) = status_stream_inner(&tx).await { 526 - log::warn!("status stream: {e}"); 534 + tokio::select! { 535 + result = status_stream_inner(&tx) => { 536 + if let Err(e) = result { log::warn!("status stream: {e}"); } 537 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 538 + } 539 + _ = notify.notified() => {} 527 540 } 528 - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 529 541 } 530 542 } 531 543 532 544 async fn status_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 533 - let mut c = PlaybackServiceClient::connect(URL).await?; 545 + let mut c = PlaybackServiceClient::connect(url()).await?; 534 546 let resp = c.stream_status(StreamStatusRequest {}).await?; 535 547 let mut stream = resp.into_inner(); 536 548 loop { ··· 558 570 } 559 571 560 572 pub async fn run_current_track_stream(tx: Sender<StateUpdate>) { 573 + let notify = crate::server::server_notify(); 561 574 loop { 562 - if let Err(e) = current_track_stream_inner(&tx).await { 563 - log::warn!("current track stream: {e}"); 575 + tokio::select! { 576 + result = current_track_stream_inner(&tx) => { 577 + if let Err(e) = result { log::warn!("current track stream: {e}"); } 578 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 579 + } 580 + _ = notify.notified() => {} 564 581 } 565 - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 566 582 } 567 583 } 568 584 569 585 async fn current_track_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> { 570 - let mut c = PlaybackServiceClient::connect(URL).await?; 586 + let mut c = PlaybackServiceClient::connect(url()).await?; 571 587 let resp = c.stream_current_track(StreamCurrentTrackRequest {}).await?; 572 588 let mut stream = resp.into_inner(); 573 589 loop { ··· 591 607 } 592 608 593 609 pub async fn run_playlist_stream(tx: Sender<StateUpdate>) { 610 + let notify = crate::server::server_notify(); 594 611 loop { 595 - if let Err(e) = playlist_stream_inner(&tx).await { 596 - log::warn!("playlist stream: {e}"); 612 + tokio::select! { 613 + result = playlist_stream_inner(&tx) => { 614 + if let Err(e) = result { log::warn!("playlist stream: {e}"); } 615 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 616 + } 617 + _ = notify.notified() => {} 597 618 } 598 - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 599 619 } 600 620 } 601 621 ··· 606 626 // publishes — without this fetch the queue would stay empty if we connect 607 627 // after the broker's initial publish. 608 628 { 609 - if let Ok(mut c) = PlaylistServiceClient::connect(URL).await { 629 + if let Ok(mut c) = PlaylistServiceClient::connect(url()).await { 610 630 let _ = c.playlist_resume(PlaylistResumeRequest {}).await; 611 631 } 612 632 } 613 633 fetch_queue(tx.clone()).await; 614 634 615 - let mut c = PlaybackServiceClient::connect(URL).await?; 635 + let mut c = PlaybackServiceClient::connect(url()).await?; 616 636 let resp = c.stream_playlist(StreamPlaylistRequest {}).await?; 617 637 let mut stream = resp.into_inner(); 618 638 loop { ··· 677 697 } 678 698 679 699 pub async fn tree_get_entries(path: Option<String>) -> Result<Vec<FileEntry>> { 680 - let mut c = BrowseServiceClient::connect(URL).await?; 700 + let mut c = BrowseServiceClient::connect(url()).await?; 681 701 let resp = c.tree_get_entries(TreeGetEntriesRequest { path }).await?; 682 702 let mut entries: Vec<FileEntry> = resp 683 703 .into_inner() ··· 702 722 } 703 723 704 724 pub async fn play_directory(path: String, shuffle: bool) -> Result<()> { 705 - let mut c = PlaybackServiceClient::connect(URL).await?; 725 + let mut c = PlaybackServiceClient::connect(url()).await?; 706 726 c.play_directory(PlayDirectoryRequest { 707 727 path, 708 728 shuffle: Some(shuffle), ··· 714 734 } 715 735 716 736 pub async fn play_directory_at(path: String, position: i32) -> Result<()> { 717 - let mut c = PlaybackServiceClient::connect(URL).await?; 737 + let mut c = PlaybackServiceClient::connect(url()).await?; 718 738 c.play_directory(PlayDirectoryRequest { 719 739 path, 720 740 shuffle: Some(false), ··· 726 746 } 727 747 728 748 pub async fn insert_directory(path: String, position: i32) -> Result<()> { 729 - let mut c = PlaylistServiceClient::connect(URL).await?; 749 + let mut c = PlaylistServiceClient::connect(url()).await?; 730 750 c.insert_directory(InsertDirectoryRequest { 731 751 directory: path, 732 752 position, ··· 744 764 use crate::api::v1alpha1::{ 745 765 saved_playlist_service_client::SavedPlaylistServiceClient, GetSavedPlaylistsRequest, 746 766 }; 747 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 767 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 748 768 let resp = c 749 769 .get_saved_playlists(GetSavedPlaylistsRequest { folder_id: None }) 750 770 .await?; ··· 769 789 use crate::api::v1alpha1::{ 770 790 smart_playlist_service_client::SmartPlaylistServiceClient, GetSmartPlaylistsRequest, 771 791 }; 772 - let mut c = SmartPlaylistServiceClient::connect(URL).await?; 792 + let mut c = SmartPlaylistServiceClient::connect(url()).await?; 773 793 let resp = c 774 794 .get_smart_playlists(GetSmartPlaylistsRequest {}) 775 795 .await?; ··· 800 820 use crate::api::v1alpha1::{ 801 821 saved_playlist_service_client::SavedPlaylistServiceClient, CreateSavedPlaylistRequest, 802 822 }; 803 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 823 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 804 824 c.create_saved_playlist(CreateSavedPlaylistRequest { 805 825 name, 806 826 description, ··· 817 837 saved_playlist_service_client::SavedPlaylistServiceClient, 818 838 AddTracksToSavedPlaylistRequest, 819 839 }; 820 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 840 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 821 841 c.add_tracks_to_saved_playlist(AddTracksToSavedPlaylistRequest { 822 842 playlist_id, 823 843 track_ids: vec![track_id], ··· 832 852 saved_playlist_service_client::SavedPlaylistServiceClient, 833 853 GetSavedPlaylistTracksRequest, 834 854 }; 835 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 855 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 836 856 let resp = c 837 857 .get_saved_playlist_tracks(GetSavedPlaylistTracksRequest { playlist_id }) 838 858 .await?; ··· 845 865 smart_playlist_service_client::SmartPlaylistServiceClient, 846 866 GetSmartPlaylistTracksRequest, 847 867 }; 848 - let mut c = SmartPlaylistServiceClient::connect(URL).await?; 868 + let mut c = SmartPlaylistServiceClient::connect(url()).await?; 849 869 let resp = c 850 870 .get_smart_playlist_tracks(GetSmartPlaylistTracksRequest { id: playlist_id }) 851 871 .await?; ··· 856 876 use crate::api::v1alpha1::{ 857 877 saved_playlist_service_client::SavedPlaylistServiceClient, PlaySavedPlaylistRequest, 858 878 }; 859 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 879 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 860 880 c.play_saved_playlist(PlaySavedPlaylistRequest { playlist_id }) 861 881 .await?; 862 882 Ok(()) ··· 866 886 use crate::api::v1alpha1::{ 867 887 smart_playlist_service_client::SmartPlaylistServiceClient, PlaySmartPlaylistRequest, 868 888 }; 869 - let mut c = SmartPlaylistServiceClient::connect(URL).await?; 889 + let mut c = SmartPlaylistServiceClient::connect(url()).await?; 870 890 c.play_smart_playlist(PlaySmartPlaylistRequest { id: playlist_id }) 871 891 .await?; 872 892 Ok(()) ··· 876 896 use crate::api::v1alpha1::{ 877 897 saved_playlist_service_client::SavedPlaylistServiceClient, DeleteSavedPlaylistRequest, 878 898 }; 879 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 899 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 880 900 c.delete_saved_playlist(DeleteSavedPlaylistRequest { id: playlist_id }) 881 901 .await?; 882 902 Ok(()) ··· 890 910 use crate::api::v1alpha1::{ 891 911 saved_playlist_service_client::SavedPlaylistServiceClient, UpdateSavedPlaylistRequest, 892 912 }; 893 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 913 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 894 914 c.update_saved_playlist(UpdateSavedPlaylistRequest { 895 915 id, 896 916 name, ··· 910 930 saved_playlist_service_client::SavedPlaylistServiceClient, 911 931 RemoveTrackFromSavedPlaylistRequest, 912 932 }; 913 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 933 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 914 934 c.remove_track_from_saved_playlist(RemoveTrackFromSavedPlaylistRequest { 915 935 playlist_id, 916 936 track_id, ··· 923 943 use crate::api::v1alpha1::{ 924 944 saved_playlist_service_client::SavedPlaylistServiceClient, PlaySavedPlaylistRequest, 925 945 }; 926 - let mut c = SavedPlaylistServiceClient::connect(URL).await?; 946 + let mut c = SavedPlaylistServiceClient::connect(url()).await?; 927 947 c.play_saved_playlist(PlaySavedPlaylistRequest { playlist_id }) 928 948 .await?; 929 949 // After loading, shuffle 930 950 use crate::api::v1alpha1::{ 931 951 playlist_service_client::PlaylistServiceClient, ShufflePlaylistRequest, 932 952 }; 933 - let mut pc = PlaylistServiceClient::connect(URL).await?; 953 + let mut pc = PlaylistServiceClient::connect(url()).await?; 934 954 pc.shuffle_playlist(ShufflePlaylistRequest { start_index: 0 }) 935 955 .await?; 936 956 Ok(()) ··· 939 959 // ── Device output API ───────────────────────────────────────────────────────── 940 960 941 961 pub async fn fetch_devices() -> Result<Vec<DeviceItem>> { 942 - let body = reqwest::get(format!("{HTTP_URL}/devices")) 962 + let body = reqwest::get(format!("{}/devices", http_url())) 943 963 .await? 944 964 .text() 945 965 .await?; ··· 950 970 pub async fn connect_device(id: String) -> Result<()> { 951 971 let client = reqwest::Client::new(); 952 972 client 953 - .put(format!("{HTTP_URL}/devices/{id}/connect")) 973 + .put(format!("{}/devices/{id}/connect", http_url())) 954 974 .send() 955 975 .await?; 956 976 Ok(()) ··· 959 979 pub async fn disconnect_device(id: String) -> Result<()> { 960 980 let client = reqwest::Client::new(); 961 981 client 962 - .put(format!("{HTTP_URL}/devices/{id}/disconnect")) 982 + .put(format!("{}/devices/{id}/disconnect", http_url())) 963 983 .send() 964 984 .await?; 965 985 Ok(())
+16
gpui/src/controller.rs
··· 38 38 rt.spawn(crate::client::run_current_track_stream(tx.clone())); 39 39 rt.spawn(crate::client::run_playlist_stream(tx.clone())); 40 40 41 + // Re-run one-shot syncs whenever the user switches the active server. 42 + let tx_for_switch = tx.clone(); 43 + let notify_for_switch = crate::server::server_notify(); 44 + rt.spawn(async move { 45 + loop { 46 + notify_for_switch.notified().await; 47 + // Small delay to let the new server's gRPC port come up. 48 + tokio::time::sleep(std::time::Duration::from_millis(500)).await; 49 + crate::client::run_library_sync(tx_for_switch.clone()).await; 50 + crate::client::run_liked_tracks_sync(tx_for_switch.clone()).await; 51 + crate::client::run_artist_images_sync(tx_for_switch.clone()).await; 52 + crate::client::run_settings_sync(tx_for_switch.clone()).await; 53 + crate::client::run_resume_info_sync(tx_for_switch.clone()).await; 54 + } 55 + }); 56 + 41 57 // Initialise OS media controls on the main thread (required by macOS). 42 58 let now_playing = NowPlayingManager::new().map(|m| Arc::new(Mutex::new(m))); 43 59
+1
gpui/src/main.rs
··· 4 4 pub mod controller; 5 5 pub mod http_client; 6 6 pub mod now_playing; 7 + pub mod server; 7 8 pub mod startup; 8 9 pub mod state; 9 10 pub mod ui;
+152
gpui/src/server.rs
··· 1 + use std::sync::{Arc, LazyLock, RwLock}; 2 + 3 + #[derive(Clone, Debug)] 4 + pub struct ServerInfo { 5 + pub name: String, 6 + pub host: String, 7 + pub grpc_port: u16, 8 + pub graphql_port: u16, 9 + pub http_port: u16, 10 + } 11 + 12 + impl ServerInfo { 13 + pub fn localhost() -> Self { 14 + Self { 15 + name: "localhost".to_string(), 16 + host: "127.0.0.1".to_string(), 17 + grpc_port: 6061, 18 + graphql_port: 6062, 19 + http_port: 6063, 20 + } 21 + } 22 + 23 + pub fn grpc_url(&self) -> String { 24 + format!("http://{}:{}", self.host, self.grpc_port) 25 + } 26 + 27 + pub fn graphql_url(&self) -> String { 28 + format!("http://{}:{}", self.host, self.graphql_port) 29 + } 30 + 31 + pub fn http_url(&self) -> String { 32 + format!("http://{}:{}", self.host, self.http_port) 33 + } 34 + 35 + pub fn display_name(&self) -> String { 36 + if self.host == "127.0.0.1" || self.host == "localhost" { 37 + "localhost".to_string() 38 + } else if !self.name.is_empty() && self.name != self.host { 39 + format!("{} ({})", self.name, self.host) 40 + } else { 41 + self.host.clone() 42 + } 43 + } 44 + 45 + pub fn is_localhost(&self) -> bool { 46 + self.host == "127.0.0.1" || self.host == "localhost" 47 + } 48 + } 49 + 50 + static CURRENT_SERVER: LazyLock<RwLock<ServerInfo>> = 51 + LazyLock::new(|| RwLock::new(ServerInfo::localhost())); 52 + 53 + static SERVER_NOTIFY: LazyLock<Arc<tokio::sync::Notify>> = 54 + LazyLock::new(|| Arc::new(tokio::sync::Notify::new())); 55 + 56 + pub fn get_grpc_url() -> String { 57 + CURRENT_SERVER.read().unwrap().grpc_url() 58 + } 59 + 60 + pub fn get_http_url() -> String { 61 + CURRENT_SERVER.read().unwrap().http_url() 62 + } 63 + 64 + pub fn get_covers_base() -> String { 65 + let s = CURRENT_SERVER.read().unwrap(); 66 + format!("http://{}:{}/covers/", s.host, s.graphql_port) 67 + } 68 + 69 + pub fn set_server(info: ServerInfo) { 70 + *CURRENT_SERVER.write().unwrap() = info; 71 + SERVER_NOTIFY.notify_waiters(); 72 + } 73 + 74 + pub fn current_server() -> ServerInfo { 75 + CURRENT_SERVER.read().unwrap().clone() 76 + } 77 + 78 + /// Returns a handle to the server-switch notification. 79 + /// Callers can `.await` `.notified()` to be woken immediately when the active server changes. 80 + pub fn server_notify() -> Arc<tokio::sync::Notify> { 81 + SERVER_NOTIFY.clone() 82 + } 83 + 84 + /// Blocking mDNS scan — returns all discovered rockboxd instances within `timeout`. 85 + /// Looks for `_rockbox._tcp.local.` services; service names prefixed with `grpc-`, 86 + /// `graphql-`, or `http-` update the corresponding port for that host. 87 + pub fn scan_mdns(timeout: std::time::Duration) -> Vec<ServerInfo> { 88 + use mdns_sd::{ServiceDaemon, ServiceEvent}; 89 + use std::collections::HashMap; 90 + 91 + let mdns = match ServiceDaemon::new() { 92 + Ok(m) => m, 93 + Err(_) => return vec![], 94 + }; 95 + let receiver = match mdns.browse("_rockbox._tcp.local.") { 96 + Ok(r) => r, 97 + Err(_) => return vec![], 98 + }; 99 + 100 + let mut by_host: HashMap<String, ServerInfo> = HashMap::new(); 101 + let deadline = std::time::Instant::now() + timeout; 102 + 103 + loop { 104 + let now = std::time::Instant::now(); 105 + if now >= deadline { 106 + break; 107 + } 108 + let remaining = deadline - now; 109 + let poll = remaining.min(std::time::Duration::from_millis(100)); 110 + 111 + match receiver.recv_timeout(poll) { 112 + Ok(ServiceEvent::ServiceResolved(info)) => { 113 + // Prefer an IPv4 address; only fall back to the hostname when none is present. 114 + // Hostnames like `foo.local` may resolve to an IPv6 link-local address, which 115 + // tonic's http2 transport rejects or connects to the wrong interface. 116 + // get_addresses() returns &HashSet<Ipv4Addr> — all entries are IPv4. 117 + // Prefer the raw IP over the .local hostname to avoid IPv6 resolution. 118 + let host = info 119 + .get_addresses() 120 + .iter() 121 + .next() 122 + .map(|a| a.to_string()) 123 + .unwrap_or_else(|| info.get_hostname().trim_end_matches('.').to_string()); 124 + let port = info.get_port(); 125 + let fullname = info.get_fullname().to_string(); 126 + 127 + let entry = by_host.entry(host.clone()).or_insert_with(|| ServerInfo { 128 + name: host.clone(), 129 + host: host.clone(), 130 + grpc_port: 6061, 131 + graphql_port: 6062, 132 + http_port: 6063, 133 + }); 134 + 135 + if fullname.starts_with("grpc-") { 136 + entry.grpc_port = port; 137 + } else if fullname.starts_with("graphql-") { 138 + entry.graphql_port = port; 139 + } else if fullname.starts_with("http-") { 140 + entry.http_port = port; 141 + } 142 + } 143 + Ok(_) | Err(_) => {} 144 + } 145 + } 146 + 147 + // Exclude localhost — handled separately as the priority default. 148 + by_host 149 + .into_values() 150 + .filter(|s| s.host != "127.0.0.1" && s.host != "localhost") 151 + .collect() 152 + }
+18 -9
gpui/src/startup.rs
··· 1 1 use std::net::TcpStream; 2 2 use std::time::Duration; 3 3 4 - const GRPC_ADDR: &str = "127.0.0.1:6061"; 4 + const LOCALHOST_GRPC: &str = "127.0.0.1:6061"; 5 5 const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); 6 + const MDNS_SCAN_TIMEOUT: Duration = Duration::from_secs(3); 6 7 7 8 #[derive(Clone, Copy, Debug)] 8 9 pub enum StartupError { 9 10 /// `rockboxd` binary not found anywhere in PATH or common locations. 10 11 NotInstalled, 11 - /// Binary found but daemon is not listening on the gRPC port. 12 + /// Binary found but no daemon is reachable (localhost or network). 12 13 NotRunning, 13 14 } 14 15 15 - /// Run all pre-flight checks. Returns `None` when everything is ready. 16 + /// Run all pre-flight checks. Returns `None` when everything is ready. 17 + /// 18 + /// Priority: 19 + /// 1. localhost:6061 — fastest, no mDNS overhead. 20 + /// 2. mDNS scan — discovers remote instances on the local network (blocks up 21 + /// to MDNS_SCAN_TIMEOUT); sets the active server via `crate::server::set_server`. 16 22 pub fn check() -> Option<StartupError> { 17 23 if !is_installed() { 18 24 return Some(StartupError::NotInstalled); 19 25 } 20 - if !is_running() { 21 - return Some(StartupError::NotRunning); 26 + if is_running() { 27 + return None; 28 + } 29 + let discovered = crate::server::scan_mdns(MDNS_SCAN_TIMEOUT); 30 + if let Some(server) = discovered.into_iter().next() { 31 + crate::server::set_server(server); 32 + return None; 22 33 } 23 - None 34 + Some(StartupError::NotRunning) 24 35 } 25 36 26 37 pub fn is_installed() -> bool { 27 - // Walk PATH explicitly — app bundles have a stripped environment. 28 38 if let Ok(path_var) = std::env::var("PATH") { 29 39 for dir in path_var.split(':') { 30 40 if std::path::Path::new(dir).join("rockboxd").exists() { ··· 32 42 } 33 43 } 34 44 } 35 - // Fallback: common macOS install locations regardless of PATH. 36 45 [ 37 46 "/usr/local/bin/rockboxd", 38 47 "/opt/homebrew/bin/rockboxd", ··· 44 53 } 45 54 46 55 pub fn is_running() -> bool { 47 - TcpStream::connect_timeout(&GRPC_ADDR.parse().unwrap(), CONNECT_TIMEOUT).is_ok() 56 + TcpStream::connect_timeout(&LOCALHOST_GRPC.parse().unwrap(), CONNECT_TIMEOUT).is_ok() 48 57 }
+13
gpui/src/ui/components/mod.rs
··· 242 242 #[derive(Clone, Default)] 243 243 pub struct AddToPlaylistMenuState(pub Option<AddToPlaylistMenu>); 244 244 impl gpui::Global for AddToPlaylistMenuState {} 245 + 246 + // ── Server picker ───────────────────────────────────────────────────────────── 247 + 248 + #[derive(Clone, Default)] 249 + pub struct ServerPickerOpen(pub bool); 250 + impl gpui::Global for ServerPickerOpen {} 251 + 252 + #[derive(Clone, Default)] 253 + pub struct DiscoveredServers { 254 + pub servers: Vec<crate::server::ServerInfo>, 255 + pub scanning: bool, 256 + } 257 + impl gpui::Global for DiscoveredServers {}
+314 -9
gpui/src/ui/components/pages/library.rs
··· 12 12 use crate::ui::components::text_input::TextInput; 13 13 use crate::ui::components::{ 14 14 AddToPlaylistMenuState, AlbumContextMenu, AlbumContextMenuState, BackSection, 15 - CreatePlaylistModal, DeletePlaylistModal, EditPlaylistModal, FileContextMenuState, 16 - HoveredAlbumIdx, LibraryContextMenu, LibraryContextMenuState, LibrarySection, LikedOrder, 17 - LikedSongs, PlaylistsSidebarCollapsed, PlaylistsState, SelectedAlbum, SelectedAlbumMeta, 18 - SelectedArtist, SelectedPlaylist, 15 + CreatePlaylistModal, DeletePlaylistModal, DiscoveredServers, EditPlaylistModal, 16 + FileContextMenuState, HoveredAlbumIdx, LibraryContextMenu, LibraryContextMenuState, 17 + LibrarySection, LikedOrder, LikedSongs, PlaylistsSidebarCollapsed, PlaylistsState, 18 + SelectedAlbum, SelectedAlbumMeta, SelectedArtist, SelectedPlaylist, ServerPickerOpen, 19 19 }; 20 20 use crate::ui::theme::Theme; 21 21 use gpui::prelude::FluentBuilder; ··· 241 241 }) 242 242 .detach(); 243 243 244 + // Background mDNS scan on startup so the server list is pre-populated. 245 + cx.global_mut::<DiscoveredServers>().scanning = true; 246 + cx.spawn(async move |_, cx| { 247 + let found = cx 248 + .background_executor() 249 + .spawn(async move { 250 + crate::server::scan_mdns(std::time::Duration::from_secs(3)) 251 + }) 252 + .await; 253 + let _ = cx.update(|app: &mut gpui::App| { 254 + let state = app.global_mut::<DiscoveredServers>(); 255 + state.scanning = false; 256 + state.servers = found; 257 + }); 258 + }) 259 + .detach(); 260 + 244 261 LibraryPage { 245 262 scroll_handle: UniformListScrollHandle::new(), 246 263 detail_scroll_handle: UniformListScrollHandle::new(), ··· 283 300 let delete_modal = cx.global::<DeletePlaylistModal>().clone(); 284 301 let add_to_playlist_menu = cx.global::<AddToPlaylistMenuState>().0.clone(); 285 302 let album_meta = cx.global::<SelectedAlbumMeta>().clone(); 303 + let cur_server = crate::server::current_server(); 304 + let picker_open = cx.global::<ServerPickerOpen>().0; 305 + let server_scanning = cx.global::<DiscoveredServers>().scanning; 306 + let discovered_servers = cx.global::<DiscoveredServers>().servers.clone(); 286 307 287 308 // Trigger playlist tracks load when in detail views 288 309 if (section == LibrarySection::PlaylistDetail ··· 3141 3162 content_inner 3142 3163 }; // end if/else search 3143 3164 3165 + let cur_is_local = cur_server.is_localhost(); 3166 + let server_is_empty = !server_scanning && discovered_servers.is_empty(); 3167 + let servers_for_picker = discovered_servers.clone(); 3168 + let cur_host_for_picker = cur_server.host.clone(); 3169 + let server_footer = div() 3170 + .w_full() 3171 + .border_t_1() 3172 + .border_color(theme.library_table_border) 3173 + .flex() 3174 + .flex_col() 3175 + .when(picker_open, |this| { 3176 + let servers = servers_for_picker; 3177 + let cur_host = cur_host_for_picker; 3178 + let scanning = server_scanning; 3179 + let is_empty = server_is_empty; 3180 + this.child( 3181 + div() 3182 + .id("server-panel") 3183 + .w_full() 3184 + .bg(theme.titlebar_bg) 3185 + .flex() 3186 + .flex_col() 3187 + .max_h(px(200.0)) 3188 + .overflow_y_scroll() 3189 + .child( 3190 + div() 3191 + .w_full() 3192 + .flex() 3193 + .items_center() 3194 + .justify_between() 3195 + .px(px(10.0)) 3196 + .py(px(6.0)) 3197 + .child( 3198 + div() 3199 + .text_xs() 3200 + .font_weight(FontWeight::BOLD) 3201 + .text_color(theme.library_header_text) 3202 + .child("Servers"), 3203 + ) 3204 + .child( 3205 + div() 3206 + .id("server-scan-btn") 3207 + .px(px(6.0)) 3208 + .py(px(2.0)) 3209 + .rounded_md() 3210 + .text_xs() 3211 + .cursor_pointer() 3212 + .text_color(if scanning { 3213 + gpui::rgb(0x6F00FF) 3214 + } else { 3215 + theme.library_header_text 3216 + }) 3217 + .hover(|s| s.text_color(theme.library_text)) 3218 + .on_click(cx.listener(|_, _, _, cx| { 3219 + if cx.global::<DiscoveredServers>().scanning { 3220 + return; 3221 + } 3222 + cx.global_mut::<DiscoveredServers>().scanning = true; 3223 + cx.global_mut::<DiscoveredServers>().servers = vec![]; 3224 + cx.notify(); 3225 + cx.spawn(async move |this, cx| { 3226 + let found = cx 3227 + .background_executor() 3228 + .spawn(async move { 3229 + crate::server::scan_mdns( 3230 + std::time::Duration::from_secs(3), 3231 + ) 3232 + }) 3233 + .await; 3234 + let _ = this.update(cx, |_, cx| { 3235 + cx.global_mut::<DiscoveredServers>().scanning = 3236 + false; 3237 + cx.global_mut::<DiscoveredServers>().servers = 3238 + found; 3239 + cx.notify(); 3240 + }); 3241 + }) 3242 + .detach(); 3243 + })) 3244 + .child(if scanning { "Scanning…" } else { "Scan" }), 3245 + ), 3246 + ) 3247 + .child( 3248 + div() 3249 + .id("server-localhost") 3250 + .w_full() 3251 + .flex() 3252 + .items_center() 3253 + .gap_x_2() 3254 + .px(px(10.0)) 3255 + .py(px(5.0)) 3256 + .cursor_pointer() 3257 + .bg(if cur_is_local { 3258 + gpui::rgba(0x6F00FF20) 3259 + } else { 3260 + theme.titlebar_bg 3261 + }) 3262 + .hover(|s| s.bg(theme.library_table_border)) 3263 + .on_click(cx.listener(|_, _, _, cx| { 3264 + crate::server::set_server( 3265 + crate::server::ServerInfo::localhost(), 3266 + ); 3267 + cx.global_mut::<ServerPickerOpen>().0 = false; 3268 + let tokio = cx.global::<crate::state::TokioHandle>().0.clone(); 3269 + cx.spawn(async move |_, cx| { 3270 + let (saved, smart) = cx 3271 + .background_executor() 3272 + .spawn(async move { 3273 + tokio.block_on(async { 3274 + let saved = crate::client::fetch_saved_playlists().await.unwrap_or_default(); 3275 + let smart = crate::client::fetch_smart_playlists().await.unwrap_or_default(); 3276 + (saved, smart) 3277 + }) 3278 + }) 3279 + .await; 3280 + let _ = cx.update(|app: &mut gpui::App| { 3281 + let state = app.global_mut::<PlaylistsState>(); 3282 + state.saved = saved; 3283 + state.smart = smart; 3284 + }); 3285 + }) 3286 + .detach(); 3287 + cx.notify(); 3288 + })) 3289 + .child( 3290 + div() 3291 + .w(px(6.0)) 3292 + .h(px(6.0)) 3293 + .rounded_full() 3294 + .bg(if cur_is_local { 3295 + gpui::rgb(0x39FF14) 3296 + } else { 3297 + theme.library_header_text 3298 + }), 3299 + ) 3300 + .child( 3301 + div() 3302 + .flex_1() 3303 + .min_w_0() 3304 + .truncate() 3305 + .text_xs() 3306 + .text_color(if cur_is_local { 3307 + theme.library_text 3308 + } else { 3309 + theme.library_header_text 3310 + }) 3311 + .child("localhost"), 3312 + ), 3313 + ) 3314 + .children(servers.into_iter().enumerate().map(|(idx, server)| { 3315 + let is_active = server.host == cur_host; 3316 + let s = server.clone(); 3317 + let label = server.display_name(); 3318 + div() 3319 + .id(gpui::SharedString::from(format!("server-row-{idx}"))) 3320 + .w_full() 3321 + .flex() 3322 + .items_center() 3323 + .gap_x_2() 3324 + .px(px(10.0)) 3325 + .py(px(5.0)) 3326 + .cursor_pointer() 3327 + .bg(if is_active { 3328 + gpui::rgba(0x6F00FF20) 3329 + } else { 3330 + theme.titlebar_bg 3331 + }) 3332 + .hover(|sv| sv.bg(theme.library_table_border)) 3333 + .on_click(cx.listener(move |_, _, _, cx| { 3334 + crate::server::set_server(s.clone()); 3335 + cx.global_mut::<ServerPickerOpen>().0 = false; 3336 + let tokio = cx.global::<crate::state::TokioHandle>().0.clone(); 3337 + cx.spawn(async move |_, cx| { 3338 + let (saved, smart) = cx 3339 + .background_executor() 3340 + .spawn(async move { 3341 + tokio.block_on(async { 3342 + let saved = crate::client::fetch_saved_playlists().await.unwrap_or_default(); 3343 + let smart = crate::client::fetch_smart_playlists().await.unwrap_or_default(); 3344 + (saved, smart) 3345 + }) 3346 + }) 3347 + .await; 3348 + let _ = cx.update(|app: &mut gpui::App| { 3349 + let state = app.global_mut::<PlaylistsState>(); 3350 + state.saved = saved; 3351 + state.smart = smart; 3352 + }); 3353 + }) 3354 + .detach(); 3355 + cx.notify(); 3356 + })) 3357 + .child( 3358 + div() 3359 + .w(px(6.0)) 3360 + .h(px(6.0)) 3361 + .rounded_full() 3362 + .bg(if is_active { 3363 + gpui::rgb(0x39FF14) 3364 + } else { 3365 + theme.library_header_text 3366 + }), 3367 + ) 3368 + .child( 3369 + div() 3370 + .flex_1() 3371 + .min_w_0() 3372 + .truncate() 3373 + .text_xs() 3374 + .text_color(if is_active { 3375 + theme.library_text 3376 + } else { 3377 + theme.library_header_text 3378 + }) 3379 + .child(label), 3380 + ) 3381 + })) 3382 + .when(scanning, |s| { 3383 + s.child( 3384 + div() 3385 + .w_full() 3386 + .px(px(10.0)) 3387 + .py(px(6.0)) 3388 + .text_xs() 3389 + .text_color(theme.library_header_text) 3390 + .child("Scanning network…"), 3391 + ) 3392 + }) 3393 + .when(is_empty, |s| { 3394 + s.child( 3395 + div() 3396 + .w_full() 3397 + .px(px(10.0)) 3398 + .py(px(6.0)) 3399 + .text_xs() 3400 + .text_color(theme.library_header_text) 3401 + .child("No servers found. Press Scan."), 3402 + ) 3403 + }), 3404 + ) 3405 + }) 3406 + .child( 3407 + div() 3408 + .id("server-picker-toggle") 3409 + .w_full() 3410 + .flex() 3411 + .items_center() 3412 + .gap_x_2() 3413 + .px(px(10.0)) 3414 + .py(px(8.0)) 3415 + .cursor_pointer() 3416 + .hover(|s| s.bg(theme.library_table_border)) 3417 + .on_click(cx.listener(|_, _, _, cx| { 3418 + let open = !cx.global::<ServerPickerOpen>().0; 3419 + cx.global_mut::<ServerPickerOpen>().0 = open; 3420 + cx.notify(); 3421 + })) 3422 + .child( 3423 + Icon::new(Icons::Device) 3424 + .size_3() 3425 + .text_color(if picker_open { 3426 + gpui::rgb(0x6F00FF) 3427 + } else { 3428 + theme.library_header_text 3429 + }), 3430 + ) 3431 + .child( 3432 + div() 3433 + .flex_1() 3434 + .min_w_0() 3435 + .truncate() 3436 + .text_xs() 3437 + .text_color(theme.library_header_text) 3438 + .child(cur_server.display_name()), 3439 + ), 3440 + ); 3441 + 3144 3442 div() 3145 3443 .size_full() 3146 3444 .flex() ··· 3157 3455 // Sidebar 3158 3456 .child( 3159 3457 div() 3160 - .id("sidebar_scroll") 3161 3458 .w(px(200.0)) 3162 3459 .h_full() 3163 3460 .flex_shrink_0() 3164 3461 .flex() 3165 3462 .flex_col() 3166 - .overflow_y_scroll() 3167 3463 .border_r_1() 3168 3464 .border_color(theme.library_table_border) 3169 - .pt_4() 3170 - .child(self.search_input.clone()) 3171 - .gap_y_1() 3465 + .child(div() 3466 + .id("sidebar_scroll") 3467 + .flex_1() 3468 + .min_h_0() 3469 + .flex() 3470 + .flex_col() 3471 + .overflow_y_scroll() 3472 + .pt_4() 3473 + .child(self.search_input.clone()) 3474 + .gap_y_1() 3172 3475 .child(make_nav_item( 3173 3476 Icons::Music, 3174 3477 5, ··· 3420 3723 ) 3421 3724 }), 3422 3725 ), 3726 + ) 3727 + .child(server_footer) 3423 3728 ) 3424 3729 .child(content), 3425 3730 )
+8
macos/Rockbox.xcodeproj/project.pbxproj
··· 434 434 CURRENT_PROJECT_VERSION = 1; 435 435 ENABLE_PREVIEWS = YES; 436 436 GENERATE_INFOPLIST_FILE = YES; 437 + INFOPLIST_KEY_NSBonjourServices = ( 438 + "_rockbox._tcp", 439 + ); 437 440 INFOPLIST_KEY_NSHumanReadableCopyright = ""; 441 + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Used to discover Rockbox servers on your local network."; 438 442 LD_RUNPATH_SEARCH_PATHS = ( 439 443 "$(inherited)", 440 444 "@executable_path/../Frameworks", ··· 459 463 CURRENT_PROJECT_VERSION = 1; 460 464 ENABLE_PREVIEWS = YES; 461 465 GENERATE_INFOPLIST_FILE = YES; 466 + INFOPLIST_KEY_NSBonjourServices = ( 467 + "_rockbox._tcp", 468 + ); 462 469 INFOPLIST_KEY_NSHumanReadableCopyright = ""; 470 + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Used to discover Rockbox servers on your local network."; 463 471 LD_RUNPATH_SEARCH_PATHS = ( 464 472 "$(inherited)", 465 473 "@executable_path/../Frameworks",
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+24 -9
macos/Rockbox/RockboxApp.swift
··· 27 27 retry() 28 28 } 29 29 .keyboardShortcut(.defaultAction) 30 - 30 + 31 31 Button("Quit") { 32 32 NSApplication.shared.terminate(nil) 33 33 } ··· 41 41 } 42 42 .windowStyle(.hiddenTitleBar) 43 43 } 44 - 44 + 45 45 private func performStartup() async { 46 46 do { 47 - // Check if server is available first 48 - _ = try await fetchGlobalStatus() 49 - 50 - // If successful, start normal operations 47 + // Priority 1: localhost. Priority 2: first mDNS-discovered server. 48 + // ServerManager already kicked off a background scan; if localhost is 49 + // unreachable we wait for it to finish and try the first result. 50 + if (try? await fetchGlobalStatus()) == nil { 51 + // localhost not available — wait for mDNS scan results 52 + let serverManager = ServerManager.shared 53 + if !serverManager.isScanning { 54 + await serverManager.scan() 55 + } else { 56 + // Already scanning (started at init) — poll until done 57 + while serverManager.isScanning { 58 + try? await Task.sleep(nanoseconds: 100_000_000) 59 + } 60 + } 61 + if let first = serverManager.discoveredServers.first { 62 + serverManager.selectServer(first) 63 + } 64 + _ = try await fetchGlobalStatus() 65 + } 66 + 51 67 player.startStreaming() 52 68 player.fetchSettings() 53 69 await deviceState.refresh() 54 - 70 + 55 71 } catch { 56 - // Show error and allow retry or quit 57 72 startupError = error 58 73 startupFailed = true 59 74 } 60 75 } 61 - 76 + 62 77 private func retry() { 63 78 Task { 64 79 await performStartup()
+10 -44
macos/Rockbox/Services/AlbumService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchAlbums(host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> [Rockbox_V1alpha1_Album] 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchAlbums() async throws -> [Rockbox_V1alpha1_Album] { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 22 - 23 15 let req = Rockbox_V1alpha1_GetAlbumsRequest() 24 - 25 16 let res = try await library.getAlbums(req) 26 - 27 17 return res.albums 28 18 } 29 19 } 30 20 31 - func fetchAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 32 - -> Rockbox_V1alpha1_Album 33 - { 34 - try await withGRPCClient( 35 - transport: .http2NIOPosix( 36 - target: .dns(host: host, port: port), 37 - transportSecurity: .plaintext 38 - ) 39 - ) { grpcClient in 21 + func fetchAlbum(id: String) async throws -> Rockbox_V1alpha1_Album { 22 + try await withRockboxGRPCClient { grpcClient in 40 23 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 41 - 42 24 var req = Rockbox_V1alpha1_GetAlbumRequest() 43 25 req.id = id 44 - 45 26 let res = try await library.getAlbum(req) 46 - 47 27 return res.album 48 28 } 49 29 } 50 30 51 - func fetchAlbumTracks(albumID: String, host: String = "127.0.0.1", port: Int = 6061) async throws 52 - -> [Rockbox_V1alpha1_Track] 53 - { 54 - let album = try await fetchAlbum(id: albumID, host: host, port: port) 31 + func fetchAlbumTracks(albumID: String) async throws -> [Rockbox_V1alpha1_Track] { 32 + let album = try await fetchAlbum(id: albumID) 55 33 return album.tracks 56 34 } 57 35 58 - func likeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 59 - try await withGRPCClient( 60 - transport: .http2NIOPosix( 61 - target: .dns(host: host, port: port), 62 - transportSecurity: .plaintext 63 - ) 64 - ) { grpcClient in 36 + func likeAlbum(id: String) async throws { 37 + try await withRockboxGRPCClient { grpcClient in 65 38 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 66 - 67 39 var req = Rockbox_V1alpha1_LikeAlbumRequest() 68 40 req.id = id 69 41 let _ = try await library.likeAlbum(req) 70 42 } 71 43 } 72 44 73 - func unlikeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 74 - try await withGRPCClient( 75 - transport: .http2NIOPosix( 76 - target: .dns(host: host, port: port), 77 - transportSecurity: .plaintext 78 - ) 79 - ) { grpcClient in 45 + func unlikeAlbum(id: String) async throws { 46 + try await withRockboxGRPCClient { grpcClient in 80 47 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 81 - 82 48 var req = Rockbox_V1alpha1_UnlikeAlbumRequest() 83 49 req.id = id 84 50 let _ = try await library.unlikeAlbum(req)
+4 -20
macos/Rockbox/Services/ArtistService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchArtists(host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> [Rockbox_V1alpha1_Artist] 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchArtists() async throws -> [Rockbox_V1alpha1_Artist] { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 22 - 23 15 let req = Rockbox_V1alpha1_GetArtistsRequest() 24 16 let res = try await library.getArtists(req) 25 17 return res.artists 26 18 } 27 19 } 28 20 29 - func fetchArtist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 30 - -> Rockbox_V1alpha1_Artist 31 - { 32 - try await withGRPCClient( 33 - transport: .http2NIOPosix( 34 - target: .dns(host: host, port: port), 35 - transportSecurity: .plaintext 36 - ) 37 - ) { grpcClient in 21 + func fetchArtist(id: String) async throws -> Rockbox_V1alpha1_Artist { 22 + try await withRockboxGRPCClient { grpcClient in 38 23 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 39 - 40 24 var req = Rockbox_V1alpha1_GetArtistRequest() 41 25 req.id = id 42 26 let res = try await library.getArtist(req)
+3 -13
macos/Rockbox/Services/BrowseService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchFiles(path: String?, host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> [Rockbox_V1alpha1_Entry] 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchFiles(path: String?) async throws -> [Rockbox_V1alpha1_Entry] { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let browse = Rockbox_V1alpha1_BrowseService.Client(wrapping: grpcClient) 22 - 23 15 var req = Rockbox_V1alpha1_TreeGetEntriesRequest() 24 - if path != nil { 25 - req.path = path ?? String() 26 - } 16 + if let path = path { req.path = path } 27 17 let res = try await browse.treeGetEntries(req) 28 18 return res.entries 29 19 }
+1 -1
macos/Rockbox/Services/DeviceService.swift
··· 5 5 6 6 import Foundation 7 7 8 - private let httpBase = "http://127.0.0.1:6063" 8 + private var httpBase: String { ServerConfig.shared.httpBaseURL } 9 9 10 10 struct DeviceInfo: Codable, Identifiable, Hashable { 11 11 var id: String
+26 -111
macos/Rockbox/Services/PlaybackService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func play(elapsed: Int64, host: String = "127.0.0.1", port: Int = 6061) async throws { 13 - try await withGRPCClient( 14 - transport: .http2NIOPosix( 15 - target: .dns(host: host, port: port), 16 - transportSecurity: .plaintext 17 - ) 18 - ) { grpcClient in 12 + func play(elapsed: Int64) async throws { 13 + try await withRockboxGRPCClient { grpcClient in 19 14 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 20 15 var req = Rockbox_V1alpha1_PlayRequest() 21 16 req.elapsed = elapsed ··· 24 19 } 25 20 } 26 21 27 - func resume(host: String = "127.0.0.1", port: Int = 6061) async throws { 28 - try await withGRPCClient( 29 - transport: .http2NIOPosix( 30 - target: .dns(host: host, port: port), 31 - transportSecurity: .plaintext 32 - ) 33 - ) { grpcClient in 22 + func resume() async throws { 23 + try await withRockboxGRPCClient { grpcClient in 34 24 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 35 25 let req = Rockbox_V1alpha1_ResumeRequest() 36 26 let _ = try await playback.resume(req) 37 27 } 38 28 } 39 29 40 - func pause(host: String = "127.0.0.1", port: Int = 6061) async throws { 41 - try await withGRPCClient( 42 - transport: .http2NIOPosix( 43 - target: .dns(host: host, port: port), 44 - transportSecurity: .plaintext 45 - ) 46 - ) { grpcClient in 30 + func pause() async throws { 31 + try await withRockboxGRPCClient { grpcClient in 47 32 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 48 33 let req = Rockbox_V1alpha1_PauseRequest() 49 34 let _ = try await playback.pause(req) 50 35 } 51 36 } 52 37 53 - func previous(host: String = "127.0.0.1", port: Int = 6061) async throws { 54 - try await withGRPCClient( 55 - transport: .http2NIOPosix( 56 - target: .dns(host: host, port: port), 57 - transportSecurity: .plaintext 58 - ) 59 - ) { grpcClient in 38 + func previous() async throws { 39 + try await withRockboxGRPCClient { grpcClient in 60 40 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 61 41 let req = Rockbox_V1alpha1_PreviousRequest() 62 42 let _ = try await playback.previous(req) 63 43 } 64 44 } 65 45 66 - func next(host: String = "127.0.0.1", port: Int = 6061) async throws { 67 - try await withGRPCClient( 68 - transport: .http2NIOPosix( 69 - target: .dns(host: host, port: port), 70 - transportSecurity: .plaintext 71 - ) 72 - ) { grpcClient in 46 + func next() async throws { 47 + try await withRockboxGRPCClient { grpcClient in 73 48 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 74 49 let req = Rockbox_V1alpha1_NextRequest() 75 50 let _ = try await playback.next(req) 76 51 } 77 52 } 78 53 79 - func fetchPlaybackStatus(host: String = "127.0.0.1", port: Int = 6061) async throws -> Int32 { 80 - try await withGRPCClient( 81 - transport: .http2NIOPosix( 82 - target: .dns(host: host, port: port), 83 - transportSecurity: .plaintext 84 - ) 85 - ) { grpcClient in 54 + func fetchPlaybackStatus() async throws -> Int32 { 55 + try await withRockboxGRPCClient { grpcClient in 86 56 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 87 57 let req = Rockbox_V1alpha1_StatusRequest() 88 58 let response = try await playback.status(req) ··· 94 64 AsyncThrowingStream { continuation in 95 65 Task { 96 66 do { 97 - try await withGRPCClient( 98 - transport: .http2NIOPosix( 99 - target: .dns(host: "127.0.0.1", port: 6061), 100 - transportSecurity: .plaintext 101 - ) 102 - ) { grpcClient in 67 + try await withRockboxGRPCClient { grpcClient in 103 68 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 104 69 let req = Rockbox_V1alpha1_StreamCurrentTrackRequest() 105 - 106 70 try await playback.streamCurrentTrack(req) { response in 107 71 for try await message in response.messages { 108 72 continuation.yield(message) ··· 121 85 AsyncThrowingStream { continuation in 122 86 Task { 123 87 do { 124 - try await withGRPCClient( 125 - transport: .http2NIOPosix( 126 - target: .dns(host: "127.0.0.1", port: 6061), 127 - transportSecurity: .plaintext 128 - ) 129 - ) { grpcClient in 88 + try await withRockboxGRPCClient { grpcClient in 130 89 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 131 90 let req = Rockbox_V1alpha1_StreamStatusRequest() 132 - 133 91 try await playback.streamStatus(req) { response in 134 92 for try await message in response.messages { 135 93 continuation.yield(message) ··· 144 102 } 145 103 } 146 104 147 - func playAlbum( 148 - albumID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 149 - port: Int = 6061 150 - ) async throws { 151 - try await withGRPCClient( 152 - transport: .http2NIOPosix( 153 - target: .dns(host: host, port: port), 154 - transportSecurity: .plaintext 155 - ) 156 - ) { grpcClient in 105 + func playAlbum(albumID: String, shuffle: Bool = false, position: Int32 = 0) async throws { 106 + try await withRockboxGRPCClient { grpcClient in 157 107 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 158 108 var req = Rockbox_V1alpha1_PlayAlbumRequest() 159 109 req.albumID = albumID ··· 163 113 } 164 114 } 165 115 166 - func playDirectory( 167 - path: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 168 - port: Int = 6061 169 - ) async throws { 170 - try await withGRPCClient( 171 - transport: .http2NIOPosix( 172 - target: .dns(host: host, port: port), 173 - transportSecurity: .plaintext 174 - ) 175 - ) { grpcClient in 116 + func playDirectory(path: String, shuffle: Bool = false, position: Int32 = 0) async throws { 117 + try await withRockboxGRPCClient { grpcClient in 176 118 if path.isEmpty { 177 119 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 178 120 var req = Rockbox_V1alpha1_PlayMusicDirectoryRequest() ··· 190 132 } 191 133 } 192 134 193 - func playTrack(path: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 194 - try await withGRPCClient( 195 - transport: .http2NIOPosix( 196 - target: .dns(host: host, port: port), 197 - transportSecurity: .plaintext 198 - ) 199 - ) { grpcClient in 135 + func playTrack(path: String) async throws { 136 + try await withRockboxGRPCClient { grpcClient in 200 137 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 201 138 var req = Rockbox_V1alpha1_PlayTrackRequest() 202 139 req.path = path ··· 204 141 } 205 142 } 206 143 207 - func playAllTracks( 208 - shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061 209 - ) async throws { 210 - try await withGRPCClient( 211 - transport: .http2NIOPosix( 212 - target: .dns(host: host, port: port), 213 - transportSecurity: .plaintext 214 - ) 215 - ) { grpcClient in 144 + func playAllTracks(shuffle: Bool = false, position: Int32 = 0) async throws { 145 + try await withRockboxGRPCClient { grpcClient in 216 146 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 217 147 var req = Rockbox_V1alpha1_PlayAllTracksRequest() 218 148 req.shuffle = shuffle ··· 221 151 } 222 152 } 223 153 224 - func playLikedTracks( 225 - shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061 226 - ) async throws { 227 - try await withGRPCClient( 228 - transport: .http2NIOPosix( 229 - target: .dns(host: host, port: port), 230 - transportSecurity: .plaintext 231 - ) 232 - ) { grpcClient in 154 + func playLikedTracks(shuffle: Bool = false, position: Int32 = 0) async throws { 155 + try await withRockboxGRPCClient { grpcClient in 233 156 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 234 157 var req = Rockbox_V1alpha1_PlayLikedTracksRequest() 235 158 req.shuffle = shuffle ··· 238 161 } 239 162 } 240 163 241 - func playArtistTracks( 242 - artistID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 243 - port: Int = 6061 244 - ) async throws { 245 - try await withGRPCClient( 246 - transport: .http2NIOPosix( 247 - target: .dns(host: host, port: port), 248 - transportSecurity: .plaintext 249 - ) 250 - ) { grpcClient in 164 + func playArtistTracks(artistID: String, shuffle: Bool = false, position: Int32 = 0) async throws { 165 + try await withRockboxGRPCClient { grpcClient in 251 166 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 252 167 var req = Rockbox_V1alpha1_PlayArtistTracksRequest() 253 168 req.shuffle = shuffle
+18 -89
macos/Rockbox/Services/PlaylistService.swift
··· 8 8 import GRPCCore 9 9 import GRPCNIOTransportHTTP2 10 10 11 - func fetchCurrentPlaylist(host: String = "127.0.0.1", port: Int = 6061) async throws 12 - -> Rockbox_V1alpha1_GetCurrentResponse 13 - { 14 - try await withGRPCClient( 15 - transport: .http2NIOPosix( 16 - target: .dns(host: host, port: port), 17 - transportSecurity: .plaintext 18 - ) 19 - ) { grpcClient in 11 + func fetchCurrentPlaylist() async throws -> Rockbox_V1alpha1_GetCurrentResponse { 12 + try await withRockboxGRPCClient { grpcClient in 20 13 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 21 - 22 14 let req = Rockbox_V1alpha1_GetCurrentRequest() 23 - 24 - let res = try await playlist.getCurrent(req) 25 - 26 - return res 15 + return try await playlist.getCurrent(req) 27 16 } 28 17 } 29 18 30 - func resumeTrack(host: String = "127.0.0.1", port: Int = 6061) async throws { 31 - try await withGRPCClient( 32 - transport: .http2NIOPosix( 33 - target: .dns(host: host, port: port), 34 - transportSecurity: .plaintext 35 - ) 36 - ) { grpcClient in 19 + func resumeTrack() async throws { 20 + try await withRockboxGRPCClient { grpcClient in 37 21 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 38 22 let req = Rockbox_V1alpha1_ResumeTrackRequest() 39 23 let _ = try await playlist.resumeTrack(req) ··· 44 28 AsyncThrowingStream { continuation in 45 29 Task { 46 30 do { 47 - try await withGRPCClient( 48 - transport: .http2NIOPosix( 49 - target: .dns(host: "127.0.0.1", port: 6061), 50 - transportSecurity: .plaintext 51 - ) 52 - ) { grpcClient in 31 + try await withRockboxGRPCClient { grpcClient in 53 32 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 54 33 let req = Rockbox_V1alpha1_StreamPlaylistRequest() 55 - 56 34 try await playback.streamPlaylist(req) { response in 57 35 for try await message in response.messages { 58 36 continuation.yield(message) ··· 67 45 } 68 46 } 69 47 70 - func startPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws { 71 - try await withGRPCClient( 72 - transport: .http2NIOPosix( 73 - target: .dns(host: host, port: port), 74 - transportSecurity: .plaintext 75 - ) 76 - ) { grpcClient in 48 + func startPlaylist(position: Int32) async throws { 49 + try await withRockboxGRPCClient { grpcClient in 77 50 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 78 51 var req = Rockbox_V1alpha1_StartRequest() 79 52 req.startIndex = position ··· 81 54 } 82 55 } 83 56 84 - func removeFromPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws 85 - { 86 - try await withGRPCClient( 87 - transport: .http2NIOPosix( 88 - target: .dns(host: host, port: port), 89 - transportSecurity: .plaintext 90 - ) 91 - ) { grpcClient in 57 + func removeFromPlaylist(position: Int32) async throws { 58 + try await withRockboxGRPCClient { grpcClient in 92 59 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 93 60 var req = Rockbox_V1alpha1_RemoveTracksRequest() 94 61 req.positions = [position] ··· 96 63 } 97 64 } 98 65 99 - func insertTracks( 100 - tracks: [String], 101 - position: Int32, 102 - shuffle: Bool = false, 103 - host: String = "127.0.0.1", 104 - port: Int = 6061 105 - ) async throws { 106 - try await withGRPCClient( 107 - transport: .http2NIOPosix( 108 - target: .dns(host: host, port: port), 109 - transportSecurity: .plaintext 110 - ) 111 - ) { grpcClient in 66 + func insertTracks(tracks: [String], position: Int32, shuffle: Bool = false) async throws { 67 + try await withRockboxGRPCClient { grpcClient in 112 68 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 113 69 var req = Rockbox_V1alpha1_InsertTracksRequest() 114 70 req.tracks = tracks ··· 118 74 } 119 75 } 120 76 121 - func insertAlbum( 122 - albumID: String, 123 - position: Int32, 124 - shuffle: Bool = false, 125 - host: String = "127.0.0.1", 126 - port: Int = 6061 127 - ) async throws { 128 - try await withGRPCClient( 129 - transport: .http2NIOPosix( 130 - target: .dns(host: host, port: port), 131 - transportSecurity: .plaintext 132 - ) 133 - ) { grpcClient in 77 + func insertAlbum(albumID: String, position: Int32, shuffle: Bool = false) async throws { 78 + try await withRockboxGRPCClient { grpcClient in 134 79 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 135 80 var req = Rockbox_V1alpha1_InsertAlbumRequest() 136 81 req.albumID = albumID ··· 141 86 } 142 87 143 88 func insertDirectory( 144 - directory: String, 145 - position: Int32, 146 - recurse: Bool = false, 147 - shuffle: Bool = false, 148 - host: String = "127.0.0.1", 149 - port: Int = 6061 89 + directory: String, position: Int32, recurse: Bool = false, shuffle: Bool = false 150 90 ) async throws { 151 - try await withGRPCClient( 152 - transport: .http2NIOPosix( 153 - target: .dns(host: host, port: port), 154 - transportSecurity: .plaintext 155 - ) 156 - ) { grpcClient in 91 + try await withRockboxGRPCClient { grpcClient in 157 92 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 158 93 var req = Rockbox_V1alpha1_InsertDirectoryRequest() 159 94 req.directory = directory ··· 164 99 } 165 100 } 166 101 167 - func clearPlaylist(host: String = "127.0.0.1", port: Int = 6061) async throws 168 - { 169 - try await withGRPCClient( 170 - transport: .http2NIOPosix( 171 - target: .dns(host: host, port: port), 172 - transportSecurity: .plaintext 173 - ) 174 - ) { grpcClient in 102 + func clearPlaylist() async throws { 103 + try await withRockboxGRPCClient { grpcClient in 175 104 let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 176 105 let req = Rockbox_V1alpha1_RemoveAllTracksRequest() 177 106 let _ = try await playlist.removeAllTracks(req)
+18 -93
macos/Rockbox/Services/SavedPlaylistService.swift
··· 6 6 import GRPCCore 7 7 import GRPCNIOTransportHTTP2 8 8 9 - func fetchSavedPlaylists( 10 - folderID: String? = nil, 11 - host: String = "127.0.0.1", 12 - port: Int = 6061 13 - ) async throws -> [Rockbox_V1alpha1_SavedPlaylist] { 14 - try await withGRPCClient( 15 - transport: .http2NIOPosix( 16 - target: .dns(host: host, port: port), 17 - transportSecurity: .plaintext 18 - ) 19 - ) { grpcClient in 9 + func fetchSavedPlaylists(folderID: String? = nil) async throws -> [Rockbox_V1alpha1_SavedPlaylist] { 10 + try await withRockboxGRPCClient { grpcClient in 20 11 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 21 12 var req = Rockbox_V1alpha1_GetSavedPlaylistsRequest() 22 13 if let fid = folderID { req.folderID = fid } ··· 25 16 } 26 17 } 27 18 28 - func fetchSavedPlaylist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 29 - -> Rockbox_V1alpha1_SavedPlaylist? 30 - { 31 - try await withGRPCClient( 32 - transport: .http2NIOPosix( 33 - target: .dns(host: host, port: port), 34 - transportSecurity: .plaintext 35 - ) 36 - ) { grpcClient in 19 + func fetchSavedPlaylist(id: String) async throws -> Rockbox_V1alpha1_SavedPlaylist? { 20 + try await withRockboxGRPCClient { grpcClient in 37 21 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 38 22 var req = Rockbox_V1alpha1_GetSavedPlaylistRequest() 39 23 req.id = id ··· 43 27 } 44 28 45 29 func createSavedPlaylist( 46 - name: String, 47 - description: String? = nil, 48 - trackIDs: [String] = [], 49 - host: String = "127.0.0.1", 50 - port: Int = 6061 30 + name: String, description: String? = nil, trackIDs: [String] = [] 51 31 ) async throws -> Rockbox_V1alpha1_SavedPlaylist { 52 - try await withGRPCClient( 53 - transport: .http2NIOPosix( 54 - target: .dns(host: host, port: port), 55 - transportSecurity: .plaintext 56 - ) 57 - ) { grpcClient in 32 + try await withRockboxGRPCClient { grpcClient in 58 33 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 59 34 var req = Rockbox_V1alpha1_CreateSavedPlaylistRequest() 60 35 req.name = name ··· 65 40 } 66 41 } 67 42 68 - func updateSavedPlaylist( 69 - id: String, 70 - name: String, 71 - description: String? = nil, 72 - host: String = "127.0.0.1", 73 - port: Int = 6061 74 - ) async throws { 75 - try await withGRPCClient( 76 - transport: .http2NIOPosix( 77 - target: .dns(host: host, port: port), 78 - transportSecurity: .plaintext 79 - ) 80 - ) { grpcClient in 43 + func updateSavedPlaylist(id: String, name: String, description: String? = nil) async throws { 44 + try await withRockboxGRPCClient { grpcClient in 81 45 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 82 46 var req = Rockbox_V1alpha1_UpdateSavedPlaylistRequest() 83 47 req.id = id ··· 87 51 } 88 52 } 89 53 90 - func deleteSavedPlaylist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 91 - try await withGRPCClient( 92 - transport: .http2NIOPosix( 93 - target: .dns(host: host, port: port), 94 - transportSecurity: .plaintext 95 - ) 96 - ) { grpcClient in 54 + func deleteSavedPlaylist(id: String) async throws { 55 + try await withRockboxGRPCClient { grpcClient in 97 56 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 98 57 var req = Rockbox_V1alpha1_DeleteSavedPlaylistRequest() 99 58 req.id = id ··· 101 60 } 102 61 } 103 62 104 - func fetchSavedPlaylistTracks( 105 - playlistID: String, 106 - host: String = "127.0.0.1", 107 - port: Int = 6061 108 - ) async throws -> [String] { 109 - try await withGRPCClient( 110 - transport: .http2NIOPosix( 111 - target: .dns(host: host, port: port), 112 - transportSecurity: .plaintext 113 - ) 114 - ) { grpcClient in 63 + func fetchSavedPlaylistTracks(playlistID: String) async throws -> [String] { 64 + try await withRockboxGRPCClient { grpcClient in 115 65 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 116 66 var req = Rockbox_V1alpha1_GetSavedPlaylistTracksRequest() 117 67 req.playlistID = playlistID ··· 120 70 } 121 71 } 122 72 123 - func addTracksToSavedPlaylist( 124 - playlistID: String, 125 - trackIDs: [String], 126 - host: String = "127.0.0.1", 127 - port: Int = 6061 128 - ) async throws { 129 - try await withGRPCClient( 130 - transport: .http2NIOPosix( 131 - target: .dns(host: host, port: port), 132 - transportSecurity: .plaintext 133 - ) 134 - ) { grpcClient in 73 + func addTracksToSavedPlaylist(playlistID: String, trackIDs: [String]) async throws { 74 + try await withRockboxGRPCClient { grpcClient in 135 75 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 136 76 var req = Rockbox_V1alpha1_AddTracksToSavedPlaylistRequest() 137 77 req.playlistID = playlistID ··· 140 80 } 141 81 } 142 82 143 - func removeTrackFromSavedPlaylist( 144 - playlistID: String, 145 - trackID: String, 146 - host: String = "127.0.0.1", 147 - port: Int = 6061 148 - ) async throws { 149 - try await withGRPCClient( 150 - transport: .http2NIOPosix( 151 - target: .dns(host: host, port: port), 152 - transportSecurity: .plaintext 153 - ) 154 - ) { grpcClient in 83 + func removeTrackFromSavedPlaylist(playlistID: String, trackID: String) async throws { 84 + try await withRockboxGRPCClient { grpcClient in 155 85 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 156 86 var req = Rockbox_V1alpha1_RemoveTrackFromSavedPlaylistRequest() 157 87 req.playlistID = playlistID ··· 160 90 } 161 91 } 162 92 163 - func playSavedPlaylist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 164 - try await withGRPCClient( 165 - transport: .http2NIOPosix( 166 - target: .dns(host: host, port: port), 167 - transportSecurity: .plaintext 168 - ) 169 - ) { grpcClient in 93 + func playSavedPlaylist(id: String) async throws { 94 + try await withRockboxGRPCClient { grpcClient in 170 95 let service = Rockbox_V1alpha1_SavedPlaylistService.Client(wrapping: grpcClient) 171 96 var req = Rockbox_V1alpha1_PlaySavedPlaylistRequest() 172 97 req.playlistID = id
+3 -14
macos/Rockbox/Services/SearchService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func searchTrack(query: String, host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> Rockbox_V1alpha1_SearchResponse 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func searchTrack(query: String) async throws -> Rockbox_V1alpha1_SearchResponse { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 22 - 23 15 var req = Rockbox_V1alpha1_SearchRequest() 24 16 req.term = query 25 - 26 - let res = try await library.search(req) 27 - 28 - return res 17 + return try await library.search(req) 29 18 } 30 19 }
+71
macos/Rockbox/Services/ServerInfo.swift
··· 1 + // 2 + // ServerInfo.swift 3 + // Rockbox 4 + // 5 + 6 + import Foundation 7 + import GRPCCore 8 + import GRPCNIOTransportHTTP2 9 + 10 + // MARK: - Server model 11 + 12 + struct RockboxServerInfo: Identifiable, Equatable, Hashable { 13 + var id: String { host } 14 + let name: String 15 + let host: String 16 + let grpcPort: Int 17 + let graphqlPort: Int 18 + let httpPort: Int 19 + 20 + static let localhost = RockboxServerInfo( 21 + name: "localhost", host: "127.0.0.1", 22 + grpcPort: 6061, graphqlPort: 6062, httpPort: 6063 23 + ) 24 + 25 + var coversBaseURL: String { "http://\(host):\(graphqlPort)/covers/" } 26 + var httpBaseURL: String { "http://\(host):\(httpPort)" } 27 + 28 + var displayName: String { 29 + if host == "127.0.0.1" || host == "localhost" { return "localhost" } 30 + if !name.isEmpty && name != host { return "\(name) (\(host))" } 31 + return host 32 + } 33 + 34 + var isLocalhost: Bool { host == "127.0.0.1" || host == "localhost" } 35 + } 36 + 37 + // MARK: - Thread-safe config 38 + 39 + final class ServerConfig { 40 + static let shared = ServerConfig() 41 + private let lock = NSLock() 42 + private var _info: RockboxServerInfo = .localhost 43 + 44 + private init() {} 45 + 46 + var grpcHost: String { lock.withLock { _info.host } } 47 + var grpcPort: Int { lock.withLock { _info.grpcPort } } 48 + var coversBaseURL: String { lock.withLock { _info.coversBaseURL } } 49 + var httpBaseURL: String { lock.withLock { _info.httpBaseURL } } 50 + 51 + func set(_ info: RockboxServerInfo) { lock.withLock { _info = info } } 52 + func current() -> RockboxServerInfo { lock.withLock { _info } } 53 + } 54 + 55 + // MARK: - gRPC convenience wrapper 56 + // 57 + // The generated stubs are generic over Transport, so the body closure must receive 58 + // the concrete GRPCClient<HTTP2ClientTransport.Posix> rather than an existential. 59 + 60 + func withRockboxGRPCClient<R: Sendable>( 61 + _ body: (GRPCClient<HTTP2ClientTransport.Posix>) async throws -> R 62 + ) async throws -> R { 63 + let host = ServerConfig.shared.grpcHost 64 + let port = ServerConfig.shared.grpcPort 65 + return try await withGRPCClient( 66 + transport: try HTTP2ClientTransport.Posix.http2NIOPosix( 67 + target: .dns(host: host, port: port), 68 + transportSecurity: .plaintext 69 + ) 70 + ) { client in try await body(client) } 71 + }
+191
macos/Rockbox/Services/ServerManager.swift
··· 1 + // 2 + // ServerManager.swift 3 + // Rockbox 4 + // 5 + 6 + import Foundation 7 + import Network 8 + import SwiftUI 9 + 10 + // MARK: - Observable manager (UI layer) 11 + 12 + @MainActor 13 + class ServerManager: ObservableObject { 14 + static let shared = ServerManager() 15 + 16 + @Published var currentServer: RockboxServerInfo = .localhost 17 + @Published var discoveredServers: [RockboxServerInfo] = [] 18 + @Published var isScanning = false 19 + 20 + private var scanner: MDNSScanner? 21 + 22 + private init() { 23 + // Kick off an automatic background scan on startup. 24 + Task { await scan() } 25 + } 26 + 27 + func selectServer(_ info: RockboxServerInfo) { 28 + currentServer = info 29 + ServerConfig.shared.set(info) 30 + NotificationCenter.default.post(name: .rockboxServerDidChange, object: nil) 31 + } 32 + 33 + func scan() async { 34 + guard !isScanning else { return } 35 + isScanning = true 36 + let s = MDNSScanner() 37 + scanner = s 38 + let found = await s.scan(timeout: 5) 39 + discoveredServers = found 40 + isScanning = false 41 + scanner = nil 42 + } 43 + } 44 + 45 + extension Notification.Name { 46 + static let rockboxServerDidChange = Notification.Name("rockboxServerDidChange") 47 + } 48 + 49 + // MARK: - mDNS scanner (NWBrowser + NetService resolution) 50 + 51 + final class MDNSScanner { 52 + private var browser: NWBrowser? 53 + private var pendingResolvers: [NetServiceResolver] = [] 54 + private var continuation: CheckedContinuation<[RockboxServerInfo], Never>? 55 + private var timer: DispatchWorkItem? 56 + 57 + // Accumulated port info per host (we see grpc/graphql/http services separately) 58 + private var serversByHost: [String: RockboxServerInfo] = [:] 59 + private let lock = NSLock() 60 + 61 + func scan(timeout: TimeInterval) async -> [RockboxServerInfo] { 62 + await withCheckedContinuation { cont in 63 + self.continuation = cont 64 + 65 + let params = NWParameters() 66 + params.includePeerToPeer = true 67 + let b = NWBrowser(for: .bonjour(type: "_rockbox._tcp", domain: "local"), using: params) 68 + self.browser = b 69 + 70 + b.browseResultsChangedHandler = { [weak self] _, changes in 71 + guard let self = self else { return } 72 + for change in changes { 73 + guard case .added(let result) = change else { continue } 74 + if case .service(let name, let type, let domain, _) = result.endpoint { 75 + let svc = NetService(domain: domain, type: type, name: name) 76 + let resolver = NetServiceResolver(service: svc) { [weak self] host, port in 77 + guard let self = self, !host.isEmpty else { return } 78 + self.lock.withLock { 79 + var entry = self.serversByHost[host] ?? RockboxServerInfo( 80 + name: host, host: host, grpcPort: 6061, graphqlPort: 6062, httpPort: 6063 81 + ) 82 + if name.hasPrefix("grpc-") { 83 + entry = RockboxServerInfo( 84 + name: entry.name, host: host, 85 + grpcPort: port, graphqlPort: entry.graphqlPort, httpPort: entry.httpPort 86 + ) 87 + } else if name.hasPrefix("graphql-") { 88 + entry = RockboxServerInfo( 89 + name: entry.name, host: host, 90 + grpcPort: entry.grpcPort, graphqlPort: port, httpPort: entry.httpPort 91 + ) 92 + } else if name.hasPrefix("http-") { 93 + entry = RockboxServerInfo( 94 + name: entry.name, host: host, 95 + grpcPort: entry.grpcPort, graphqlPort: entry.graphqlPort, httpPort: port 96 + ) 97 + } 98 + self.serversByHost[host] = entry 99 + } 100 + } 101 + self.pendingResolvers.append(resolver) 102 + resolver.start() 103 + } 104 + } 105 + } 106 + 107 + b.stateUpdateHandler = { _ in } 108 + b.start(queue: .main) 109 + 110 + let item = DispatchWorkItem { [weak self] in self?.finish() } 111 + self.timer = item 112 + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: item) 113 + } 114 + } 115 + 116 + private func finish() { 117 + guard let cont = continuation else { return } 118 + continuation = nil 119 + browser?.cancel() 120 + browser = nil 121 + timer = nil 122 + for r in pendingResolvers { r.stop() } 123 + pendingResolvers = [] 124 + let servers = lock.withLock { 125 + serversByHost.values 126 + .filter { !$0.isLocalhost } 127 + .sorted { $0.host < $1.host } 128 + } 129 + cont.resume(returning: servers) 130 + } 131 + } 132 + 133 + // MARK: - NetService resolver helper 134 + 135 + final class NetServiceResolver: NSObject, NetServiceDelegate { 136 + private let service: NetService 137 + private let completion: (String, Int) -> Void 138 + 139 + init(service: NetService, completion: @escaping (String, Int) -> Void) { 140 + self.service = service 141 + self.completion = completion 142 + super.init() 143 + service.delegate = self 144 + } 145 + 146 + func start() { 147 + service.schedule(in: .main, forMode: .default) 148 + service.resolve(withTimeout: 4) 149 + } 150 + 151 + func stop() { 152 + service.stop() 153 + } 154 + 155 + func netServiceDidResolveAddress(_ sender: NetService) { 156 + let host = resolvedHost(from: sender) 157 + guard !host.isEmpty else { return } 158 + completion(host, sender.port) 159 + } 160 + 161 + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {} 162 + 163 + private func resolvedHost(from sender: NetService) -> String { 164 + // Always prefer a raw IPv4 address from the sockaddr list. 165 + // `hostName` is a .local mDNS hostname that may resolve to an IPv6 link-local 166 + // address, causing gRPC connections to fail or reach the wrong interface. 167 + if let addresses = sender.addresses { 168 + for data in addresses { 169 + // On Darwin sockaddr_in: sin_len at offset 0, sin_family at offset 1 170 + let af = data.withUnsafeBytes { ptr -> UInt8 in 171 + ptr.baseAddress!.advanced(by: 1).assumingMemoryBound(to: UInt8.self).pointee 172 + } 173 + if Int32(af) == AF_INET { 174 + let ip = data.withUnsafeBytes { ptr -> String? in 175 + var sin = ptr.load(as: sockaddr_in.self) 176 + var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) 177 + guard inet_ntop(AF_INET, &sin.sin_addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } 178 + return String(cString: buf) 179 + } 180 + if let ip = ip, !ip.isEmpty { return ip } 181 + } 182 + } 183 + } 184 + // Last resort: strip trailing dot from hostName (may still be a hostname, not an IP). 185 + if let raw = sender.hostName { 186 + let h = raw.hasSuffix(".") ? String(raw.dropLast()) : raw 187 + if !h.isEmpty { return h } 188 + } 189 + return "" 190 + } 191 + }
+17 -39
macos/Rockbox/Services/SettingsService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchGlobalSettings(host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> Rockbox_V1alpha1_GetGlobalSettingsResponse 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchGlobalSettings() async throws -> Rockbox_V1alpha1_GetGlobalSettingsResponse { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 22 - 23 15 let req = Rockbox_V1alpha1_GetGlobalSettingsRequest() 24 - let res = try await settings.getGlobalSettings(req) 25 - 26 - return res 16 + return try await settings.getGlobalSettings(req) 27 17 } 28 18 } 29 19 30 - func updatePlaylistShuffle(enabled: Bool, host: String = "127.0.0.1", port: Int = 6061) async throws { 31 - try await withGRPCClient( 32 - transport: .http2NIOPosix( 33 - target: .dns(host: host, port: port), 34 - transportSecurity: .plaintext 35 - ) 36 - ) { grpcClient in 37 - let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 38 - 39 - var req = Rockbox_V1alpha1_SaveSettingsRequest() 40 - req.playlistShuffle = enabled 41 - let _ = try await settings.saveSettings(req) 42 - } 20 + func updatePlaylistShuffle(enabled: Bool) async throws { 21 + try await withRockboxGRPCClient { grpcClient in 22 + let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 23 + var req = Rockbox_V1alpha1_SaveSettingsRequest() 24 + req.playlistShuffle = enabled 25 + let _ = try await settings.saveSettings(req) 26 + } 43 27 } 44 28 45 - func updateRepeatMode(repeatMode: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws { 46 - try await withGRPCClient( 47 - transport: .http2NIOPosix( 48 - target: .dns(host: host, port: port), 49 - transportSecurity: .plaintext 50 - ) 51 - ) { grpcClient in 52 - let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 53 - 54 - var req = Rockbox_V1alpha1_SaveSettingsRequest() 55 - req.repeatMode = repeatMode 56 - let _ = try await settings.saveSettings(req) 57 - } 29 + func updateRepeatMode(repeatMode: Int32) async throws { 30 + try await withRockboxGRPCClient { grpcClient in 31 + let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 32 + var req = Rockbox_V1alpha1_SaveSettingsRequest() 33 + req.repeatMode = repeatMode 34 + let _ = try await settings.saveSettings(req) 35 + } 58 36 }
+10 -41
macos/Rockbox/Services/SmartPlaylistService.swift
··· 6 6 import GRPCCore 7 7 import GRPCNIOTransportHTTP2 8 8 9 - func fetchSmartPlaylists(host: String = "127.0.0.1", port: Int = 6061) async throws 10 - -> [Rockbox_V1alpha1_SmartPlaylist] 11 - { 12 - try await withGRPCClient( 13 - transport: .http2NIOPosix( 14 - target: .dns(host: host, port: port), 15 - transportSecurity: .plaintext 16 - ) 17 - ) { grpcClient in 9 + func fetchSmartPlaylists() async throws -> [Rockbox_V1alpha1_SmartPlaylist] { 10 + try await withRockboxGRPCClient { grpcClient in 18 11 let service = Rockbox_V1alpha1_SmartPlaylistService.Client(wrapping: grpcClient) 19 12 let req = Rockbox_V1alpha1_GetSmartPlaylistsRequest() 20 13 let res = try await service.getSmartPlaylists(req) ··· 22 15 } 23 16 } 24 17 25 - func fetchSmartPlaylistTracks( 26 - id: String, 27 - host: String = "127.0.0.1", 28 - port: Int = 6061 29 - ) async throws -> [String] { 30 - try await withGRPCClient( 31 - transport: .http2NIOPosix( 32 - target: .dns(host: host, port: port), 33 - transportSecurity: .plaintext 34 - ) 35 - ) { grpcClient in 18 + func fetchSmartPlaylistTracks(id: String) async throws -> [String] { 19 + try await withRockboxGRPCClient { grpcClient in 36 20 let service = Rockbox_V1alpha1_SmartPlaylistService.Client(wrapping: grpcClient) 37 21 var req = Rockbox_V1alpha1_GetSmartPlaylistTracksRequest() 38 22 req.id = id ··· 41 25 } 42 26 } 43 27 44 - func playSmartPlaylist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 45 - try await withGRPCClient( 46 - transport: .http2NIOPosix( 47 - target: .dns(host: host, port: port), 48 - transportSecurity: .plaintext 49 - ) 50 - ) { grpcClient in 28 + func playSmartPlaylist(id: String) async throws { 29 + try await withRockboxGRPCClient { grpcClient in 51 30 let service = Rockbox_V1alpha1_SmartPlaylistService.Client(wrapping: grpcClient) 52 31 var req = Rockbox_V1alpha1_PlaySmartPlaylistRequest() 53 32 req.id = id ··· 55 34 } 56 35 } 57 36 58 - func deleteSmartPlaylist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 59 - try await withGRPCClient( 60 - transport: .http2NIOPosix( 61 - target: .dns(host: host, port: port), 62 - transportSecurity: .plaintext 63 - ) 64 - ) { grpcClient in 37 + func deleteSmartPlaylist(id: String) async throws { 38 + try await withRockboxGRPCClient { grpcClient in 65 39 let service = Rockbox_V1alpha1_SmartPlaylistService.Client(wrapping: grpcClient) 66 40 var req = Rockbox_V1alpha1_DeleteSmartPlaylistRequest() 67 41 req.id = id ··· 69 43 } 70 44 } 71 45 72 - func recordTrackPlayed(trackID: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 73 - try await withGRPCClient( 74 - transport: .http2NIOPosix( 75 - target: .dns(host: host, port: port), 76 - transportSecurity: .plaintext 77 - ) 78 - ) { grpcClient in 46 + func recordTrackPlayed(trackID: String) async throws { 47 + try await withRockboxGRPCClient { grpcClient in 79 48 let service = Rockbox_V1alpha1_SmartPlaylistService.Client(wrapping: grpcClient) 80 49 var req = Rockbox_V1alpha1_RecordTrackPlayedRequest() 81 50 req.trackID = trackID
+3 -13
macos/Rockbox/Services/SystemService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchGlobalStatus(host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> Rockbox_V1alpha1_GetGlobalStatusResponse 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchGlobalStatus() async throws -> Rockbox_V1alpha1_GetGlobalStatusResponse { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let system = Rockbox_V1alpha1_SystemService.Client(wrapping: grpcClient) 22 - 23 15 let req = Rockbox_V1alpha1_GetGlobalStatusRequest() 24 - let res = try await system.getGlobalStatus(req) 25 - 26 - return res 16 + return try await system.getGlobalStatus(req) 27 17 } 28 18 }
+13 -55
macos/Rockbox/Services/TrackService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 13 - -> Rockbox_V1alpha1_Track 14 - { 15 - try await withGRPCClient( 16 - transport: .http2NIOPosix( 17 - target: .dns(host: host, port: port), 18 - transportSecurity: .plaintext 19 - ) 20 - ) { grpcClient in 12 + func fetchTrack(id: String) async throws -> Rockbox_V1alpha1_Track { 13 + try await withRockboxGRPCClient { grpcClient in 21 14 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 22 15 var req = Rockbox_V1alpha1_GetTrackRequest() 23 16 req.id = id 24 - let res = try await library.getTrack(req) 25 - return res.track 17 + return try await library.getTrack(req).track 26 18 } 27 19 } 28 20 29 - func fetchTracks(host: String = "127.0.0.1", port: Int = 6061) async throws 30 - -> [Rockbox_V1alpha1_Track] 31 - { 32 - try await withGRPCClient( 33 - transport: .http2NIOPosix( 34 - target: .dns(host: host, port: port), 35 - transportSecurity: .plaintext 36 - ) 37 - ) { grpcClient in 21 + func fetchTracks() async throws -> [Rockbox_V1alpha1_Track] { 22 + try await withRockboxGRPCClient { grpcClient in 38 23 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 39 - 40 24 let req = Rockbox_V1alpha1_GetTracksRequest() 41 - 42 - let res = try await library.getTracks(req) 43 - 44 - return res.tracks 25 + return try await library.getTracks(req).tracks 45 26 } 46 27 } 47 28 48 - func fetchLikedTracks(host: String = "127.0.0.1", port: Int = 6061) async throws 49 - -> [Rockbox_V1alpha1_Track] 50 - { 51 - try await withGRPCClient( 52 - transport: .http2NIOPosix( 53 - target: .dns(host: host, port: port), 54 - transportSecurity: .plaintext 55 - ) 56 - ) { grpcClient in 29 + func fetchLikedTracks() async throws -> [Rockbox_V1alpha1_Track] { 30 + try await withRockboxGRPCClient { grpcClient in 57 31 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 58 - 59 32 let req = Rockbox_V1alpha1_GetLikedTracksRequest() 60 - 61 - let res = try await library.getLikedTracks(req) 62 - 63 - return res.tracks 33 + return try await library.getLikedTracks(req).tracks 64 34 } 65 35 } 66 36 67 - func likeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 68 - try await withGRPCClient( 69 - transport: .http2NIOPosix( 70 - target: .dns(host: host, port: port), 71 - transportSecurity: .plaintext 72 - ) 73 - ) { grpcClient in 37 + func likeTrack(id: String) async throws { 38 + try await withRockboxGRPCClient { grpcClient in 74 39 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 75 - 76 40 var req = Rockbox_V1alpha1_LikeTrackRequest() 77 41 req.id = id 78 42 let _ = try await library.likeTrack(req) 79 43 } 80 44 } 81 45 82 - func unlikeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 83 - try await withGRPCClient( 84 - transport: .http2NIOPosix( 85 - target: .dns(host: host, port: port), 86 - transportSecurity: .plaintext 87 - ) 88 - ) { grpcClient in 46 + func unlikeTrack(id: String) async throws { 47 + try await withRockboxGRPCClient { grpcClient in 89 48 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 90 - 91 49 var req = Rockbox_V1alpha1_UnlikeTrackRequest() 92 50 req.id = id 93 51 let _ = try await library.unlikeTrack(req)
+1 -1
macos/Rockbox/State/NavigationManager.swift
··· 30 30 artist: albumData.artist, 31 31 year: Int(albumData.year), 32 32 color: .gray.opacity(0.3), 33 - cover: "http://localhost:6062/covers/" + albumData.albumArt, 33 + cover: ServerConfig.shared.coversBaseURL + albumData.albumArt, 34 34 releaseDate: albumData.yearString, 35 35 copyrightMessage: albumData.copyrightMessage, 36 36 artistID: albumData.artistID,
+13 -4
macos/Rockbox/State/PlayerState.swift
··· 46 46 init() { 47 47 setupMediaControls() 48 48 setInitialNowPlayingInfo() 49 + NotificationCenter.default.addObserver( 50 + forName: .rockboxServerDidChange, object: nil, queue: .main 51 + ) { [weak self] _ in 52 + Task { @MainActor [weak self] in 53 + self?.stopStreaming() 54 + self?.startStreaming() 55 + self?.fetchSettings() 56 + } 57 + } 49 58 } 50 59 51 60 private func setupMediaControls() { ··· 126 135 title: response.title, 127 136 artist: response.artist, 128 137 album: response.album, 129 - albumArt: URL(string: "http://localhost:6062/covers/" + response.albumArt), 138 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + response.albumArt), 130 139 duration: TimeInterval(response.length / 1000), 131 140 trackNumber: Int(response.tracknum), 132 141 discNumber: Int(response.discnum), ··· 196 205 title: track.title, 197 206 artist: track.artist, 198 207 album: track.album, 199 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 208 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 200 209 duration: TimeInterval(track.length / 1000), 201 210 trackNumber: Int(track.tracknum), 202 211 discNumber: Int(track.discnum), ··· 248 257 title: track.title, 249 258 artist: track.artist, 250 259 album: track.album, 251 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 260 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 252 261 duration: TimeInterval(track.length / 1000), 253 262 trackNumber: Int(track.tracknum), 254 263 discNumber: Int(track.discnum), ··· 295 304 title: track.title, 296 305 artist: track.artist, 297 306 album: track.album, 298 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 307 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 299 308 duration: TimeInterval(track.length / 1000), 300 309 trackNumber: Int(track.tracknum), 301 310 discNumber: Int(track.discnum),
+2 -2
macos/Rockbox/State/SearchManager.swift
··· 105 105 title: track.title, 106 106 artist: track.artist, 107 107 album: track.album, 108 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 108 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 109 109 duration: TimeInterval(track.length / 1000), 110 110 trackNumber: Int(track.trackNumber), 111 111 discNumber: Int(track.discNumber), ··· 121 121 artist: album.artist, 122 122 year: Int(album.year), 123 123 color: .gray.opacity(0.3), 124 - cover: "http://localhost:6062/covers/" + album.albumArt, 124 + cover: ServerConfig.shared.coversBaseURL + album.albumArt, 125 125 releaseDate: album.yearString, 126 126 copyrightMessage: album.copyrightMessage, 127 127 artistID: album.artistID,
+1 -1
macos/Rockbox/Views/AlbumDetail/AlbumDetailView.swift
··· 96 96 title: track.title, 97 97 artist: track.artist, 98 98 album: track.album, 99 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 99 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 100 100 duration: TimeInterval(track.length / 1000), 101 101 trackNumber: Int(track.trackNumber), 102 102 discNumber: Int(track.discNumber),
+1 -1
macos/Rockbox/Views/Albums/AlbumsGridView.swift
··· 41 41 artist: $0.artist, 42 42 year: Int($0.year), 43 43 color: .gray.opacity(0.3), 44 - cover: "http://localhost:6062/covers/" + $0.albumArt, 44 + cover: ServerConfig.shared.coversBaseURL + $0.albumArt, 45 45 releaseDate: $0.yearString, 46 46 copyrightMessage: $0.copyrightMessage, 47 47 artistID: $0.artistID,
+2 -2
macos/Rockbox/Views/ArtistDetail/ArtistDetailView.swift
··· 68 68 artist: album.artist, 69 69 year: Int(album.year), 70 70 color: .gray.opacity(0.3), 71 - cover: "http://localhost:6062/covers/" + album.albumArt, 71 + cover: ServerConfig.shared.coversBaseURL + album.albumArt, 72 72 releaseDate: album.yearString, 73 73 copyrightMessage: album.copyrightMessage, 74 74 artistID: album.artistID, ··· 83 83 title: track.title, 84 84 artist: track.artist, 85 85 album: track.album, 86 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 86 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 87 87 duration: TimeInterval(track.length / 1000), 88 88 trackNumber: Int(track.trackNumber), 89 89 discNumber: Int(track.discNumber),
+113
macos/Rockbox/Views/Components/ServerPickerView.swift
··· 1 + // 2 + // ServerPickerView.swift 3 + // Rockbox 4 + // 5 + 6 + import SwiftUI 7 + 8 + struct ServerPickerView: View { 9 + @ObservedObject var serverManager: ServerManager 10 + 11 + var body: some View { 12 + VStack(spacing: 0) { 13 + Divider() 14 + 15 + if serverManager.isScanning || !serverManager.discoveredServers.isEmpty { 16 + serverList 17 + } 18 + 19 + // Toggle / status row 20 + HStack(spacing: 6) { 21 + Image(systemName: "antenna.radiowaves.left.and.right") 22 + .font(.system(size: 11)) 23 + .foregroundStyle(.secondary) 24 + 25 + Text(serverManager.currentServer.displayName) 26 + .font(.system(size: 12)) 27 + .foregroundStyle(.secondary) 28 + .lineLimit(1) 29 + .truncationMode(.tail) 30 + 31 + Spacer() 32 + 33 + Button { 34 + Task { await serverManager.scan() } 35 + } label: { 36 + if serverManager.isScanning { 37 + ProgressView() 38 + .scaleEffect(0.6) 39 + .frame(width: 14, height: 14) 40 + } else { 41 + Image(systemName: "arrow.clockwise") 42 + .font(.system(size: 10)) 43 + .foregroundStyle(.secondary) 44 + } 45 + } 46 + .buttonStyle(.plain) 47 + .disabled(serverManager.isScanning) 48 + } 49 + .padding(.horizontal, 12) 50 + .padding(.vertical, 8) 51 + } 52 + } 53 + 54 + @ViewBuilder 55 + private var serverList: some View { 56 + VStack(spacing: 0) { 57 + // Header 58 + HStack { 59 + Text("Servers") 60 + .font(.system(size: 10, weight: .semibold)) 61 + .foregroundStyle(.secondary) 62 + .textCase(.uppercase) 63 + Spacer() 64 + } 65 + .padding(.horizontal, 12) 66 + .padding(.top, 8) 67 + .padding(.bottom, 4) 68 + 69 + // Localhost row 70 + serverRow(RockboxServerInfo.localhost) 71 + 72 + // Discovered rows 73 + ForEach(serverManager.discoveredServers) { server in 74 + serverRow(server) 75 + } 76 + 77 + if serverManager.isScanning { 78 + HStack { 79 + Text("Scanning…") 80 + .font(.system(size: 11)) 81 + .foregroundStyle(.tertiary) 82 + Spacer() 83 + } 84 + .padding(.horizontal, 12) 85 + .padding(.vertical, 4) 86 + } 87 + } 88 + } 89 + 90 + @ViewBuilder 91 + private func serverRow(_ server: RockboxServerInfo) -> some View { 92 + let isActive = server.host == serverManager.currentServer.host 93 + Button { 94 + serverManager.selectServer(server) 95 + } label: { 96 + HStack(spacing: 8) { 97 + Circle() 98 + .fill(isActive ? Color(red: 0.22, green: 1.0, blue: 0.08) : Color.secondary.opacity(0.4)) 99 + .frame(width: 6, height: 6) 100 + Text(server.displayName) 101 + .font(.system(size: 12)) 102 + .foregroundStyle(isActive ? .primary : .secondary) 103 + .lineLimit(1) 104 + .truncationMode(.tail) 105 + Spacer() 106 + } 107 + .padding(.horizontal, 12) 108 + .padding(.vertical, 5) 109 + .background(isActive ? Color.accentColor.opacity(0.08) : Color.clear) 110 + } 111 + .buttonStyle(.plain) 112 + } 113 + }
+3
macos/Rockbox/Views/Components/Sidebar.swift
··· 10 10 struct Sidebar: View { 11 11 @Binding var selection: SidebarItem? 12 12 @EnvironmentObject var searchManager: SearchManager 13 + @ObservedObject var serverManager = ServerManager.shared 13 14 14 15 var body: some View { 15 16 ZStack { ··· 60 61 } 61 62 .listStyle(.sidebar) 62 63 .scrollContentBackground(.hidden) 64 + 65 + ServerPickerView(serverManager: serverManager) 63 66 } 64 67 } 65 68 }
+1 -1
macos/Rockbox/Views/Likes/LikesListView.swift
··· 99 99 title: track.title, 100 100 artist: track.artist, 101 101 album: track.album, 102 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 102 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 103 103 duration: TimeInterval(track.length / 1000), 104 104 trackNumber: Int(track.trackNumber), 105 105 discNumber: Int(track.discNumber),
+1 -1
macos/Rockbox/Views/Main/DetailView.swift
··· 39 39 title: track.title, 40 40 artist: track.artist, 41 41 album: track.album, 42 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 42 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 43 43 duration: TimeInterval(track.length / 1000), 44 44 trackNumber: Int(track.trackNumber), 45 45 discNumber: Int(track.discNumber),
+2 -2
macos/Rockbox/Views/PlaylistDetail/SavedPlaylistDetailView.swift
··· 50 50 .frame(width: 120, height: 120) 51 51 .overlay { 52 52 if let img = playlist.image, !img.isEmpty { 53 - CachedAsyncImage(url: URL(string: "http://localhost:6062/covers/" + img)) { phase in 53 + CachedAsyncImage(url: URL(string: ServerConfig.shared.coversBaseURL + img)) { phase in 54 54 switch phase { 55 55 case .success(let image): 56 56 image.resizable().aspectRatio(contentMode: .fill) ··· 203 203 title: track.title, 204 204 artist: track.artist, 205 205 album: track.album, 206 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 206 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 207 207 duration: TimeInterval(track.length / 1000), 208 208 trackNumber: Int(track.trackNumber), 209 209 discNumber: Int(track.discNumber),
+1 -1
macos/Rockbox/Views/PlaylistDetail/SmartPlaylistDetailView.swift
··· 140 140 title: track.title, 141 141 artist: track.artist, 142 142 album: track.album, 143 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 143 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 144 144 duration: TimeInterval(track.length / 1000), 145 145 trackNumber: Int(track.trackNumber), 146 146 discNumber: Int(track.discNumber),
+1 -1
macos/Rockbox/Views/Playlists/PlaylistCardView.swift
··· 23 23 .aspectRatio(1, contentMode: .fit) 24 24 .overlay { 25 25 if let imageURL = playlist.image, !imageURL.isEmpty { 26 - CachedAsyncImage(url: URL(string: "http://localhost:6062/covers/" + imageURL)) { phase in 26 + CachedAsyncImage(url: URL(string: ServerConfig.shared.coversBaseURL + imageURL)) { phase in 27 27 switch phase { 28 28 case .success(let image): 29 29 image.resizable().aspectRatio(contentMode: .fill)
+2 -2
macos/Rockbox/Views/Songs/SongsListView.swift
··· 73 73 title: track.title, 74 74 artist: track.artist, 75 75 album: track.album, 76 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 76 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 77 77 duration: TimeInterval(track.length / 1000), 78 78 trackNumber: Int(track.trackNumber), 79 79 discNumber: Int(track.discNumber), ··· 90 90 title: track.title, 91 91 artist: track.artist, 92 92 album: track.album, 93 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 93 + albumArt: URL(string: ServerConfig.shared.coversBaseURL + track.albumArt), 94 94 duration: TimeInterval(track.length / 1000), 95 95 trackNumber: Int(track.trackNumber), 96 96 discNumber: Int(track.discNumber),
+2 -1
webui/rockbox/src/Hooks/GraphQL.tsx
··· 963 963 }>; 964 964 965 965 966 - export type GetAlbumQuery = { __typename?: 'Query', album?: { __typename?: 'Album', id: string, title: string, artist: string, albumArt?: string | null, year: number, yearString: string, artistId: string, md5: string, tracks: Array<{ __typename?: 'Track', id?: string | null, title: string, tracknum: number, artist: string, album: string, discnum: number, albumArtist: string, artistId?: string | null, albumId?: string | null, path: string, length: number }> } | null }; 966 + export type GetAlbumQuery = { __typename?: 'Query', album?: { __typename?: 'Album', id: string, title: string, artist: string, albumArt?: string | null, year: number, yearString: string, artistId: string, md5: string, copyrightMessage?: string | null, tracks: Array<{ __typename?: 'Track', id?: string | null, title: string, tracknum: number, artist: string, album: string, discnum: number, albumArtist: string, artistId?: string | null, albumId?: string | null, path: string, length: number }> } | null }; 967 967 968 968 export type GetLikedTracksQueryVariables = Exact<{ [key: string]: never; }>; 969 969 ··· 1694 1694 yearString 1695 1695 artistId 1696 1696 md5 1697 + copyrightMessage 1697 1698 tracks { 1698 1699 id 1699 1700 title