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.

Use server covers base; prefer IPv4 for mDNS

Replace hardcoded "http://localhost:6062/covers/" with
crate::server::get_covers_base() across GPUI so cover URLs follow the
active server.

Improve mDNS scanning by ranking IPv4 addresses (192.168 → 10 → others)
and selecting the best non-loopback/link-local address so records for
the
same physical host coalesce.

Bluetooth/UI and macOS tweaks:
- Add MiniPlayer bluetooth button (shows when Bluetooth is available)
and open/fetch devices when toggled
- Update BluetoothPicker visuals (white text, green rounded icon)
- macOS app now listens for server-change notifications and restarts
streaming/fetches settings and device/bluetooth state

BluetoothService: check availability via fetchGlobalStatus() to avoid
transient UNIMPLEMENTED errors when probing getDevices()

+99 -33
+1 -1
gpui/src/now_playing.rs
··· 86 86 let cover_url = track 87 87 .and_then(|t| t.album_art.as_deref()) 88 88 .filter(|s| !s.is_empty()) 89 - .map(|name| format!("http://localhost:6062/covers/{}", name)); 89 + .map(|name| format!("{}{name}", crate::server::get_covers_base())); 90 90 91 91 // Don't touch MPNowPlayingInfoCenter until we have (or previously had) a track. 92 92 // Calling set_playback(Stopped) every 100ms during the startup window before
+30 -10
gpui/src/server.rs
··· 84 84 /// Blocking mDNS scan — returns all discovered rockboxd instances within `timeout`. 85 85 /// Looks for `_rockbox._tcp.local.` services; service names prefixed with `grpc-`, 86 86 /// `graphql-`, or `http-` update the corresponding port for that host. 87 + /// Rank an IPv4 address by how likely it is to be a real local-network address. 88 + /// Lower is better: 192.168.x.x → 0, 10.x.x.x → 1, others (172.x.x.x Docker bridges) → 2. 89 + fn addr_preference(a: &std::net::Ipv4Addr) -> u8 { 90 + let o = a.octets(); 91 + if o[0] == 192 && o[1] == 168 { 92 + 0 93 + } else if o[0] == 10 { 94 + 1 95 + } else { 96 + 2 97 + } 98 + } 99 + 100 + /// Pick the most-preferred non-loopback, non-link-local IPv4 address from a set. 101 + /// Prefers home-network ranges (192.168.x.x, 10.x.x.x) over Docker/VM bridge ranges 102 + /// (172.16.x.x – 172.31.x.x) so that all service records for the same physical host 103 + /// always resolve to the same key and get merged into a single ServerInfo entry. 104 + fn best_addr(addrs: &std::collections::HashSet<std::net::Ipv4Addr>) -> Option<String> { 105 + let mut candidates: Vec<std::net::Ipv4Addr> = addrs 106 + .iter() 107 + .filter(|a| !a.is_loopback() && !a.is_link_local()) 108 + .cloned() 109 + .collect(); 110 + candidates.sort_by_key(addr_preference); 111 + candidates.into_iter().next().map(|a| a.to_string()) 112 + } 113 + 87 114 pub fn scan_mdns(timeout: std::time::Duration) -> Vec<ServerInfo> { 88 115 use mdns_sd::{ServiceDaemon, ServiceEvent}; 89 116 use std::collections::HashMap; ··· 110 137 111 138 match receiver.recv_timeout(poll) { 112 139 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()) 140 + // Always pick the best-ranked address so that grpc-, graphql-, and http- 141 + // records for the same physical host all hash to the same key. 142 + let host = best_addr(info.get_addresses()) 123 143 .unwrap_or_else(|| info.get_hostname().trim_end_matches('.').to_string()); 124 144 let port = info.get_port(); 125 145 let fullname = info.get_fullname().to_string();
+5 -5
gpui/src/ui/components/bluetooth_picker.rs
··· 101 101 .gap_x_3() 102 102 .cursor_pointer() 103 103 .text_color(if is_connected { 104 - theme.player_icons_text_active 104 + gpui::rgb(0xFFFFFF) 105 105 } else { 106 106 theme.player_title_text 107 - }) 108 - .when(is_connected, |this: gpui::Stateful<gpui::Div>| { 109 - this.bg(theme.player_icons_bg_active) 110 107 }) 111 108 .hover(|this| this.bg(theme.player_icons_bg_hover)) 112 109 .on_click(move |_, _, cx: &mut App| { ··· 146 143 this.child( 147 144 div() 148 145 .flex_shrink_0() 149 - .child(Icon::new(Icons::Bluetooth).size_3()), 146 + .w(px(6.0)) 147 + .h(px(6.0)) 148 + .rounded_full() 149 + .bg(gpui::rgb(0x39FF14)), 150 150 ) 151 151 }) 152 152 })),
+30 -3
gpui/src/ui/components/miniplayer.rs
··· 1 1 use crate::client::{adjust_volume, save_repeat, save_shuffle}; 2 2 use crate::controller::Controller; 3 3 use crate::state::{ 4 - format_duration, volume_fraction, DevicesState, PlaybackStatus, VOLUME_MAX_DB, VOLUME_MIN_DB, 4 + format_duration, volume_fraction, BluetoothState, DevicesState, PlaybackStatus, VOLUME_MAX_DB, 5 + VOLUME_MIN_DB, 5 6 }; 7 + use crate::ui::components::bluetooth_picker::fetch_and_update_bluetooth_devices; 6 8 use crate::ui::components::device_picker::device_icon; 7 9 use crate::ui::components::icons::{Icon, Icons}; 8 10 use crate::ui::components::seek_bar::SeekBar; ··· 11 13 use crate::ui::theme::Theme; 12 14 use gpui::prelude::FluentBuilder; 13 15 use gpui::{ 14 - div, img, px, App, Context, FontWeight, InteractiveElement, IntoElement, 16 + div, img, px, App, Context, Div, FontWeight, InteractiveElement, IntoElement, 15 17 ObjectFit, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled, 16 18 StyledImage, Window, 17 19 }; ··· 46 48 .current_track() 47 49 .and_then(|t| t.album_art.as_deref()) 48 50 .filter(|s| !s.is_empty()) 49 - .map(|id| format!("http://localhost:6062/covers/{id}")); 51 + .map(|id| format!("{}{id}", crate::server::get_covers_base())); 50 52 let position = state.position; 51 53 let fill_fraction = if duration > 0 { 52 54 (position as f32 / duration as f32).clamp(0.0, 1.0) ··· 68 70 .map(|t| t.id.clone()) 69 71 .unwrap_or_default(); 70 72 let is_liked = liked_songs.contains(&track_id); 73 + let bluetooth_available = cx.global::<BluetoothState>().available; 71 74 72 75 div() 73 76 .w_full() ··· 386 389 .items_center() 387 390 .justify_end() 388 391 .gap_x_2() 392 + .when(bluetooth_available, |this: Div| { 393 + this.child( 394 + div() 395 + .id("mini_bluetooth") 396 + .p_1p5() 397 + .rounded_md() 398 + .flex() 399 + .items_center() 400 + .justify_center() 401 + .cursor_pointer() 402 + .text_color(theme.player_icons_text) 403 + .hover(|this| { 404 + this.bg(theme.player_icons_bg_hover) 405 + .text_color(theme.player_icons_text_hover) 406 + }) 407 + .on_click(move |_, _, cx: &mut App| { 408 + fetch_and_update_bluetooth_devices(cx); 409 + let mut state = cx.global::<BluetoothState>().clone(); 410 + state.picker_open = !state.picker_open; 411 + cx.set_global(state); 412 + }) 413 + .child(Icon::new(Icons::Bluetooth).size_4()), 414 + ) 415 + }) 389 416 .child( 390 417 div() 391 418 .id("mini_device")
+12 -10
gpui/src/ui/components/pages/library.rs
··· 25 25 StatefulInteractiveElement, Styled, StyledImage, Subscription, UniformListScrollHandle, Window, 26 26 }; 27 27 28 - const COVERS_BASE: &str = "http://localhost:6062/covers/"; 28 + fn covers_base() -> String { 29 + crate::server::get_covers_base() 30 + } 29 31 30 32 /// Parse "yyyy-MM-dd" into "9 December 2014". Falls back to the raw string on any parse failure. 31 33 fn format_release_date(s: &str) -> String { ··· 57 59 ) -> AnyElement { 58 60 let art_url = art 59 61 .filter(|s| !s.is_empty()) 60 - .map(|id| format!("{COVERS_BASE}{id}")); 62 + .map(|id| format!("{}{id}", covers_base())); 61 63 let mut container = div().w_full().rounded_lg().overflow_hidden(); 62 64 container.style().aspect_ratio = Some(1.0_f32); 63 65 if let Some(url) = art_url { ··· 85 87 ) -> AnyElement { 86 88 let art_url = art 87 89 .filter(|s| !s.is_empty()) 88 - .map(|id| format!("{COVERS_BASE}{id}")); 90 + .map(|id| format!("{}{id}", covers_base())); 89 91 if let Some(url) = art_url { 90 92 div() 91 93 .w(size) ··· 1006 1008 if s.starts_with("http") { 1007 1009 s.to_string() 1008 1010 } else { 1009 - format!("{COVERS_BASE}{s}") 1011 + format!("{}{s}", covers_base()) 1010 1012 } 1011 1013 }); 1012 1014 div() ··· 1102 1104 .album_art 1103 1105 .as_deref() 1104 1106 .filter(|s| !s.is_empty()) 1105 - .map(|id| format!("{COVERS_BASE}{id}")); 1107 + .map(|id| format!("{}{id}", covers_base())); 1106 1108 div() 1107 1109 .id(("sab", idx)) 1108 1110 .w(px(130.0)) ··· 1441 1443 let name_clone_opts = name.clone(); 1442 1444 let art_url = album_art 1443 1445 .filter(|s| !s.is_empty()) 1444 - .map(|id| format!("{COVERS_BASE}{id}")); 1446 + .map(|id| format!("{}{id}", covers_base())); 1445 1447 let art_url_opts = art_url.clone(); 1446 1448 let album_id_play = album_id.clone(); 1447 1449 let album_id_opts = album_id.clone(); ··· 1653 1655 if s.starts_with("http") { 1654 1656 s 1655 1657 } else { 1656 - format!("{COVERS_BASE}{s}") 1658 + format!("{}{s}", covers_base()) 1657 1659 } 1658 1660 }); 1659 1661 let mut container = div() ··· 2035 2037 if s.starts_with("http") { 2036 2038 s 2037 2039 } else { 2038 - format!("{COVERS_BASE}{s}") 2040 + format!("{}{s}", covers_base()) 2039 2041 } 2040 2042 }); 2041 2043 if let Some(url) = img_url { ··· 3740 3742 .album_art 3741 3743 .as_deref() 3742 3744 .filter(|s| !s.is_empty()) 3743 - .map(|id| format!("{COVERS_BASE}{id}")); 3745 + .map(|id| format!("{}{id}", covers_base())); 3744 3746 // header ~64px + 6 items × ~33px + separator + borders 3745 3747 let menu_w = px(240.0); 3746 3748 let menu_h = px(264.0); ··· 4115 4117 if s.starts_with("http") { 4116 4118 s.to_string() 4117 4119 } else { 4118 - format!("{COVERS_BASE}{s}") 4120 + format!("{}{s}", covers_base()) 4119 4121 } 4120 4122 }); 4121 4123 // header ~64px + 7 items × ~33px + borders
+1 -1
gpui/src/ui/components/pages/player.rs
··· 48 48 .current_track() 49 49 .and_then(|t| t.album_art.as_deref()) 50 50 .filter(|s| !s.is_empty()) 51 - .map(|id| format!("http://localhost:6062/covers/{id}")); 51 + .map(|id| format!("{}{id}", crate::server::get_covers_base())); 52 52 let bg_art_url = album_art_url.clone(); 53 53 let queue_total = state.queue.len(); 54 54 let queue_pos = state.current_idx.map(|i| i + 1);
+10
macos/Rockbox/RockboxApp.swift
··· 40 40 .task { 41 41 await performStartup() 42 42 } 43 + .onReceive(NotificationCenter.default.publisher(for: .rockboxServerDidChange)) { _ in 44 + Task { await onServerChanged() } 45 + } 43 46 } 44 47 .windowStyle(.hiddenTitleBar) 45 48 } ··· 75 78 startupError = error 76 79 startupFailed = true 77 80 } 81 + } 82 + 83 + private func onServerChanged() async { 84 + player.startStreaming() 85 + player.fetchSettings() 86 + await deviceState.refresh() 87 + await bluetoothState.checkAvailability() 78 88 } 79 89 80 90 private func retry() {
+4 -1
macos/Rockbox/Services/BluetoothService.swift
··· 18 18 19 19 @available(macOS 15.0, *) 20 20 func checkBluetoothAvailable() async -> Bool { 21 + // Mirror GPUI: bluetooth is considered available whenever the server is 22 + // reachable. Calling getDevices here made the button disappear whenever 23 + // the service returned UNIMPLEMENTED or any transient error. 21 24 do { 22 - _ = try await fetchBluetoothDevices() 25 + _ = try await fetchGlobalStatus() 23 26 return true 24 27 } catch { 25 28 return false
+6 -2
macos/Rockbox/Views/Main/DetailView.swift
··· 12 12 @ObservedObject var library: MusicLibrary 13 13 @EnvironmentObject var navigation: NavigationManager 14 14 @EnvironmentObject var searchManager: SearchManager 15 + @ObservedObject private var serverManager = ServerManager.shared 15 16 @Binding var showQueue: Bool 16 17 17 18 var body: some View { 18 19 VStack(spacing: 0) { 19 - // Main content area 20 + // Main content area — re-keyed on server host so all child .task{} 21 + // blocks re-run automatically when the user switches servers. 20 22 contentView 23 + .id(serverManager.currentServer.host) 21 24 .background(.white) 22 25 23 26 Divider() ··· 29 32 .onChange(of: selection) { 30 33 navigation.reset() 31 34 } 32 - .task { 35 + .task(id: serverManager.currentServer.host) { 36 + library.likedSongIds = [] 33 37 do { 34 38 let likes = try await fetchLikedTracks() 35 39 for track in likes {