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.

Paginate UPnP browse and cache results

Implement paginated UPnP Browse requests (RequestedCount=200) and loop
through pages, accumulating entries until no more are returned or
TotalMatches
is reached. Add parse_u32_tag helper and more robust error/log handling.

Client/UI changes:
- Prefetch UPnP device list into a TTL'd cache (upnp_cache) and add a
loading flag so initial UI open is instant and background fetches
don't
block rendering.
- macOS tweaks: add snapcast icon/color, simplify the device header,
show
loading/empty states for device list, and only set isLoading on first
refresh.
- Files list view: show ProgressView while loading and drive task key by
mode+path to avoid stale reloads.

+214 -133
+81 -39
crates/upnp/src/control_point.rs
··· 108 108 Err(_) => return vec![], 109 109 }; 110 110 111 - let soap_body = format!( 112 - r#"<?xml version="1.0"?> 111 + let mut all_entries: Vec<ContentEntry> = Vec::new(); 112 + let mut starting_index: u32 = 0; 113 + 114 + loop { 115 + let soap_body = format!( 116 + r#"<?xml version="1.0"?> 113 117 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 114 118 s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 115 119 <s:Body> ··· 117 121 <ObjectID>{}</ObjectID> 118 122 <BrowseFlag>BrowseDirectChildren</BrowseFlag> 119 123 <Filter>*</Filter> 120 - <StartingIndex>0</StartingIndex> 121 - <RequestedCount>0</RequestedCount> 124 + <StartingIndex>{}</StartingIndex> 125 + <RequestedCount>200</RequestedCount> 122 126 <SortCriteria></SortCriteria> 123 127 </u:Browse> 124 128 </s:Body> 125 129 </s:Envelope>"#, 126 - xml_escape(object_id) 127 - ); 130 + xml_escape(object_id), 131 + starting_index, 132 + ); 128 133 129 - let resp = match client 130 - .post(control_url) 131 - .header("Content-Type", r#"text/xml; charset="utf-8""#) 132 - .header( 133 - "SOAPAction", 134 - r#""urn:schemas-upnp-org:service:ContentDirectory:1#Browse""#, 135 - ) 136 - .body(soap_body) 137 - .send() 138 - .await 139 - { 140 - Ok(r) => r, 141 - Err(e) => { 142 - tracing::warn!("UPnP browse {control_url}: {e}"); 143 - return vec![]; 144 - } 145 - }; 134 + let resp = match client 135 + .post(control_url) 136 + .header("Content-Type", r#"text/xml; charset="utf-8""#) 137 + .header( 138 + "SOAPAction", 139 + r#""urn:schemas-upnp-org:service:ContentDirectory:1#Browse""#, 140 + ) 141 + .body(soap_body) 142 + .send() 143 + .await 144 + { 145 + Ok(r) => r, 146 + Err(e) => { 147 + tracing::warn!("UPnP browse {control_url}: {e}"); 148 + break; 149 + } 150 + }; 146 151 147 - let body = match resp.text().await { 148 - Ok(b) => b, 149 - Err(_) => return vec![], 150 - }; 152 + let body = match resp.text().await { 153 + Ok(b) => b, 154 + Err(_) => break, 155 + }; 151 156 152 - tracing::debug!("UPnP browse response body: {body}"); 157 + tracing::debug!("UPnP browse response body (start={starting_index}): {body}"); 153 158 154 - let result_xml = match extract_result_tag(&body) { 155 - Some(r) => html_unescape(&r), 156 - None => { 157 - tracing::warn!("UPnP browse: no <Result> tag found in response"); 158 - return vec![]; 159 - } 160 - }; 159 + let result_xml = match extract_result_tag(&body) { 160 + Some(r) => html_unescape(&r), 161 + None => { 162 + tracing::warn!("UPnP browse: no <Result> tag found in response"); 163 + break; 164 + } 165 + }; 161 166 162 - tracing::debug!("UPnP DIDL-Lite XML: {result_xml}"); 167 + tracing::debug!("UPnP DIDL-Lite XML: {result_xml}"); 163 168 164 - let entries = parse_didl_lite(&result_xml); 165 - tracing::debug!("UPnP parsed {} entries", entries.len()); 166 - entries 169 + let page = parse_didl_lite(&result_xml); 170 + let number_returned = parse_u32_tag(&body, "NumberReturned").unwrap_or(page.len() as u32); 171 + let total_matches = parse_u32_tag(&body, "TotalMatches").unwrap_or(0); 172 + 173 + tracing::debug!( 174 + "UPnP browse: start={} returned={} total={} parsed={}", 175 + starting_index, 176 + number_returned, 177 + total_matches, 178 + page.len() 179 + ); 180 + 181 + let fetched = page.len() as u32; 182 + all_entries.extend(page); 183 + 184 + // Stop if the server returned nothing, or we've collected everything. 185 + if fetched == 0 || number_returned == 0 { 186 + break; 187 + } 188 + starting_index += number_returned; 189 + if total_matches > 0 && starting_index >= total_matches { 190 + break; 191 + } 192 + // If the server doesn't report TotalMatches, stop when it returns fewer 193 + // items than we requested (last page). 194 + if total_matches == 0 && number_returned < 200 { 195 + break; 196 + } 197 + } 198 + 199 + tracing::debug!("UPnP browse total entries collected: {}", all_entries.len()); 200 + all_entries 167 201 } 168 202 169 203 // ── DIDL-Lite parser ────────────────────────────────────────────────────────── ··· 215 249 } 216 250 217 251 // ── XML/HTML helpers ────────────────────────────────────────────────────────── 252 + 253 + fn parse_u32_tag(xml: &str, tag: &str) -> Option<u32> { 254 + extract_tag(xml, tag) 255 + .or_else(|| extract_namespaced(xml, tag))? 256 + .trim() 257 + .parse() 258 + .ok() 259 + } 218 260 219 261 /// Extracts the content of the SOAP <Result> element, handling optional namespace prefixes 220 262 /// like <u:Result> that some UPnP servers emit.
+101 -41
gpui/src/ui/components/pages/files.rs
··· 11 11 InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, 12 12 WeakEntity, Window, 13 13 }; 14 + use std::collections::HashMap; 15 + use std::time::{Duration, Instant}; 16 + 17 + /// How long the UPnP device list (upnp://) stays fresh. SSDP is time-sensitive. 18 + const UPNP_DEVICE_TTL: Duration = Duration::from_secs(30); 19 + /// How long a browsed UPnP content folder stays fresh. Content is stable. 20 + const UPNP_CONTENT_TTL: Duration = Duration::from_secs(300); 21 + 22 + struct CacheEntry { 23 + entries: Vec<FileEntry>, 24 + fetched_at: Instant, 25 + } 14 26 15 27 pub struct FilesView { 16 28 entries: Vec<FileEntry>, 17 - upnp_devices: Vec<FileEntry>, 18 29 last_loaded: Option<(FilesMode, Option<String>)>, 30 + loading: bool, 31 + upnp_cache: HashMap<String, CacheEntry>, 19 32 } 20 33 21 34 impl FilesView { ··· 23 36 cx.set_global(FilesBrowseState::default()); 24 37 cx.set_global(FileContextMenuState::default()); 25 38 26 - // Kick off a background prefetch of UPnP devices so they appear instantly 27 - // when the user opens "UPnP Devices". The result is stored in upnp_devices. 39 + // Prefetch UPnP device list into the cache so the first open is instant. 28 40 let (tx, rx) = tokio::sync::oneshot::channel::<Vec<FileEntry>>(); 29 41 cx.global::<Controller>().rt().spawn(async move { 30 - let devices = 31 - crate::client::tree_get_entries(Some("upnp://".to_string())) 32 - .await 33 - .unwrap_or_default(); 42 + let devices = crate::client::tree_get_entries(Some("upnp://".to_string())) 43 + .await 44 + .unwrap_or_default(); 34 45 let _ = tx.send(devices); 35 46 }); 36 47 cx.spawn(async move |this: WeakEntity<FilesView>, cx| { 37 48 if let Ok(devices) = rx.await { 38 49 let _ = this.update(cx, |this, _cx| { 39 - this.upnp_devices = devices; 40 - // Do NOT notify or touch entries here — initial prefetch only 41 - // populates the cache; entries are set in load_if_needed. 50 + this.upnp_cache.insert( 51 + "upnp://".to_string(), 52 + CacheEntry { entries: devices, fetched_at: Instant::now() }, 53 + ); 42 54 }); 43 55 } 44 56 }) ··· 46 58 47 59 FilesView { 48 60 entries: Vec::new(), 49 - upnp_devices: Vec::new(), 50 61 last_loaded: None, 62 + loading: false, 63 + upnp_cache: HashMap::new(), 51 64 } 52 65 } 53 66 ··· 58 71 if self.last_loaded.as_ref() == Some(&key) { 59 72 return; 60 73 } 74 + 61 75 // Root mode renders static tiles — no fetch needed. 62 76 if browse.mode == FilesMode::Root { 63 77 self.entries.clear(); ··· 65 79 return; 66 80 } 67 81 68 - // UpnpDevices: show preloaded cache immediately, then re-fetch in background. 69 - if browse.mode == FilesMode::UpnpDevices { 70 - self.entries = self.upnp_devices.clone(); 71 - self.last_loaded = Some(key.clone()); 72 - // Re-fetch to refresh the device list (SSDP discovery is time-sensitive). 73 - let (tx, rx) = tokio::sync::oneshot::channel::<Vec<FileEntry>>(); 74 - cx.global::<Controller>().rt().spawn(async move { 75 - let devices = 76 - crate::client::tree_get_entries(Some("upnp://".to_string())) 77 - .await 78 - .unwrap_or_default(); 79 - let _ = tx.send(devices); 80 - }); 81 - cx.spawn(async move |this: WeakEntity<FilesView>, cx| { 82 - if let Ok(devices) = rx.await { 83 - let _ = this.update(cx, |this, cx| { 84 - this.upnp_devices = devices.clone(); 85 - // Only apply to displayed entries if still on the device list. 86 - let current_mode = cx.global::<FilesBrowseState>().mode.clone(); 87 - if current_mode == FilesMode::UpnpDevices { 88 - this.entries = devices; 89 - cx.notify(); 90 - } 91 - }); 82 + // Determine if this is a UPnP path and its cache key. 83 + let cache_key: Option<String> = match browse.mode { 84 + FilesMode::UpnpDevices => Some("upnp://".to_string()), 85 + FilesMode::UpnpBrowse => browse.current_path.clone(), 86 + _ => None, 87 + }; 88 + let ttl = match browse.mode { 89 + FilesMode::UpnpDevices => UPNP_DEVICE_TTL, 90 + _ => UPNP_CONTENT_TTL, 91 + }; 92 + 93 + if let Some(ref ck) = cache_key { 94 + let cached_entries = self.upnp_cache.get(ck).filter(|c| !c.entries.is_empty()); 95 + if let Some(cached) = cached_entries { 96 + let fresh = cached.fetched_at.elapsed() < ttl; 97 + // Show cached entries immediately — no spinner. 98 + self.entries = cached.entries.clone(); 99 + self.loading = false; 100 + self.last_loaded = Some(key.clone()); 101 + cx.notify(); 102 + 103 + if fresh { 104 + // Cache is warm — nothing more to do. 105 + return; 92 106 } 93 - }) 94 - .detach(); 107 + // Cache is stale: silent background refresh (stale-while-revalidate). 108 + self.spawn_fetch(cx, browse.current_path.clone(), key, Some(ck.clone())); 109 + return; 110 + } 111 + // Cache miss (or empty result from a previous failed fetch): show 112 + // loading spinner and fetch. 113 + self.entries.clear(); 114 + self.loading = true; 115 + self.last_loaded = Some(key.clone()); 116 + cx.notify(); 117 + self.spawn_fetch(cx, browse.current_path.clone(), key, Some(ck.clone())); 95 118 return; 96 119 } 97 120 121 + // Local filesystem — no caching, plain fetch with spinner. 122 + self.entries.clear(); 123 + self.loading = true; 98 124 self.last_loaded = Some(key.clone()); 99 - let path = browse.current_path.clone(); 125 + cx.notify(); 126 + self.spawn_fetch(cx, browse.current_path.clone(), key, None); 127 + } 100 128 129 + fn spawn_fetch( 130 + &self, 131 + cx: &mut Context<Self>, 132 + path: Option<String>, 133 + key: (FilesMode, Option<String>), 134 + cache_key: Option<String>, 135 + ) { 101 136 let (tx, rx) = tokio::sync::oneshot::channel::<Vec<FileEntry>>(); 102 137 cx.global::<Controller>().rt().spawn(async move { 103 138 let entries = crate::client::tree_get_entries(path).await.unwrap_or_default(); 104 139 let _ = tx.send(entries); 105 140 }); 106 - 107 141 cx.spawn(async move |this: WeakEntity<FilesView>, cx| { 108 142 if let Ok(entries) = rx.await { 109 143 let _ = this.update(cx, |this, cx| { 110 - // Only apply if the user is still on the same page that triggered 111 - // this fetch — guards against races when navigating quickly. 144 + // Always update the cache. 145 + if let Some(ref ck) = cache_key { 146 + this.upnp_cache.insert( 147 + ck.clone(), 148 + CacheEntry { entries: entries.clone(), fetched_at: Instant::now() }, 149 + ); 150 + } 151 + // Only update the displayed entries if the user is still 152 + // on the page that triggered this fetch. 112 153 let current_key = { 113 154 let browse = cx.global::<FilesBrowseState>(); 114 155 (browse.mode.clone(), browse.current_path.clone()) 115 156 }; 116 157 if current_key == key { 117 158 this.entries = entries; 159 + this.loading = false; 118 160 cx.notify(); 119 161 } 120 162 }); ··· 153 195 let current_dir = browse.current_path.clone().unwrap_or_default(); 154 196 let mode = browse.mode.clone(); 155 197 let entries = self.entries.clone(); 198 + let loading = self.loading; 156 199 157 200 div() 158 201 .size_full() ··· 199 242 // ── Content ─────────────────────────────────────────────────────── 200 243 .child(match mode { 201 244 FilesMode::Root => render_root(theme).into_any_element(), 245 + _ if loading => render_loading(theme).into_any_element(), 202 246 _ => render_entries(entries, current_dir, mode, theme).into_any_element(), 203 247 }) 204 248 } 249 + } 250 + 251 + fn render_loading(theme: Theme) -> AnyElement { 252 + div() 253 + .flex_1() 254 + .min_h_0() 255 + .flex() 256 + .items_center() 257 + .justify_center() 258 + .child( 259 + div() 260 + .text_sm() 261 + .text_color(theme.library_header_text) 262 + .child("Loading…"), 263 + ) 264 + .into_any_element() 205 265 } 206 266 207 267 fn render_root(theme: Theme) -> AnyElement {
+1 -1
macos/Rockbox/State/DeviceState.swift
··· 16 16 } 17 17 18 18 func refresh() async { 19 - isLoading = true 19 + if devices.isEmpty { isLoading = true } 20 20 do { 21 21 devices = try await fetchDevices() 22 22 } catch {
+24 -51
macos/Rockbox/Views/Components/DeviceListView.swift
··· 10 10 switch device.service { 11 11 case "builtin": return "macmini" 12 12 case "fifo": return "antenna.radiowaves.left.and.right" 13 + case "snapcast": return "antenna.radiowaves.left.and.right" 13 14 case "squeezelite": return "hifispeaker" 14 15 case "airplay": return "airplayvideo" 15 16 case "chromecast": return "tv.and.mediabox" ··· 23 24 switch device.service { 24 25 case "builtin": return Color(hex: "28fce3") 25 26 case "fifo": return Color(hex: "9090ff") 27 + case "snapcast": return Color(hex: "9090ff") 26 28 case "squeezelite": return Color(hex: "ffa028") 27 29 case "airplay": return Color(hex: "fe09a3") 28 30 case "chromecast": return Color(hex: "28cbfc") ··· 37 39 38 40 var body: some View { 39 41 VStack(alignment: .leading, spacing: 0) { 40 - // Header — current device 41 - HStack(spacing: 12) { 42 - ZStack { 43 - RoundedRectangle(cornerRadius: 8) 44 - .fill(Color.secondary.opacity(0.12)) 45 - .frame(width: 36, height: 36) 46 - if let current = deviceState.currentDevice { 47 - Image(systemName: deviceSymbol(current)) 48 - .font(.system(size: 16)) 49 - .foregroundStyle(deviceColor(current)) 50 - } else { 51 - Image(systemName: "macmini") 52 - .font(.system(size: 16)) 53 - .foregroundStyle(Color(hex: "fe09a3")) 54 - } 55 - } 56 - 57 - VStack(alignment: .leading, spacing: 1) { 58 - Text("Current device") 59 - .font(.system(size: 11)) 60 - .foregroundStyle(.secondary) 61 - Text(deviceState.currentDevice?.name ?? "Rockbox (Built-in)") 62 - .font(.system(size: 13, weight: .medium)) 63 - .lineLimit(1) 64 - } 65 - 66 - Spacer() 67 - } 68 - .padding(.horizontal, 16) 69 - .padding(.top, 14) 70 - .padding(.bottom, 10) 42 + Text("Output Device") 43 + .font(.system(size: 11, weight: .semibold)) 44 + .foregroundStyle(.secondary) 45 + .padding(.horizontal, 16) 46 + .padding(.top, 14) 47 + .padding(.bottom, 8) 71 48 72 49 Divider() 73 50 .padding(.horizontal, 8) 74 51 75 - // Device list 76 52 if deviceState.isLoading { 77 53 HStack { 78 54 Spacer() ··· 80 56 .padding() 81 57 Spacer() 82 58 } 59 + } else if deviceState.devices.isEmpty { 60 + Text("No devices found.") 61 + .font(.system(size: 12)) 62 + .foregroundStyle(.secondary) 63 + .frame(maxWidth: .infinity, alignment: .center) 64 + .padding() 83 65 } else { 84 - let others = deviceState.devices.filter { !$0.isCurrentDevice } 85 - if others.isEmpty { 86 - Text("No other devices found.") 87 - .font(.system(size: 12)) 88 - .foregroundStyle(.secondary) 89 - .frame(maxWidth: .infinity, alignment: .center) 90 - .padding() 91 - } else { 92 - ScrollView { 93 - VStack(spacing: 2) { 94 - ForEach(others) { device in 95 - DeviceRow(device: device) { 96 - Task { 97 - await deviceState.connect(device) 98 - dismiss() 99 - } 66 + ScrollView { 67 + VStack(spacing: 2) { 68 + ForEach(deviceState.devices) { device in 69 + DeviceRow(device: device) { 70 + Task { 71 + await deviceState.connect(device) 72 + dismiss() 100 73 } 101 74 } 102 75 } 103 - .padding(.vertical, 6) 104 - .padding(.horizontal, 8) 105 76 } 106 - .frame(maxHeight: 280) 77 + .padding(.vertical, 6) 78 + .padding(.horizontal, 8) 107 79 } 80 + .frame(maxHeight: 280) 108 81 } 109 82 } 110 83 .frame(width: 280)
+7 -1
macos/Rockbox/Views/Files/FilesListView.swift
··· 16 16 struct FilesListView: View { 17 17 @State private var files: [FileItem] = [] 18 18 @State private var errorText: String? 19 + @State private var isLoading = false 19 20 @State private var currentPath: String? = nil 20 21 @State private var mode: FilesMode = .root 21 22 @State private var history: [(FilesMode, String?)] = [] ··· 47 48 48 49 if mode == .root { 49 50 rootView 51 + } else if isLoading { 52 + ProgressView() 53 + .frame(maxWidth: .infinity, maxHeight: .infinity) 50 54 } else { 51 55 fileListView 52 56 } 53 57 } 54 - .task(id: currentPath) { 58 + .task(id: "\(mode)-\(currentPath ?? "")") { 55 59 guard mode != .root else { return } 56 60 await loadFiles() 57 61 } ··· 156 160 // MARK: - Data loading 157 161 158 162 private func loadFiles() async { 163 + isLoading = true 164 + defer { isLoading = false } 159 165 do { 160 166 let entries = try await fetchFiles(path: currentPath) 161 167 files = entries.compactMap { entry -> FileItem? in