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 UPnP browsing support

Introduce a rockbox-upnp crate with a control point implementation and
integrate it into GraphQL, RPC, GPUI, web UI and macOS bindings. Add a
display_name proto field for entries.

Pre-initialize the UPnP Tokio runtime in the server to avoid nested
runtime panics.

Also: inline-skip small forward seeks in netstream to avoid round trips,
and fix TYPE_ID3 buffering fallback to send BUFFER_EVENT_FINISHED when a
file cannot be opened.

+1290 -302
+2
Cargo.lock
··· 9208 9208 "rockbox-sys", 9209 9209 "rockbox-types", 9210 9210 "rockbox-typesense", 9211 + "rockbox-upnp", 9211 9212 "rockbox-webui", 9212 9213 "serde", 9213 9214 "serde_json", ··· 9365 9366 "rockbox-sys", 9366 9367 "rockbox-types", 9367 9368 "rockbox-typesense", 9369 + "rockbox-upnp", 9368 9370 "serde", 9369 9371 "serde_json", 9370 9372 "sqlx",
+12
apps/buffering.c
··· 648 648 if (h->fd == -1) { 649 649 /* could not open the file, truncate it where it is */ 650 650 h->filesize = h->end; 651 + /* For TYPE_ID3 the playback chain waits for BUFFER_EVENT_FINISHED 652 + * before it posts Q_AUDIO_FINISH_LOAD_TRACK. If we return here 653 + * without sending it the track loading stalls permanently. Send 654 + * the event with a zeroed mp3entry (no metadata) so the chain 655 + * keeps moving. */ 656 + if (h->type == TYPE_ID3) { 657 + wipe_mp3entry((struct mp3entry *)ringbuf_ptr(h->data)); 658 + h->filesize = sizeof(struct mp3entry); 659 + h->widx = ringbuf_add(h->data, h->filesize); 660 + h->end = h->filesize; 661 + send_event(BUFFER_EVENT_FINISHED, &handle_id); 662 + } 651 663 return true; 652 664 } 653 665
+2
cli/src/api/rockbox.v1alpha1.rs
··· 22 22 pub time_write: u32, 23 23 #[prost(int32, tag = "4")] 24 24 pub customaction: i32, 25 + #[prost(string, optional, tag = "5")] 26 + pub display_name: ::core::option::Option<::prost::alloc::string::String>, 25 27 } 26 28 #[derive(Clone, PartialEq, ::prost::Message)] 27 29 pub struct TreeGetEntriesResponse {
cli/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+1
crates/graphql/Cargo.toml
··· 20 20 owo-colors = "4.1.0" 21 21 reqwest = {version = "0.12.5", features = ["rustls-tls", "json"], default-features = false} 22 22 rockbox-library = {path = "../library"} 23 + rockbox-upnp = {path = "../upnp"} 23 24 rockbox-playlists = {path = "../playlists"} 24 25 rockbox-rocksky = {path = "../rocksky"} 25 26 rockbox-typesense = {path = "../typesense"}
+35
crates/graphql/src/lib.rs
··· 23 23 24 24 pub fn read_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 25 25 Box::pin(async move { 26 + if path.starts_with("upnp://") { 27 + return read_upnp_files(path).await; 28 + } 26 29 let mut result = Vec::new(); 27 30 let mut dir = fs::read_dir(path).await?; 28 31 let mut futures = FuturesUnordered::new(); ··· 49 52 Ok(result) 50 53 }) 51 54 } 55 + 56 + pub fn read_upnp_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 57 + Box::pin(async move { 58 + use rockbox_upnp::control_point::{ 59 + browse_content_directory, percent_decode, percent_encode, 60 + }; 61 + let rest = path.trim_start_matches("upnp://"); 62 + let (ctrl_encoded, object_id_raw) = match rest.find('/') { 63 + None => (rest, "0"), 64 + Some(i) => (&rest[..i], &rest[i + 1..]), 65 + }; 66 + let object_id = if object_id_raw.is_empty() { 67 + "0".to_string() 68 + } else { 69 + percent_decode(object_id_raw) 70 + }; 71 + let control_url = percent_decode(ctrl_encoded); 72 + let ctrl_encoded = ctrl_encoded.to_string(); 73 + let entries = browse_content_directory(&control_url, &object_id).await; 74 + let mut result = Vec::new(); 75 + for entry in entries { 76 + if entry.is_container { 77 + let sub_path = format!("upnp://{}/{}", ctrl_encoded, percent_encode(&entry.id)); 78 + let sub = read_upnp_files(sub_path).await?; 79 + result.extend(sub); 80 + } else if let Some(uri) = entry.uri { 81 + result.push(uri); 82 + } 83 + } 84 + Ok(result) 85 + }) 86 + }
+57
crates/graphql/src/schema/browse.rs
··· 3 3 use async_graphql::*; 4 4 5 5 use crate::{schema::objects::entry::Entry, AUDIO_EXTENSIONS}; 6 + use rockbox_upnp::control_point::{ 7 + browse_content_directory, discover_media_servers, percent_decode, percent_encode, 8 + }; 6 9 7 10 #[derive(Default)] 8 11 pub struct BrowseQuery; ··· 14 17 _ctx: &Context<'_>, 15 18 path: Option<String>, 16 19 ) -> Result<Vec<Entry>, Error> { 20 + if let Some(ref p) = path { 21 + if p.starts_with("upnp://") { 22 + return handle_upnp(p).await; 23 + } 24 + } 25 + 17 26 let show_hidden = false; 18 27 let home = env::var("HOME").unwrap(); 19 28 let music_library = env::var("ROCKBOX_LIBRARY").unwrap_or(format!("{}/Music", home)); ··· 64 73 Ok(entries) 65 74 } 66 75 } 76 + 77 + async fn handle_upnp(path: &str) -> Result<Vec<Entry>, Error> { 78 + let rest = path.trim_start_matches("upnp://"); 79 + 80 + if rest.is_empty() { 81 + let devices = discover_media_servers().await; 82 + return Ok(devices 83 + .into_iter() 84 + .map(|d| Entry { 85 + name: format!("upnp://{}", percent_encode(&d.control_url)), 86 + attr: 0x10, 87 + display_name: Some(d.friendly_name), 88 + ..Default::default() 89 + }) 90 + .collect()); 91 + } 92 + 93 + let (ctrl_encoded, object_id) = match rest.find('/') { 94 + None => (rest, "0"), 95 + Some(i) => (&rest[..i], &rest[i + 1..]), 96 + }; 97 + let object_id_decoded = if object_id.is_empty() { 98 + "0".to_string() 99 + } else { 100 + percent_decode(object_id) 101 + }; 102 + let control_url = percent_decode(ctrl_encoded); 103 + 104 + let content = browse_content_directory(&control_url, &object_id_decoded).await; 105 + Ok(content 106 + .into_iter() 107 + .map(|e| { 108 + let name = if e.is_container { 109 + format!("upnp://{}/{}", ctrl_encoded, percent_encode(&e.id)) 110 + } else { 111 + e.uri.unwrap_or_else(|| { 112 + format!("upnp://{}/item/{}", ctrl_encoded, percent_encode(&e.id)) 113 + }) 114 + }; 115 + Entry { 116 + name, 117 + attr: if e.is_container { 0x10 } else { 0 }, 118 + display_name: Some(e.title), 119 + ..Default::default() 120 + } 121 + }) 122 + .collect()) 123 + }
+6
crates/graphql/src/schema/objects/entry.rs
··· 7 7 pub attr: i32, 8 8 pub time_write: u32, 9 9 pub customaction: i32, 10 + pub display_name: Option<String>, 10 11 } 11 12 12 13 #[Object] ··· 26 27 async fn customaction(&self) -> i32 { 27 28 self.customaction 28 29 } 30 + 31 + async fn display_name(&self) -> Option<&str> { 32 + self.display_name.as_deref() 33 + } 29 34 } 30 35 31 36 impl From<rockbox_sys::types::tree::Entry> for Entry { ··· 35 40 attr: entry.attr, 36 41 time_write: entry.time_write, 37 42 customaction: entry.customaction, 43 + display_name: None, 38 44 } 39 45 } 40 46 }
+20 -1
crates/netstream/src/lib.rs
··· 92 92 /// Re-issue the request starting at `new_pos` using an HTTP Range header. 93 93 /// Falls back to reopening from byte 0 and discarding bytes if the server 94 94 /// ignores Range and responds with the full body. 95 + /// 96 + /// The existing response is only replaced on success. A failed seek leaves 97 + /// the stream at its current position so reads can still continue. 95 98 fn seek_to(&mut self, new_pos: u64) -> bool { 99 + // Small forward seek: skip bytes in the existing response body rather 100 + // than issuing a new Range request. Avoids a full round-trip for the 101 + // tiny metadata seeks that codecs commonly do (ID3 tags, MP4 atoms). 102 + const INLINE_SKIP_MAX: u64 = 128 * 1024; // 128 KB 103 + if new_pos > self.pos && new_pos - self.pos <= INLINE_SKIP_MAX { 104 + if let Some(resp) = &mut self.response { 105 + let to_skip = new_pos - self.pos; 106 + if Self::skip_bytes(resp, to_skip) { 107 + self.pos = new_pos; 108 + return true; 109 + } 110 + // Inline skip failed (connection dropped); fall through to Range request. 111 + } 112 + } 113 + 96 114 // Use a bounded range when content_length is known so the request is 97 115 // always within [0, content_length). This prevents spurious 416 98 116 // responses and tells the server exactly how many bytes we want. ··· 100 118 Some(cl) if cl > 0 => format!("bytes={}-{}", new_pos, cl - 1), 101 119 _ => format!("bytes={}-", new_pos), 102 120 }; 103 - self.response = None; 121 + // Do NOT clear self.response before the request succeeds. If the new 122 + // request fails the stream stays readable at the current position. 104 123 let result = CLIENT.get(&self.url).header("Range", range_header).send(); 105 124 106 125 match result {
+2
crates/rocksky/src/api/rockbox.v1alpha1.rs
··· 22 22 pub time_write: u32, 23 23 #[prost(int32, tag = "4")] 24 24 pub customaction: i32, 25 + #[prost(string, optional, tag = "5")] 26 + pub display_name: ::core::option::Option<::prost::alloc::string::String>, 25 27 } 26 28 #[derive(Clone, PartialEq, ::prost::Message)] 27 29 pub struct TreeGetEntriesResponse {
crates/rocksky/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+1
crates/rpc/Cargo.toml
··· 17 17 "json", 18 18 ], default-features = false } 19 19 rockbox-graphql = { path = "../graphql" } 20 + rockbox-upnp = { path = "../upnp" } 20 21 rockbox-library = { path = "../library" } 21 22 rockbox-playlists = { path = "../playlists" } # needed for Playlist/PlaylistFolder type deserialization 22 23 rockbox-rocksky = {path = "../rocksky"}
+1
crates/rpc/proto/rockbox/v1alpha1/browse.proto
··· 19 19 int32 attr = 2; 20 20 uint32 time_write = 3; 21 21 int32 customaction = 4; 22 + optional string display_name = 5; 22 23 } 23 24 24 25 message TreeGetEntriesResponse {
+2
crates/rpc/src/api/rockbox.v1alpha1.rs
··· 22 22 pub time_write: u32, 23 23 #[prost(int32, tag = "4")] 24 24 pub customaction: i32, 25 + #[prost(string, optional, tag = "5")] 26 + pub display_name: ::core::option::Option<::prost::alloc::string::String>, 25 27 } 26 28 #[derive(Clone, PartialEq, ::prost::Message)] 27 29 pub struct TreeGetEntriesResponse {
crates/rpc/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+60
crates/rpc/src/browse.rs
··· 4 4 api::rockbox::v1alpha1::{browse_service_server::BrowseService, *}, 5 5 AUDIO_EXTENSIONS, 6 6 }; 7 + use rockbox_upnp::control_point::{ 8 + browse_content_directory, discover_media_servers, percent_decode, percent_encode, 9 + }; 7 10 8 11 #[derive(Default)] 9 12 pub struct Browse; ··· 15 18 request: tonic::Request<TreeGetEntriesRequest>, 16 19 ) -> Result<tonic::Response<TreeGetEntriesResponse>, tonic::Status> { 17 20 let path = request.into_inner().path; 21 + 22 + if let Some(ref p) = path { 23 + if p.starts_with("upnp://") { 24 + return handle_upnp(p).await; 25 + } 26 + } 18 27 19 28 let show_hidden = false; 20 29 let home = env::var("HOME").unwrap(); ··· 67 76 Ok(tonic::Response::new(TreeGetEntriesResponse { entries })) 68 77 } 69 78 } 79 + 80 + async fn handle_upnp(path: &str) -> Result<tonic::Response<TreeGetEntriesResponse>, tonic::Status> { 81 + let rest = path.trim_start_matches("upnp://"); 82 + 83 + if rest.is_empty() { 84 + let devices = discover_media_servers().await; 85 + let entries = devices 86 + .into_iter() 87 + .map(|d| Entry { 88 + name: format!("upnp://{}", percent_encode(&d.control_url)), 89 + attr: 0x10, 90 + display_name: Some(d.friendly_name), 91 + ..Default::default() 92 + }) 93 + .collect(); 94 + return Ok(tonic::Response::new(TreeGetEntriesResponse { entries })); 95 + } 96 + 97 + let (ctrl_encoded, object_id) = match rest.find('/') { 98 + None => (rest, "0"), 99 + Some(i) => (&rest[..i], &rest[i + 1..]), 100 + }; 101 + let object_id_decoded = if object_id.is_empty() { 102 + "0".to_string() 103 + } else { 104 + percent_decode(object_id) 105 + }; 106 + let control_url = percent_decode(ctrl_encoded); 107 + 108 + let content = browse_content_directory(&control_url, &object_id_decoded).await; 109 + let entries = content 110 + .into_iter() 111 + .map(|e| { 112 + let name = if e.is_container { 113 + format!("upnp://{}/{}", ctrl_encoded, percent_encode(&e.id)) 114 + } else { 115 + e.uri.unwrap_or_else(|| { 116 + format!("upnp://{}/item/{}", ctrl_encoded, percent_encode(&e.id)) 117 + }) 118 + }; 119 + Entry { 120 + name, 121 + attr: if e.is_container { 0x10 } else { 0 }, 122 + display_name: Some(e.title), 123 + ..Default::default() 124 + } 125 + }) 126 + .collect(); 127 + 128 + Ok(tonic::Response::new(TreeGetEntriesResponse { entries })) 129 + }
+36
crates/rpc/src/lib.rs
··· 747 747 attr, 748 748 time_write, 749 749 customaction, 750 + display_name: None, 750 751 } 751 752 } 752 753 } ··· 983 984 984 985 pub fn read_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 985 986 Box::pin(async move { 987 + if path.starts_with("upnp://") { 988 + return read_upnp_files(path).await; 989 + } 986 990 let mut result = Vec::new(); 987 991 let mut dir = fs::read_dir(path).await?; 988 992 let mut futures = FuturesUnordered::new(); ··· 1005 1009 } 1006 1010 while let Some(Ok(future)) = futures.next().await { 1007 1011 result.extend(future?); 1012 + } 1013 + Ok(result) 1014 + }) 1015 + } 1016 + 1017 + pub fn read_upnp_files(path: String) -> BoxFuture<'static, Result<Vec<String>, Error>> { 1018 + Box::pin(async move { 1019 + use rockbox_upnp::control_point::{ 1020 + browse_content_directory, percent_decode, percent_encode, 1021 + }; 1022 + let rest = path.trim_start_matches("upnp://"); 1023 + let (ctrl_encoded, object_id_raw) = match rest.find('/') { 1024 + None => (rest, "0"), 1025 + Some(i) => (&rest[..i], &rest[i + 1..]), 1026 + }; 1027 + let object_id = if object_id_raw.is_empty() { 1028 + "0".to_string() 1029 + } else { 1030 + percent_decode(object_id_raw) 1031 + }; 1032 + let control_url = percent_decode(ctrl_encoded); 1033 + let ctrl_encoded = ctrl_encoded.to_string(); 1034 + let entries = browse_content_directory(&control_url, &object_id).await; 1035 + let mut result = Vec::new(); 1036 + for entry in entries { 1037 + if entry.is_container { 1038 + let sub_path = format!("upnp://{}/{}", ctrl_encoded, percent_encode(&entry.id)); 1039 + let sub = read_upnp_files(sub_path).await?; 1040 + result.extend(sub); 1041 + } else if let Some(uri) = entry.uri { 1042 + result.push(uri); 1043 + } 1008 1044 } 1009 1045 Ok(result) 1010 1046 })
+32 -28
crates/rpc/src/playback.rs
··· 408 408 None => recurse, 409 409 }; 410 410 411 - if !std::path::Path::new(&path).is_dir() { 412 - return Err(tonic::Status::invalid_argument("Path is not a directory")); 413 - } 414 - 415 - match recurse { 416 - Some(true) => { 417 - tracks = read_files(path) 418 - .await 419 - .map_err(|e| tonic::Status::internal(e.to_string()))? 411 + if path.starts_with("upnp://") { 412 + tracks = crate::read_upnp_files(path) 413 + .await 414 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 415 + } else { 416 + if !std::path::Path::new(&path).is_dir() { 417 + return Err(tonic::Status::invalid_argument("Path is not a directory")); 420 418 } 421 - _ => { 422 - for file in 423 - fs::read_dir(&path).map_err(|e| tonic::Status::internal(e.to_string()))? 424 - { 425 - let file = file.map_err(|e| tonic::Status::internal(e.to_string()))?; 426 - 427 - if file 428 - .metadata() 419 + match recurse { 420 + Some(true) => { 421 + tracks = read_files(path) 422 + .await 429 423 .map_err(|e| tonic::Status::internal(e.to_string()))? 430 - .is_file() 431 - && !AUDIO_EXTENSIONS.iter().any(|ext| { 432 - file.path() 433 - .to_string_lossy() 434 - .ends_with(&format!(".{}", ext)) 435 - }) 424 + } 425 + _ => { 426 + for file in 427 + fs::read_dir(&path).map_err(|e| tonic::Status::internal(e.to_string()))? 436 428 { 437 - continue; 438 - } 429 + let file = file.map_err(|e| tonic::Status::internal(e.to_string()))?; 439 430 440 - tracks.push(file.path().to_string_lossy().to_string()); 431 + if file 432 + .metadata() 433 + .map_err(|e| tonic::Status::internal(e.to_string()))? 434 + .is_file() 435 + && !AUDIO_EXTENSIONS.iter().any(|ext| { 436 + file.path() 437 + .to_string_lossy() 438 + .ends_with(&format!(".{}", ext)) 439 + }) 440 + { 441 + continue; 442 + } 443 + 444 + tracks.push(file.path().to_string_lossy().to_string()); 445 + } 441 446 } 442 447 } 448 + tracks.sort(); 443 449 } 444 - 445 - tracks.sort(); 446 450 447 451 let body = serde_json::json!({ 448 452 "tracks": tracks
+5
crates/server/src/lib.rs
··· 173 173 app.get("/schemas/:id", index); 174 174 app.get("/openapi.json", get_openapi); 175 175 176 + // Pre-initialize the UPnP tokio runtime before any HTTP handler runs. 177 + // If initialized lazily inside a handler's block_on context, tokio 1.27+ 178 + // panics with "Cannot start a runtime from within a runtime." 179 + rockbox_upnp::init(); 180 + 176 181 match app.listen() { 177 182 Ok(_) => {} 178 183 Err(e) => {
+2
crates/upnp/src/api/rockbox.v1alpha1.rs
··· 22 22 pub time_write: u32, 23 23 #[prost(int32, tag = "4")] 24 24 pub customaction: i32, 25 + #[prost(string, optional, tag = "5")] 26 + pub display_name: ::core::option::Option<::prost::alloc::string::String>, 25 27 } 26 28 #[derive(Clone, PartialEq, ::prost::Message)] 27 29 pub struct TreeGetEntriesResponse {
crates/upnp/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+360
crates/upnp/src/control_point.rs
··· 1 + use std::collections::HashSet; 2 + use std::time::Duration; 3 + use tokio::net::UdpSocket; 4 + 5 + const SSDP_ADDR: &str = "239.255.255.250:1900"; 6 + const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); 7 + 8 + #[derive(Debug, Clone)] 9 + pub struct DiscoveredDevice { 10 + pub friendly_name: String, 11 + pub control_url: String, 12 + } 13 + 14 + #[derive(Debug, Clone)] 15 + pub struct ContentEntry { 16 + pub id: String, 17 + pub title: String, 18 + pub is_container: bool, 19 + pub uri: Option<String>, 20 + } 21 + 22 + pub async fn discover_media_servers() -> Vec<DiscoveredDevice> { 23 + let socket = match UdpSocket::bind("0.0.0.0:0").await { 24 + Ok(s) => s, 25 + Err(e) => { 26 + tracing::warn!("UPnP control point: bind failed: {e}"); 27 + return vec![]; 28 + } 29 + }; 30 + if let Err(e) = socket.set_multicast_ttl_v4(4) { 31 + tracing::debug!("UPnP control point: set_multicast_ttl_v4 failed: {e}"); 32 + } 33 + 34 + let msearch = concat!( 35 + "M-SEARCH * HTTP/1.1\r\n", 36 + "HOST: 239.255.255.250:1900\r\n", 37 + "MAN: \"ssdp:discover\"\r\n", 38 + "ST: urn:schemas-upnp-org:device:MediaServer:1\r\n", 39 + "MX: 2\r\n", 40 + "\r\n" 41 + ); 42 + 43 + if let Err(e) = socket.send_to(msearch.as_bytes(), SSDP_ADDR).await { 44 + tracing::warn!("UPnP control point: M-SEARCH failed: {e}"); 45 + return vec![]; 46 + } 47 + 48 + let mut locations: HashSet<String> = HashSet::new(); 49 + let mut buf = vec![0u8; 4096]; 50 + let deadline = tokio::time::Instant::now() + DISCOVERY_TIMEOUT; 51 + 52 + loop { 53 + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); 54 + if remaining.is_zero() { 55 + break; 56 + } 57 + match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await { 58 + Ok(Ok((len, _))) => { 59 + let msg = std::str::from_utf8(&buf[..len]).unwrap_or(""); 60 + if let Some(loc) = header_value(msg, "LOCATION") { 61 + locations.insert(loc.to_string()); 62 + } 63 + } 64 + _ => break, 65 + } 66 + } 67 + 68 + let client = reqwest::Client::builder() 69 + .timeout(Duration::from_secs(5)) 70 + .build() 71 + .unwrap_or_default(); 72 + 73 + let mut devices = vec![]; 74 + for location in &locations { 75 + if let Some(device) = fetch_device_info(&client, location).await { 76 + devices.push(device); 77 + } 78 + } 79 + devices 80 + } 81 + 82 + async fn fetch_device_info(client: &reqwest::Client, location: &str) -> Option<DiscoveredDevice> { 83 + let xml = client.get(location).send().await.ok()?.text().await.ok()?; 84 + let friendly_name = extract_tag(&xml, "friendlyName")?; 85 + let control_url_rel = extract_service_control_url(&xml, "ContentDirectory")?; 86 + let base = url_base(location)?; 87 + let control_url = if control_url_rel.starts_with("http") { 88 + control_url_rel 89 + } else { 90 + format!( 91 + "{}/{}", 92 + base.trim_end_matches('/'), 93 + control_url_rel.trim_start_matches('/') 94 + ) 95 + }; 96 + Some(DiscoveredDevice { 97 + friendly_name, 98 + control_url, 99 + }) 100 + } 101 + 102 + pub async fn browse_content_directory(control_url: &str, object_id: &str) -> Vec<ContentEntry> { 103 + let client = match reqwest::Client::builder() 104 + .timeout(Duration::from_secs(10)) 105 + .build() 106 + { 107 + Ok(c) => c, 108 + Err(_) => return vec![], 109 + }; 110 + 111 + let soap_body = format!( 112 + r#"<?xml version="1.0"?> 113 + <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 114 + s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 115 + <s:Body> 116 + <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"> 117 + <ObjectID>{}</ObjectID> 118 + <BrowseFlag>BrowseDirectChildren</BrowseFlag> 119 + <Filter>*</Filter> 120 + <StartingIndex>0</StartingIndex> 121 + <RequestedCount>0</RequestedCount> 122 + <SortCriteria></SortCriteria> 123 + </u:Browse> 124 + </s:Body> 125 + </s:Envelope>"#, 126 + xml_escape(object_id) 127 + ); 128 + 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 + }; 146 + 147 + let body = match resp.text().await { 148 + Ok(b) => b, 149 + Err(_) => return vec![], 150 + }; 151 + 152 + tracing::debug!("UPnP browse response body: {body}"); 153 + 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 + }; 161 + 162 + tracing::debug!("UPnP DIDL-Lite XML: {result_xml}"); 163 + 164 + let entries = parse_didl_lite(&result_xml); 165 + tracing::debug!("UPnP parsed {} entries", entries.len()); 166 + entries 167 + } 168 + 169 + // ── DIDL-Lite parser ────────────────────────────────────────────────────────── 170 + 171 + fn parse_didl_lite(xml: &str) -> Vec<ContentEntry> { 172 + let mut entries = Vec::new(); 173 + parse_elements(xml, "container", true, &mut entries); 174 + parse_elements(xml, "item", false, &mut entries); 175 + entries 176 + } 177 + 178 + fn parse_elements(xml: &str, tag: &str, is_container: bool, out: &mut Vec<ContentEntry>) { 179 + let open_prefix = format!("<{tag} "); 180 + let open_empty = format!("<{tag}>"); 181 + let close_tag = format!("</{tag}>"); 182 + let mut pos = 0; 183 + loop { 184 + let rel = xml[pos..] 185 + .find(&open_prefix) 186 + .map(|i| (i, false)) 187 + .into_iter() 188 + .chain(xml[pos..].find(&open_empty).map(|i| (i, true))) 189 + .min_by_key(|(i, _)| *i); 190 + let (start_rel, _) = match rel { 191 + Some(v) => v, 192 + None => break, 193 + }; 194 + let abs_start = pos + start_rel; 195 + let end_abs = match xml[abs_start..].find(&close_tag) { 196 + Some(p) => abs_start + p + close_tag.len(), 197 + None => break, 198 + }; 199 + let element = &xml[abs_start..end_abs]; 200 + let id = attr_value(element, "id").unwrap_or_default(); 201 + let title = extract_namespaced(element, "title").unwrap_or_default(); 202 + let uri = if is_container { 203 + None 204 + } else { 205 + extract_res_uri(element) 206 + }; 207 + out.push(ContentEntry { 208 + id, 209 + title, 210 + is_container, 211 + uri, 212 + }); 213 + pos = end_abs; 214 + } 215 + } 216 + 217 + // ── XML/HTML helpers ────────────────────────────────────────────────────────── 218 + 219 + /// Extracts the content of the SOAP <Result> element, handling optional namespace prefixes 220 + /// like <u:Result> that some UPnP servers emit. 221 + fn extract_result_tag(xml: &str) -> Option<String> { 222 + extract_tag(xml, "Result").or_else(|| extract_namespaced(xml, "Result")) 223 + } 224 + 225 + fn extract_tag(xml: &str, tag: &str) -> Option<String> { 226 + let open = format!("<{tag}>"); 227 + let close = format!("</{tag}>"); 228 + let start = xml.find(&open)? + open.len(); 229 + let end = xml[start..].find(&close)?; 230 + Some(xml[start..start + end].trim().to_string()) 231 + } 232 + 233 + fn extract_namespaced(xml: &str, local: &str) -> Option<String> { 234 + if let Some(v) = extract_tag(xml, local) { 235 + return Some(v); 236 + } 237 + // Find <prefix:local>content</prefix:local> 238 + let open_suffix = format!(":{local}>"); 239 + let content_start = xml.find(&open_suffix)? + open_suffix.len(); 240 + let remaining = &xml[content_start..]; 241 + let mut p = 0; 242 + while let Some(close_rel) = remaining[p..].find("</") { 243 + let abs = p + close_rel; 244 + let after_slash = &remaining[abs + 2..]; 245 + if let Some(colon) = after_slash.find(':') { 246 + let after_colon = &after_slash[colon + 1..]; 247 + if after_colon.starts_with(local) { 248 + let after_local = &after_colon[local.len()..]; 249 + if after_local.starts_with('>') { 250 + return Some(remaining[..abs].trim().to_string()); 251 + } 252 + } 253 + } 254 + p = abs + 2; 255 + } 256 + None 257 + } 258 + 259 + fn extract_res_uri(element: &str) -> Option<String> { 260 + let res_start = element.find("<res ")?; 261 + let content_start = element[res_start..].find('>')? + res_start + 1; 262 + let content_end = element[content_start..].find("</res>")?; 263 + let uri = element[content_start..content_start + content_end].trim(); 264 + if uri.starts_with("http") { 265 + Some(uri.to_string()) 266 + } else { 267 + None 268 + } 269 + } 270 + 271 + fn extract_service_control_url(xml: &str, service_type_fragment: &str) -> Option<String> { 272 + let needle = format!(":{service_type_fragment}:"); 273 + let svc_pos = xml.find(&needle)?; 274 + // Walk forward to find <controlURL> 275 + let after = &xml[svc_pos..]; 276 + extract_tag(after, "controlURL") 277 + } 278 + 279 + fn header_value<'a>(msg: &'a str, name: &str) -> Option<&'a str> { 280 + let prefix = format!("{name}:"); 281 + msg.lines() 282 + .find(|l| { 283 + l.to_ascii_uppercase() 284 + .starts_with(&prefix.to_ascii_uppercase()) 285 + }) 286 + .map(|l| l[prefix.len()..].trim()) 287 + } 288 + 289 + fn attr_value(element: &str, name: &str) -> Option<String> { 290 + let needle = format!(r#"{name}=""#); 291 + let start = element.find(&needle)? + needle.len(); 292 + let end = element[start..].find('"')?; 293 + Some(element[start..start + end].to_string()) 294 + } 295 + 296 + fn url_base(url: &str) -> Option<String> { 297 + let after_scheme = url.find("://")?; 298 + let host_start = after_scheme + 3; 299 + let host_end = url[host_start..] 300 + .find('/') 301 + .map(|i| host_start + i) 302 + .unwrap_or(url.len()); 303 + Some(url[..host_end].to_string()) 304 + } 305 + 306 + fn xml_escape(s: &str) -> String { 307 + s.replace('&', "&amp;") 308 + .replace('<', "&lt;") 309 + .replace('>', "&gt;") 310 + .replace('"', "&quot;") 311 + .replace('\'', "&apos;") 312 + } 313 + 314 + fn html_unescape(s: &str) -> String { 315 + s.replace("&amp;", "&") 316 + .replace("&lt;", "<") 317 + .replace("&gt;", ">") 318 + .replace("&quot;", "\"") 319 + .replace("&apos;", "'") 320 + } 321 + 322 + // ── UPnP path encoding ──────────────────────────────────────────────────────── 323 + 324 + pub fn percent_encode(s: &str) -> String { 325 + let mut out = String::with_capacity(s.len()); 326 + for b in s.bytes() { 327 + match b { 328 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { 329 + out.push(b as char); 330 + } 331 + _ => { 332 + out.push('%'); 333 + out.push(char::from_digit((b >> 4) as u32, 16).unwrap()); 334 + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); 335 + } 336 + } 337 + } 338 + out 339 + } 340 + 341 + pub fn percent_decode(s: &str) -> String { 342 + let mut out = Vec::with_capacity(s.len()); 343 + let bytes = s.as_bytes(); 344 + let mut i = 0; 345 + while i < bytes.len() { 346 + if bytes[i] == b'%' && i + 2 < bytes.len() { 347 + if let (Some(hi), Some(lo)) = ( 348 + (bytes[i + 1] as char).to_digit(16), 349 + (bytes[i + 2] as char).to_digit(16), 350 + ) { 351 + out.push(((hi << 4) | lo) as u8); 352 + i += 3; 353 + continue; 354 + } 355 + } 356 + out.push(bytes[i]); 357 + i += 1; 358 + } 359 + String::from_utf8_lossy(&out).into_owned() 360 + }
+8
crates/upnp/src/lib.rs
··· 6 6 } 7 7 } 8 8 9 + pub mod control_point; 9 10 pub mod db; 10 11 pub(crate) mod didl; 11 12 pub mod format; ··· 220 221 // --------------------------------------------------------------------------- 221 222 // Public API — UPnP Media Server (ContentDirectory + SSDP) 222 223 // --------------------------------------------------------------------------- 224 + 225 + /// Pre-initialize the UPnP tokio runtime. Call this once at server startup, 226 + /// before any HTTP handler runs, to ensure `Runtime::new()` is never called 227 + /// from inside a `block_on` context (tokio 1.27+ panics in that case). 228 + pub fn init() { 229 + let _ = get_runtime(); 230 + } 223 231 224 232 /// Start the UPnP/DLNA Media Server so control points can browse and stream 225 233 /// the music library. Idempotent.
+1
gpui/assets/icons/upnp.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-access-point"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12l0 .01" /><path d="M14.828 9.172a4 4 0 0 1 0 5.656" /><path d="M17.657 6.343a8 8 0 0 1 0 11.314" /><path d="M9.168 14.828a4 4 0 0 1 0 -5.656" /><path d="M6.337 17.657a8 8 0 0 1 0 -11.314" /></svg>
+2
gpui/src/api/rockbox.v1alpha1.rs
··· 22 22 pub time_write: u32, 23 23 #[prost(int32, tag = "4")] 24 24 pub customaction: i32, 25 + #[prost(string, optional, tag = "5")] 26 + pub display_name: ::core::option::Option<::prost::alloc::string::String>, 25 27 } 26 28 #[derive(Clone, PartialEq, ::prost::Message)] 27 29 pub struct TreeGetEntriesResponse {
gpui/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+12 -5
gpui/src/client.rs
··· 665 665 pub is_dir: bool, 666 666 } 667 667 668 + fn entry_display_name(path: &str, display_name: Option<String>) -> String { 669 + if let Some(dn) = display_name { 670 + return dn; 671 + } 672 + std::path::Path::new(path) 673 + .file_name() 674 + .and_then(|n| n.to_str()) 675 + .unwrap_or(path) 676 + .to_string() 677 + } 678 + 668 679 pub async fn tree_get_entries(path: Option<String>) -> Result<Vec<FileEntry>> { 669 680 let mut c = BrowseServiceClient::connect(URL).await?; 670 681 let resp = c.tree_get_entries(TreeGetEntriesRequest { path }).await?; ··· 674 685 .into_iter() 675 686 .map(|e| { 676 687 let is_dir = e.attr == 0x10; 677 - let name = std::path::Path::new(&e.name) 678 - .file_name() 679 - .and_then(|n| n.to_str()) 680 - .unwrap_or(&e.name) 681 - .to_string(); 688 + let name = entry_display_name(&e.name, e.display_name); 682 689 FileEntry { 683 690 name, 684 691 path: e.name,
+2
gpui/src/ui/components/icons.rs
··· 179 179 Device, 180 180 Chromecast, 181 181 Airplay, 182 + Upnp, 182 183 } 183 184 184 185 impl IconNamed for Icons { ··· 216 217 Icons::Device => "icons/device.svg", 217 218 Icons::Chromecast => "icons/chromecast.svg", 218 219 Icons::Airplay => "icons/airplay.svg", 220 + Icons::Upnp => "icons/upnp.svg", 219 221 } 220 222 .into() 221 223 }
+41 -1
gpui/src/ui/components/mod.rs
··· 32 32 } 33 33 impl gpui::Global for LibrarySection {} 34 34 35 + #[derive(Clone, PartialEq)] 36 + pub enum FilesMode { 37 + /// Root landing: show "Music" and "UPnP Devices" tiles. 38 + Root, 39 + /// Browsing the local music directory (current_path = current dir, None = music root). 40 + Local, 41 + /// Listing discovered UPnP/DLNA media servers. 42 + UpnpDevices, 43 + /// Browsing a UPnP device's ContentDirectory. 44 + UpnpBrowse, 45 + } 46 + 47 + impl Default for FilesMode { 48 + fn default() -> Self { 49 + FilesMode::Root 50 + } 51 + } 52 + 35 53 #[derive(Clone, Default)] 36 54 pub struct FilesBrowseState { 55 + pub mode: FilesMode, 37 56 pub current_path: Option<String>, 38 - pub path_history: Vec<Option<String>>, 57 + pub history: Vec<(FilesMode, Option<String>)>, 39 58 } 59 + 60 + impl FilesBrowseState { 61 + pub fn can_go_back(&self) -> bool { 62 + !self.history.is_empty() 63 + } 64 + 65 + pub fn go_back(&mut self) { 66 + if let Some((prev_mode, prev_path)) = self.history.pop() { 67 + self.mode = prev_mode; 68 + self.current_path = prev_path; 69 + } 70 + } 71 + 72 + pub fn navigate(&mut self, new_mode: FilesMode, new_path: Option<String>) { 73 + let old_mode = std::mem::replace(&mut self.mode, new_mode); 74 + let old_path = self.current_path.take(); 75 + self.history.push((old_mode, old_path)); 76 + self.current_path = new_path; 77 + } 78 + } 79 + 40 80 impl gpui::Global for FilesBrowseState {} 41 81 42 82 #[derive(Clone)]
+340 -172
gpui/src/ui/components/pages/files.rs
··· 1 1 use crate::client::{play_directory, play_directory_at, FileEntry}; 2 2 use crate::controller::Controller; 3 3 use crate::ui::components::icons::{Icon, Icons}; 4 - use crate::ui::components::{FileContextMenu, FileContextMenuState, FilesBrowseState}; 4 + use crate::ui::components::{ 5 + FileContextMenu, FileContextMenuState, FilesBrowseState, FilesMode, 6 + }; 5 7 use crate::ui::theme::Theme; 6 8 use gpui::prelude::FluentBuilder; 7 9 use gpui::{ 8 - div, px, uniform_list, App, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, 9 - ParentElement, Render, StatefulInteractiveElement, Styled, WeakEntity, Window, 10 + div, px, uniform_list, AnyElement, App, ClickEvent, Context, FontWeight, 11 + InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, 12 + WeakEntity, Window, 10 13 }; 11 14 12 15 pub struct FilesView { 13 16 entries: Vec<FileEntry>, 14 - last_requested_path: Option<Option<String>>, 17 + upnp_devices: Vec<FileEntry>, 18 + last_loaded: Option<(FilesMode, Option<String>)>, 15 19 } 16 20 17 21 impl FilesView { 18 - pub fn new(cx: &mut App) -> Self { 22 + pub fn new(cx: &mut Context<Self>) -> Self { 19 23 cx.set_global(FilesBrowseState::default()); 20 24 cx.set_global(FileContextMenuState::default()); 25 + 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. 28 + let (tx, rx) = tokio::sync::oneshot::channel::<Vec<FileEntry>>(); 29 + 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(); 34 + let _ = tx.send(devices); 35 + }); 36 + cx.spawn(async move |this: WeakEntity<FilesView>, cx| { 37 + if let Ok(devices) = rx.await { 38 + 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. 42 + }); 43 + } 44 + }) 45 + .detach(); 46 + 21 47 FilesView { 22 48 entries: Vec::new(), 23 - last_requested_path: None, 49 + upnp_devices: Vec::new(), 50 + last_loaded: None, 24 51 } 25 52 } 26 53 27 54 fn load_if_needed(&mut self, cx: &mut Context<Self>) { 28 - let current_path = cx.global::<FilesBrowseState>().current_path.clone(); 29 - let needs_load = self 30 - .last_requested_path 31 - .as_ref() 32 - .map(|p| p != &current_path) 33 - .unwrap_or(true); 34 - if !needs_load { 55 + let browse = cx.global::<FilesBrowseState>().clone(); 56 + let key = (browse.mode.clone(), browse.current_path.clone()); 57 + 58 + if self.last_loaded.as_ref() == Some(&key) { 35 59 return; 36 60 } 37 - self.last_requested_path = Some(current_path.clone()); 61 + // Root mode renders static tiles — no fetch needed. 62 + if browse.mode == FilesMode::Root { 63 + self.entries.clear(); 64 + self.last_loaded = Some(key); 65 + return; 66 + } 38 67 39 - // Run the gRPC fetch on the Tokio runtime (requires a Tokio reactor). 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 + }); 92 + } 93 + }) 94 + .detach(); 95 + return; 96 + } 97 + 98 + self.last_loaded = Some(key.clone()); 99 + let path = browse.current_path.clone(); 100 + 40 101 let (tx, rx) = tokio::sync::oneshot::channel::<Vec<FileEntry>>(); 41 102 cx.global::<Controller>().rt().spawn(async move { 42 - let entries = crate::client::tree_get_entries(current_path) 43 - .await 44 - .unwrap_or_default(); 103 + let entries = crate::client::tree_get_entries(path).await.unwrap_or_default(); 45 104 let _ = tx.send(entries); 46 105 }); 47 106 48 - // Await the oneshot in GPUI's executor (no Tokio reactor needed for the 49 - // oneshot receiver itself), then push the result back into our entity. 50 107 cx.spawn(async move |this: WeakEntity<FilesView>, cx| { 51 108 if let Ok(entries) = rx.await { 52 109 let _ = this.update(cx, |this, cx| { 53 - this.entries = entries; 54 - cx.notify(); 110 + // Only apply if the user is still on the same page that triggered 111 + // this fetch — guards against races when navigating quickly. 112 + let current_key = { 113 + let browse = cx.global::<FilesBrowseState>(); 114 + (browse.mode.clone(), browse.current_path.clone()) 115 + }; 116 + if current_key == key { 117 + this.entries = entries; 118 + cx.notify(); 119 + } 55 120 }); 56 121 } 57 122 }) ··· 65 130 66 131 let theme = *cx.global::<Theme>(); 67 132 let browse = cx.global::<FilesBrowseState>().clone(); 68 - let entries = self.entries.clone(); 69 - let can_go_back = !browse.path_history.is_empty(); 133 + let can_go_back = browse.can_go_back(); 70 134 71 - let path_display: String = browse 72 - .current_path 73 - .as_deref() 74 - .and_then(|p| std::path::Path::new(p).file_name()) 75 - .and_then(|n| n.to_str()) 76 - .unwrap_or("Files") 77 - .to_string(); 135 + let path_display: String = match &browse.mode { 136 + FilesMode::Root => "Files".to_string(), 137 + FilesMode::Local => browse 138 + .current_path 139 + .as_deref() 140 + .and_then(|p| std::path::Path::new(p).file_name()) 141 + .and_then(|n| n.to_str()) 142 + .unwrap_or("Music") 143 + .to_string(), 144 + FilesMode::UpnpDevices => "UPnP Devices".to_string(), 145 + FilesMode::UpnpBrowse => browse 146 + .current_path 147 + .as_deref() 148 + .and_then(|p| p.rsplit('/').next()) 149 + .unwrap_or("UPnP") 150 + .to_string(), 151 + }; 78 152 79 153 let current_dir = browse.current_path.clone().unwrap_or_default(); 154 + let mode = browse.mode.clone(); 155 + let entries = self.entries.clone(); 80 156 81 157 div() 82 158 .size_full() 83 159 .flex() 84 160 .flex_col() 85 - // ── Header: back button + current path ──────────────────────────── 161 + // ── Header ──────────────────────────────────────────────────────── 86 162 .child( 87 163 div() 88 164 .flex() ··· 106 182 .when(can_go_back, |this| { 107 183 this.hover(|t| t.bg(theme.library_track_bg_hover)).on_click( 108 184 |_, _, cx: &mut App| { 109 - let state = cx.global_mut::<FilesBrowseState>(); 110 - if let Some(prev) = state.path_history.pop() { 111 - state.current_path = prev; 112 - } 185 + cx.global_mut::<FilesBrowseState>().go_back(); 113 186 }, 114 187 ) 115 188 }) ··· 123 196 .child(path_display), 124 197 ), 125 198 ) 126 - // ── File list ───────────────────────────────────────────────────── 127 - .child( 128 - uniform_list("files_list", entries.len(), move |range, _, _cx| { 129 - range 130 - .map(|idx| { 131 - let entry = entries[idx].clone(); 132 - let group_name: gpui::SharedString = format!("file_row_{}", idx).into(); 133 - let gn_icon = group_name.clone(); 134 - let gn_play = group_name.clone(); 135 - let gn_opts = group_name.clone(); 136 - let path_nav = entry.path.clone(); 137 - let path_play = entry.path.clone(); 138 - let path_opts = entry.path.clone(); 139 - let name_opts = entry.name.clone(); 140 - let cur_dir_play = current_dir.clone(); 141 - let cur_dir_opts = current_dir.clone(); 142 - let is_dir = entry.is_dir; 199 + // ── Content ─────────────────────────────────────────────────────── 200 + .child(match mode { 201 + FilesMode::Root => render_root(theme).into_any_element(), 202 + _ => render_entries(entries, current_dir, mode, theme).into_any_element(), 203 + }) 204 + } 205 + } 143 206 144 - div() 145 - .id(("file_row", idx)) 146 - .group(group_name) 147 - .w_full() 148 - .flex() 149 - .items_center() 150 - .gap_x_3() 151 - .px_4() 152 - .py_2p5() 153 - .cursor_pointer() 154 - .hover(|t| t.bg(theme.library_track_bg_hover)) 155 - // Directory: click row to navigate in 156 - .when(is_dir, |this| { 157 - this.on_click(move |_, _, cx: &mut App| { 158 - let state = cx.global_mut::<FilesBrowseState>(); 159 - state.path_history.push(state.current_path.clone()); 160 - state.current_path = Some(path_nav.clone()); 207 + fn render_root(theme: Theme) -> AnyElement { 208 + div() 209 + .flex_1() 210 + .min_h_0() 211 + .child( 212 + div() 213 + .id("root_music") 214 + .w_full() 215 + .flex() 216 + .items_center() 217 + .gap_x_3() 218 + .px_4() 219 + .py_2p5() 220 + .cursor_pointer() 221 + .hover(|t| t.bg(theme.library_track_bg_hover)) 222 + .on_click(|_, _, cx: &mut App| { 223 + cx.global_mut::<FilesBrowseState>().navigate(FilesMode::Local, None); 224 + }) 225 + .child( 226 + div() 227 + .w(px(22.0)) 228 + .h(px(22.0)) 229 + .flex_shrink_0() 230 + .flex() 231 + .items_center() 232 + .text_color(theme.library_text) 233 + .child(Icon::new(Icons::Directory).size_5()), 234 + ) 235 + .child( 236 + div() 237 + .flex_1() 238 + .min_w_0() 239 + .text_sm() 240 + .truncate() 241 + .text_color(theme.library_text) 242 + .child("Music"), 243 + ), 244 + ) 245 + .child( 246 + div() 247 + .id("root_upnp") 248 + .w_full() 249 + .flex() 250 + .items_center() 251 + .gap_x_3() 252 + .px_4() 253 + .py_2p5() 254 + .cursor_pointer() 255 + .hover(|t| t.bg(theme.library_track_bg_hover)) 256 + .on_click(|_, _, cx: &mut App| { 257 + cx.global_mut::<FilesBrowseState>() 258 + .navigate(FilesMode::UpnpDevices, Some("upnp://".to_string())); 259 + }) 260 + .child( 261 + div() 262 + .w(px(22.0)) 263 + .h(px(22.0)) 264 + .flex_shrink_0() 265 + .flex() 266 + .items_center() 267 + .text_color(theme.library_text) 268 + .child(Icon::new(Icons::Upnp).size_5()), 269 + ) 270 + .child( 271 + div() 272 + .flex_1() 273 + .min_w_0() 274 + .text_sm() 275 + .truncate() 276 + .text_color(theme.library_text) 277 + .child("UPnP Devices"), 278 + ), 279 + ) 280 + .into_any_element() 281 + } 282 + 283 + fn render_entries( 284 + entries: Vec<FileEntry>, 285 + current_dir: String, 286 + mode: FilesMode, 287 + theme: Theme, 288 + ) -> AnyElement { 289 + let is_device_list = mode == FilesMode::UpnpDevices; 290 + uniform_list("files_list", entries.len(), move |range, _, _cx| { 291 + range 292 + .map(|idx| { 293 + let entry = entries[idx].clone(); 294 + let group_name: gpui::SharedString = format!("file_row_{}", idx).into(); 295 + let gn_icon = group_name.clone(); 296 + let gn_play = group_name.clone(); 297 + let gn_opts = group_name.clone(); 298 + let path_nav = entry.path.clone(); 299 + let path_play = entry.path.clone(); 300 + let path_opts = entry.path.clone(); 301 + let name_opts = entry.name.clone(); 302 + let cur_dir_play = current_dir.clone(); 303 + let cur_dir_opts = current_dir.clone(); 304 + let is_dir = entry.is_dir; 305 + let is_upnp = entry.path.starts_with("upnp://"); 306 + 307 + let dir_icon = if is_device_list { 308 + Icons::Device 309 + } else { 310 + Icons::Directory 311 + }; 312 + 313 + div() 314 + .id(("file_row", idx)) 315 + .group(group_name) 316 + .w_full() 317 + .flex() 318 + .items_center() 319 + .gap_x_3() 320 + .px_4() 321 + .py_2p5() 322 + .hover(|t| t.bg(theme.library_track_bg_hover)) 323 + // ── Icon ───────────────────────────────────────────────── 324 + .child( 325 + div() 326 + .w(px(22.0)) 327 + .h(px(22.0)) 328 + .flex_shrink_0() 329 + .relative() 330 + .child( 331 + div() 332 + .absolute() 333 + .top_0() 334 + .left_0() 335 + .w_full() 336 + .h_full() 337 + .flex() 338 + .items_center() 339 + .when(!is_device_list, |this| { 340 + this.group_hover(gn_icon, |s| s.opacity(0.0)) 161 341 }) 162 - }) 163 - // ── Icon column (dir/music icon → play on hover) ── 164 - .child( 165 - div() 166 - .w(px(22.0)) 167 - .h(px(22.0)) 168 - .flex_shrink_0() 169 - .relative() 170 - // Default icon (hidden on hover) 171 - .child( 172 - div() 173 - .absolute() 174 - .top_0() 175 - .left_0() 176 - .w_full() 177 - .h_full() 178 - .flex() 179 - .items_center() 180 - .group_hover(gn_icon, |s| s.opacity(0.0)) 181 - .text_color(if is_dir { 182 - theme.library_text 183 - } else { 184 - theme.library_header_text 185 - }) 186 - .child( 187 - Icon::new(if is_dir { 188 - Icons::Directory 189 - } else { 190 - Icons::Music 191 - }) 192 - .size_5(), 193 - ), 194 - ) 195 - // Play icon (visible on hover) 196 - .child( 197 - div() 198 - .id(("file_play_icon", idx)) 199 - .absolute() 200 - .top_0() 201 - .left(px(-3.0)) 202 - .w_full() 203 - .h_full() 204 - .flex() 205 - .items_center() 206 - .opacity(0.0) 207 - .group_hover(gn_play, |s| s.opacity(1.0)) 208 - .cursor_pointer() 209 - .text_color(theme.library_text) 210 - .on_click(move |_, _, cx: &mut App| { 211 - cx.stop_propagation(); 212 - let rt = cx.global::<Controller>().rt(); 213 - if is_dir { 214 - rt.spawn(play_directory( 215 - path_play.clone(), 216 - false, 217 - )); 218 - } else { 219 - rt.spawn(play_directory_at( 220 - cur_dir_play.clone(), 221 - idx as i32, 222 - )); 223 - } 224 - }) 225 - .child(Icon::new(Icons::Play).size_5()), 226 - ), 227 - ) 228 - // ── Name ───────────────────────────────────── 229 - .child( 230 - div() 231 - .flex_1() 232 - .min_w_0() 233 - .text_sm() 234 - .truncate() 235 - .text_color(theme.library_text) 236 - .child(entry.name.clone()), 237 - ) 238 - // ── Context menu button (⋮) ────────────────── 239 - .child( 342 + .text_color(if is_dir { 343 + theme.library_text 344 + } else { 345 + theme.library_header_text 346 + }) 347 + .child( 348 + Icon::new(if is_dir { dir_icon } else { Icons::Music }) 349 + .size_5(), 350 + ), 351 + ) 352 + .when(!is_device_list, |this| { 353 + this.child( 240 354 div() 241 - .id(("file_opts_btn", idx)) 242 - .w(px(28.0)) 243 - .flex_shrink_0() 355 + .id(("file_play_icon", idx)) 356 + .absolute() 357 + .top_0() 358 + .left(px(-3.0)) 359 + .w_full() 360 + .h_full() 244 361 .flex() 245 362 .items_center() 246 - .justify_center() 247 363 .opacity(0.0) 248 - .group_hover(gn_opts, |s| s.opacity(1.0)) 364 + .group_hover(gn_play, |s| s.opacity(1.0)) 249 365 .cursor_pointer() 250 - .text_color(theme.library_header_text) 251 - .on_click(move |event: &ClickEvent, _, cx: &mut App| { 366 + .text_color(theme.library_text) 367 + .on_click(move |_, _, cx: &mut App| { 252 368 cx.stop_propagation(); 253 - cx.global_mut::<FileContextMenuState>().0 = 254 - Some(FileContextMenu { 255 - pos: event.position(), 256 - path: path_opts.clone(), 257 - name: name_opts.clone(), 258 - is_dir, 259 - current_dir: cur_dir_opts.clone(), 260 - dir_idx: idx, 261 - }); 369 + let rt = cx.global::<Controller>().rt(); 370 + if is_dir { 371 + rt.spawn(play_directory(path_play.clone(), false)); 372 + } else { 373 + rt.spawn(play_directory_at( 374 + cur_dir_play.clone(), 375 + idx as i32, 376 + )); 377 + } 262 378 }) 263 - .child(Icon::new(Icons::Options).size_4()), 379 + .child(Icon::new(Icons::Play).size_5()), 264 380 ) 265 - }) 266 - .collect() 267 - }) 268 - .flex_1() 269 - .min_h_0(), 270 - ) 271 - } 381 + }), 382 + ) 383 + // ── Name — clicking navigates into directories ──────────── 384 + .child( 385 + div() 386 + .id(("file_name", idx)) 387 + .flex_1() 388 + .min_w_0() 389 + .text_sm() 390 + .truncate() 391 + .text_color(theme.library_text) 392 + .when(is_dir, |this| { 393 + this.cursor_pointer().on_click(move |_, _, cx: &mut App| { 394 + let new_mode = if is_upnp { 395 + FilesMode::UpnpBrowse 396 + } else { 397 + FilesMode::Local 398 + }; 399 + cx.global_mut::<FilesBrowseState>() 400 + .navigate(new_mode, Some(path_nav.clone())); 401 + }) 402 + }) 403 + .child(entry.name.clone()), 404 + ) 405 + // ── Context menu (not shown for device-list entries) ─────── 406 + .when(!is_device_list, |this| { 407 + this.child( 408 + div() 409 + .id(("file_opts_btn", idx)) 410 + .w(px(28.0)) 411 + .flex_shrink_0() 412 + .flex() 413 + .items_center() 414 + .justify_center() 415 + .opacity(0.0) 416 + .group_hover(gn_opts, |s| s.opacity(1.0)) 417 + .cursor_pointer() 418 + .text_color(theme.library_header_text) 419 + .on_click(move |event: &ClickEvent, _, cx: &mut App| { 420 + cx.stop_propagation(); 421 + cx.global_mut::<FileContextMenuState>().0 = 422 + Some(FileContextMenu { 423 + pos: event.position(), 424 + path: path_opts.clone(), 425 + name: name_opts.clone(), 426 + is_dir, 427 + current_dir: cur_dir_opts.clone(), 428 + dir_idx: idx, 429 + }); 430 + }) 431 + .child(Icon::new(Icons::Options).size_4()), 432 + ) 433 + }) 434 + }) 435 + .collect() 436 + }) 437 + .flex_1() 438 + .min_h_0() 439 + .into_any_element() 272 440 } 273 441 274 442 pub fn menu_item(
+17 -1
macos/Rockbox/Services/rockbox/v1alpha1/browse.pb.swift
··· 94 94 95 95 var customaction: Int32 = 0 96 96 97 + var displayName: String { 98 + get {return _displayName ?? String()} 99 + set {_displayName = newValue} 100 + } 101 + /// Returns true if `displayName` has been explicitly set. 102 + var hasDisplayName: Bool {return self._displayName != nil} 103 + /// Clears the value of `displayName`. Subsequent reads from it will return its default value. 104 + mutating func clearDisplayName() {self._displayName = nil} 105 + 97 106 var unknownFields = SwiftProtobuf.UnknownStorage() 98 107 99 108 init() {} 109 + 110 + fileprivate var _displayName: String? = nil 100 111 } 101 112 102 113 struct Rockbox_V1alpha1_TreeGetEntriesResponse: Sendable { ··· 267 278 268 279 extension Rockbox_V1alpha1_Entry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 269 280 static let protoMessageName: String = _protobuf_package + ".Entry" 270 - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}name\0\u{1}attr\0\u{3}time_write\0\u{1}customaction\0") 281 + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}name\0\u{1}attr\0\u{3}time_write\0\u{1}customaction\0\u{4}display_name\0") 271 282 272 283 mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { 273 284 while let fieldNumber = try decoder.nextFieldNumber() { ··· 279 290 case 2: try { try decoder.decodeSingularInt32Field(value: &self.attr) }() 280 291 case 3: try { try decoder.decodeSingularUInt32Field(value: &self.timeWrite) }() 281 292 case 4: try { try decoder.decodeSingularInt32Field(value: &self.customaction) }() 293 + case 5: try { try decoder.decodeSingularStringField(value: &self._displayName) }() 282 294 default: break 283 295 } 284 296 } ··· 297 309 if self.customaction != 0 { 298 310 try visitor.visitSingularInt32Field(value: self.customaction, fieldNumber: 4) 299 311 } 312 + if let v = self._displayName { 313 + try visitor.visitSingularStringField(value: v, fieldNumber: 5) 314 + } 300 315 try unknownFields.traverse(visitor: &visitor) 301 316 } 302 317 ··· 305 320 if lhs.attr != rhs.attr {return false} 306 321 if lhs.timeWrite != rhs.timeWrite {return false} 307 322 if lhs.customaction != rhs.customaction {return false} 323 + if lhs._displayName != rhs._displayName {return false} 308 324 if lhs.unknownFields != rhs.unknownFields {return false} 309 325 return true 310 326 }
+130 -67
macos/Rockbox/Views/Files/FilesListView.swift
··· 2 2 // FilesListView.swift 3 3 // Rockbox 4 4 // 5 - // Created by Tsiry Sandratraina on 14/12/2025. 6 - // 7 5 8 6 import Foundation 9 7 import SwiftUI 10 8 9 + enum FilesMode { 10 + case root 11 + case local 12 + case upnpDevices 13 + case upnpBrowse 14 + } 11 15 12 16 struct FilesListView: View { 13 17 @State private var files: [FileItem] = [] 14 18 @State private var errorText: String? 15 19 @State private var currentPath: String? = nil 16 - @State private var pathHistory: [String?] = [] // For back navigation 17 - 20 + @State private var mode: FilesMode = .root 21 + @State private var history: [(FilesMode, String?)] = [] 22 + 18 23 var body: some View { 19 24 VStack(spacing: 0) { 20 25 HStack { ··· 26 31 .contentShape(Rectangle()) 27 32 } 28 33 .buttonStyle(.plain) 29 - .disabled(pathHistory.isEmpty) 30 - .opacity(pathHistory.isEmpty ? 0.3 : 1) 31 - 32 - Text(currentPathDisplay) 34 + .disabled(history.isEmpty) 35 + .opacity(history.isEmpty ? 0.3 : 1) 36 + 37 + Text(pathDisplay) 33 38 .font(.system(size: 13, weight: .medium)) 34 39 .lineLimit(1) 35 - 40 + 36 41 Spacer() 37 42 } 38 43 .padding(.horizontal, 16) 39 44 .padding(.vertical, 10) 40 - 45 + 41 46 Divider() 42 - 43 - ScrollView { 44 - LazyVStack(spacing: 0) { 45 - // Header row 46 - FileHeaderRow() 47 - 48 - Divider() 49 - 50 - // File rows 51 - ForEach(Array(files.enumerated()), id: \.element.id) { index, file in 52 - FileRowView(file: file, isEven: index % 2 == 0, selectedIndex: index, currentDirectory: currentPath ?? "") 53 - .contentShape(Rectangle()) 54 - .onTapGesture { 55 - if file.type == .directory { 56 - navigateTo(file: file) 57 - } 58 - } 59 - } 60 - } 47 + 48 + if mode == .root { 49 + rootView 50 + } else { 51 + fileListView 61 52 } 62 53 } 63 54 .task(id: currentPath) { 55 + guard mode != .root else { return } 64 56 await loadFiles() 65 57 } 66 58 .alert("gRPC Error", isPresented: .constant(errorText != nil)) { ··· 69 61 Text(errorText ?? "") 70 62 } 71 63 } 72 - 73 - private var currentPathDisplay: String { 74 - if let path = currentPath { 75 - return URL(string: path)?.lastPathComponent ?? path 64 + 65 + // MARK: - Root landing 66 + 67 + private var rootView: some View { 68 + ScrollView { 69 + LazyVStack(spacing: 0) { 70 + rootRow(name: "Music", systemImage: "folder") { 71 + navigate(to: .local, path: nil) 72 + } 73 + rootRow(name: "UPnP Devices", systemImage: "network") { 74 + navigate(to: .upnpDevices, path: "upnp://") 75 + } 76 + } 77 + } 78 + } 79 + 80 + private func rootRow(name: String, systemImage: String, action: @escaping () -> Void) -> some View { 81 + Button(action: action) { 82 + HStack(spacing: 12) { 83 + Image(systemName: systemImage) 84 + .frame(width: 20, height: 20) 85 + Text(name) 86 + .font(.system(size: 13)) 87 + Spacer() 88 + Image(systemName: "chevron.right") 89 + .font(.system(size: 11)) 90 + .foregroundColor(.secondary) 91 + } 92 + .padding(.horizontal, 16) 93 + .padding(.vertical, 10) 94 + .contentShape(Rectangle()) 95 + } 96 + .buttonStyle(.plain) 97 + } 98 + 99 + // MARK: - File list 100 + 101 + private var fileListView: some View { 102 + ScrollView { 103 + LazyVStack(spacing: 0) { 104 + FileHeaderRow() 105 + Divider() 106 + ForEach(Array(files.enumerated()), id: \.element.id) { index, file in 107 + FileRowView( 108 + file: file, 109 + isEven: index % 2 == 0, 110 + selectedIndex: index, 111 + currentDirectory: currentPath ?? "" 112 + ) 113 + .contentShape(Rectangle()) 114 + .onTapGesture { 115 + if file.type == .directory { 116 + navigateTo(file: file) 117 + } 118 + } 119 + } 120 + } 121 + } 122 + } 123 + 124 + // MARK: - Navigation 125 + 126 + private var pathDisplay: String { 127 + switch mode { 128 + case .root: return "Files" 129 + case .local: 130 + guard let path = currentPath else { return "Music" } 131 + return URL(fileURLWithPath: path).lastPathComponent 132 + case .upnpDevices: return "UPnP Devices" 133 + case .upnpBrowse: 134 + return currentPath?.split(separator: "/").last.map(String.init) ?? "UPnP" 76 135 } 77 - return "Files" 78 136 } 79 - 137 + 138 + private func navigate(to newMode: FilesMode, path: String?) { 139 + history.append((mode, currentPath)) 140 + mode = newMode 141 + currentPath = path 142 + } 143 + 144 + private func navigateTo(file: FileItem) { 145 + let newMode: FilesMode = file.path.hasPrefix("upnp://") ? .upnpBrowse : .local 146 + navigate(to: newMode, path: file.path) 147 + } 148 + 149 + private func goBack() { 150 + guard let (prevMode, prevPath) = history.popLast() else { return } 151 + mode = prevMode 152 + currentPath = prevPath 153 + if mode == .root { files = [] } 154 + } 155 + 156 + // MARK: - Data loading 157 + 80 158 private func loadFiles() async { 81 159 do { 82 - let data = try await fetchFiles(path: currentPath) 83 - var newFiles: [FileItem] = [] 84 - 85 - for entry in data { 86 - if entry.attr == 16 { 87 - newFiles.append(FileItem( 88 - name: URL(string: entry.name)?.lastPathComponent ?? "", 89 - path: entry.name, 90 - type: .directory, 91 - size: nil, 92 - itemCount: nil 93 - )) 160 + let entries = try await fetchFiles(path: currentPath) 161 + files = entries.compactMap { entry -> FileItem? in 162 + let isDir = entry.attr == 16 163 + let displayName: String 164 + if entry.hasDisplayName { 165 + displayName = entry.displayName 166 + } else if entry.name.hasPrefix("upnp://") { 167 + displayName = entry.name 94 168 } else { 95 - newFiles.append(FileItem( 96 - name: URL(string: entry.name)?.lastPathComponent ?? "", 97 - path: entry.name, 98 - type: .audioFile, 99 - size: nil, 100 - itemCount: nil 101 - )) 169 + displayName = URL(fileURLWithPath: entry.name).lastPathComponent 102 170 } 171 + return FileItem( 172 + name: displayName, 173 + path: entry.name, 174 + type: isDir ? .directory : .audioFile, 175 + size: nil, 176 + itemCount: nil 177 + ) 103 178 } 104 - 105 - files = newFiles.sorted { a, b in 179 + .sorted { a, b in 106 180 if a.type == b.type { 107 181 return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending 108 182 } 109 183 return a.type == .directory 110 184 } 111 - 112 185 } catch { 113 186 errorText = String(describing: error) 114 187 } 115 - } 116 - 117 - private func navigateTo(file: FileItem) { 118 - pathHistory.append(currentPath) 119 - currentPath = file.path 120 - } 121 - 122 - private func goBack() { 123 - guard !pathHistory.isEmpty else { return } 124 - currentPath = pathHistory.removeLast() 125 188 } 126 189 }
+50 -16
webui/rockbox/src/Components/Files/Files.tsx
··· 4 4 import { createColumnHelper } from "@tanstack/react-table"; 5 5 import Sidebar from "../Sidebar"; 6 6 import ControlBar from "../ControlBar"; 7 - import { Folder2, MusicNoteBeamed } from "@styled-icons/bootstrap"; 7 + import { Folder2, HddNetwork, MusicNoteBeamed } from "@styled-icons/bootstrap"; 8 8 import { 9 9 AudioFile, 10 10 BackButton, ··· 32 32 refetching?: boolean; 33 33 onPlayTrack: (path: string, index: number) => void; 34 34 onPlayDirectory: (path: string) => void; 35 + onNavigateDirectory?: (file: File) => void; 35 36 }; 36 37 37 38 const Files: FC<FilesProps> = (props) => { ··· 49 50 marginLeft: 10, 50 51 }} 51 52 > 52 - {info.row.original.isDirectory && ( 53 + {info.row.original.isDirectory && 54 + info.row.original.path !== "__local__" && 55 + info.row.original.path !== "upnp://" && ( 53 56 <div> 54 57 <div 55 58 className="play" ··· 62 65 </div> 63 66 </div> 64 67 )} 68 + {info.row.original.path === "__local__" && ( 69 + <div className="no-play"> 70 + <div className="folder"> 71 + <Folder2 size={20} /> 72 + </div> 73 + </div> 74 + )} 75 + {info.row.original.path === "upnp://" && ( 76 + <div className="no-play"> 77 + <div className="folder"> 78 + <HddNetwork size={20} /> 79 + </div> 80 + </div> 81 + )} 65 82 {!info.row.original.isDirectory && ( 66 83 <div> 67 84 <div ··· 85 102 header: "", 86 103 cell: (info) => ( 87 104 <> 88 - {info.row.original.isDirectory && ( 105 + {info.row.original.isDirectory && props.onNavigateDirectory && ( 106 + <Directory 107 + to="#" 108 + onClick={(e) => { 109 + e.preventDefault(); 110 + props.onNavigateDirectory!(info.row.original); 111 + }} 112 + > 113 + {info.getValue()} 114 + </Directory> 115 + )} 116 + {info.row.original.isDirectory && !props.onNavigateDirectory && ( 89 117 <Directory to={`/files?q=${info.row.original.path}`}> 90 118 {info.getValue()} 91 119 </Directory> ··· 106 134 columnHelper.accessor("name", { 107 135 header: "", 108 136 // eslint-disable-next-line @typescript-eslint/no-unused-vars 109 - cell: (info) => ( 110 - <ButtonGroup 111 - style={{ justifyContent: "flex-end", alignItems: "center" }} 112 - > 113 - <ContextMenu 114 - entry={{ 115 - title: info.row.original.name, 116 - isDirectory: info.row.original.isDirectory, 117 - path: info.row.original.path, 118 - }} 119 - /> 120 - </ButtonGroup> 121 - ), 137 + cell: (info) => { 138 + const isRootEntry = 139 + info.row.original.path === "__local__" || 140 + info.row.original.path === "upnp://"; 141 + if (isRootEntry) return <ButtonGroup />; 142 + return ( 143 + <ButtonGroup 144 + style={{ justifyContent: "flex-end", alignItems: "center" }} 145 + > 146 + <ContextMenu 147 + entry={{ 148 + title: info.row.original.name, 149 + isDirectory: info.row.original.isDirectory, 150 + path: info.row.original.path, 151 + }} 152 + /> 153 + </ButtonGroup> 154 + ); 155 + }, 122 156 }), 123 157 ]; 124 158
+47 -9
webui/rockbox/src/Components/Files/FilesWithData.tsx
··· 5 5 usePlayDirectoryMutation, 6 6 } from "../../Hooks/GraphQL"; 7 7 import { useNavigate, useSearchParams } from "react-router-dom"; 8 + import { File } from "../../Types/file"; 9 + 10 + const ROOT_ENTRIES: File[] = [ 11 + { name: "Music", path: "__local__", isDirectory: true }, 12 + { name: "UPnP Devices", path: "upnp://", isDirectory: true }, 13 + ]; 8 14 9 15 const FilesWithData: FC = () => { 10 16 const navigate = useNavigate(); 11 17 const [refetching, setRefetching] = useState(false); 12 18 const [params] = useSearchParams(); 13 19 const path = params.get("q"); 14 - const { data, isLoading, refetch } = useGetEntriesQuery(path !== null ? { path } : undefined); 15 - const canGoBack = !!path; 20 + const isRoot = path === null; 21 + 22 + // Resolve the actual path to query: __local__ means music root (no path arg). 23 + const queryPath = isRoot ? undefined : path === "__local__" ? undefined : path; 24 + const shouldFetch = !isRoot; 25 + 26 + // Eagerly prefetch UPnP devices on page load so the list appears instantly when 27 + // the user opens "UPnP Devices". React Query caches the result; on navigation it 28 + // serves the stale data immediately and revalidates in the background. 29 + useGetEntriesQuery( 30 + { path: "upnp://" }, 31 + { staleTime: 0, refetchOnMount: false } 32 + ); 33 + 34 + const { data, isLoading, refetch } = useGetEntriesQuery( 35 + shouldFetch ? (queryPath !== undefined ? { path: queryPath } : {}) : undefined, 36 + { enabled: shouldFetch } 37 + ); 16 38 const { mutate: playDirectory } = usePlayDirectoryMutation(); 17 39 18 - const files = 19 - data?.treeGetEntries.map((x) => ({ 20 - name: x.name.split("/").pop()!, 21 - isDirectory: x.attr === 16, 22 - path: x.name, 23 - })) || []; 40 + const files: File[] = isRoot 41 + ? ROOT_ENTRIES 42 + : data?.treeGetEntries.map((x) => ({ 43 + name: x.displayName ?? x.name.split("/").pop()!, 44 + isDirectory: x.attr === 16, 45 + path: x.name, 46 + })) ?? []; 24 47 48 + const canGoBack = !isRoot; 25 49 const onGoBack = () => navigate(-1); 26 50 27 51 const onPlayDirectory = (path: string) => { 52 + if (path.startsWith("upnp://") || path === "__local__") return; 28 53 playDirectory({ path, recurse: true }); 29 54 }; 30 55 31 56 const onPlayTrack = (path: string, position: number) => { 57 + if (path.startsWith("upnp://")) return; 32 58 playDirectory({ path, position }); 33 59 }; 34 60 61 + const onNavigateDirectory = (file: File) => { 62 + if (file.path === "__local__") { 63 + navigate("/files?q=__local__"); 64 + } else if (file.path.startsWith("upnp://")) { 65 + navigate(`/files?q=${encodeURIComponent(file.path)}`); 66 + } else { 67 + navigate(`/files?q=${file.path}`); 68 + } 69 + }; 70 + 35 71 useEffect(() => { 72 + if (!shouldFetch) return; 36 73 setRefetching(true); 37 74 refetch() 38 75 .then(() => setRefetching(false)) ··· 45 82 files={files} 46 83 canGoBack={canGoBack} 47 84 onGoBack={onGoBack} 48 - refetching={isLoading || refetching} 85 + refetching={shouldFetch && (isLoading || refetching)} 49 86 onPlayDirectory={onPlayDirectory} 50 87 onPlayTrack={onPlayTrack} 88 + onNavigateDirectory={onNavigateDirectory} 51 89 /> 52 90 ); 53 91 };
+3 -1
webui/rockbox/src/Hooks/GraphQL.tsx
··· 84 84 __typename?: 'Entry'; 85 85 attr: Scalars['Int']['output']; 86 86 customaction: Scalars['Int']['output']; 87 + displayName?: Maybe<Scalars['String']['output']>; 87 88 name: Scalars['String']['output']; 88 89 timeWrite: Scalars['Int']['output']; 89 90 }; ··· 878 879 }>; 879 880 880 881 881 - export type GetEntriesQuery = { __typename?: 'Query', treeGetEntries: Array<{ __typename?: 'Entry', name: string, attr: number, timeWrite: number }> }; 882 + export type GetEntriesQuery = { __typename?: 'Query', treeGetEntries: Array<{ __typename?: 'Entry', name: string, attr: number, timeWrite: number, displayName?: string | null }> }; 882 883 883 884 export type ConnectToDeviceMutationVariables = Exact<{ 884 885 id: Scalars['String']['input']; ··· 1289 1290 name 1290 1291 attr 1291 1292 timeWrite 1293 + displayName 1292 1294 } 1293 1295 } 1294 1296 `);
+1 -1
webui/rockbox/src/index.css
··· 71 71 } 72 72 73 73 74 - tr:hover td div .folder { 74 + tr:hover td div:not(.no-play) .folder { 75 75 display: none; 76 76 }