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.

gpui: add support macos media controls

+559 -47
+168 -10
gpui/Cargo.lock
··· 16 16 "reqwest", 17 17 "rust-embed", 18 18 "serde", 19 + "souvlaki", 19 20 "tokio", 20 21 "tonic", 21 22 "tonic-build", ··· 961 962 962 963 [[package]] 963 964 name = "cocoa" 965 + version = "0.24.1" 966 + source = "registry+https://github.com/rust-lang/crates.io-index" 967 + checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" 968 + dependencies = [ 969 + "bitflags 1.3.2", 970 + "block", 971 + "cocoa-foundation 0.1.2", 972 + "core-foundation 0.9.4", 973 + "core-graphics 0.22.3", 974 + "foreign-types 0.3.2", 975 + "libc", 976 + "objc", 977 + ] 978 + 979 + [[package]] 980 + name = "cocoa" 964 981 version = "0.25.0" 965 982 source = "registry+https://github.com/rust-lang/crates.io-index" 966 983 checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" ··· 970 987 "cocoa-foundation 0.1.2", 971 988 "core-foundation 0.9.4", 972 989 "core-graphics 0.23.2", 973 - "foreign-types", 990 + "foreign-types 0.5.0", 974 991 "libc", 975 992 "objc", 976 993 ] ··· 986 1003 "cocoa-foundation 0.2.0", 987 1004 "core-foundation 0.10.0", 988 1005 "core-graphics 0.24.0", 989 - "foreign-types", 1006 + "foreign-types 0.5.0", 990 1007 "libc", 991 1008 "objc", 992 1009 ] ··· 1133 1150 1134 1151 [[package]] 1135 1152 name = "core-graphics" 1153 + version = "0.22.3" 1154 + source = "registry+https://github.com/rust-lang/crates.io-index" 1155 + checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" 1156 + dependencies = [ 1157 + "bitflags 1.3.2", 1158 + "core-foundation 0.9.4", 1159 + "core-graphics-types 0.1.3", 1160 + "foreign-types 0.3.2", 1161 + "libc", 1162 + ] 1163 + 1164 + [[package]] 1165 + name = "core-graphics" 1136 1166 version = "0.23.2" 1137 1167 source = "registry+https://github.com/rust-lang/crates.io-index" 1138 1168 checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" ··· 1140 1170 "bitflags 1.3.2", 1141 1171 "core-foundation 0.9.4", 1142 1172 "core-graphics-types 0.1.3", 1143 - "foreign-types", 1173 + "foreign-types 0.5.0", 1144 1174 "libc", 1145 1175 ] 1146 1176 ··· 1153 1183 "bitflags 2.11.1", 1154 1184 "core-foundation 0.10.0", 1155 1185 "core-graphics-types 0.2.0", 1156 - "foreign-types", 1186 + "foreign-types 0.5.0", 1157 1187 "libc", 1158 1188 ] 1159 1189 ··· 1166 1196 "bitflags 2.11.1", 1167 1197 "core-foundation 0.9.4", 1168 1198 "core-graphics-types 0.1.3", 1169 - "foreign-types", 1199 + "foreign-types 0.5.0", 1170 1200 "libc", 1171 1201 ] 1172 1202 ··· 1213 1243 dependencies = [ 1214 1244 "core-foundation 0.10.0", 1215 1245 "core-graphics 0.24.0", 1216 - "foreign-types", 1246 + "foreign-types 0.5.0", 1217 1247 "libc", 1218 1248 ] 1219 1249 ··· 1355 1385 checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" 1356 1386 1357 1387 [[package]] 1388 + name = "dbus" 1389 + version = "0.9.11" 1390 + source = "registry+https://github.com/rust-lang/crates.io-index" 1391 + checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" 1392 + dependencies = [ 1393 + "libc", 1394 + "libdbus-sys", 1395 + "windows-sys 0.61.2", 1396 + ] 1397 + 1398 + [[package]] 1399 + name = "dbus-crossroads" 1400 + version = "0.5.3" 1401 + source = "registry+https://github.com/rust-lang/crates.io-index" 1402 + checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" 1403 + dependencies = [ 1404 + "dbus", 1405 + ] 1406 + 1407 + [[package]] 1358 1408 name = "deflate64" 1359 1409 version = "0.1.12" 1360 1410 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1871 1921 1872 1922 [[package]] 1873 1923 name = "foreign-types" 1924 + version = "0.3.2" 1925 + source = "registry+https://github.com/rust-lang/crates.io-index" 1926 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 1927 + dependencies = [ 1928 + "foreign-types-shared 0.1.1", 1929 + ] 1930 + 1931 + [[package]] 1932 + name = "foreign-types" 1874 1933 version = "0.5.0" 1875 1934 source = "registry+https://github.com/rust-lang/crates.io-index" 1876 1935 checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" 1877 1936 dependencies = [ 1878 1937 "foreign-types-macros", 1879 - "foreign-types-shared", 1938 + "foreign-types-shared 0.3.1", 1880 1939 ] 1881 1940 1882 1941 [[package]] ··· 1889 1948 "quote", 1890 1949 "syn 2.0.117", 1891 1950 ] 1951 + 1952 + [[package]] 1953 + name = "foreign-types-shared" 1954 + version = "0.1.1" 1955 + source = "registry+https://github.com/rust-lang/crates.io-index" 1956 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 1892 1957 1893 1958 [[package]] 1894 1959 name = "foreign-types-shared" ··· 2218 2283 "etagere", 2219 2284 "filedescriptor", 2220 2285 "flume", 2221 - "foreign-types", 2286 + "foreign-types 0.5.0", 2222 2287 "futures", 2223 2288 "gpui-macros", 2224 2289 "gpui_collections", ··· 2351 2416 "core-foundation 0.10.0", 2352 2417 "core-video", 2353 2418 "ctor", 2354 - "foreign-types", 2419 + "foreign-types 0.5.0", 2355 2420 "metal", 2356 2421 "objc", 2357 2422 ] ··· 3108 3173 checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" 3109 3174 3110 3175 [[package]] 3176 + name = "libdbus-sys" 3177 + version = "0.2.7" 3178 + source = "registry+https://github.com/rust-lang/crates.io-index" 3179 + checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" 3180 + dependencies = [ 3181 + "pkg-config", 3182 + ] 3183 + 3184 + [[package]] 3111 3185 name = "libfuzzer-sys" 3112 3186 version = "0.4.12" 3113 3187 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3323 3397 "bitflags 2.11.1", 3324 3398 "block", 3325 3399 "core-graphics-types 0.1.3", 3326 - "foreign-types", 3400 + "foreign-types 0.5.0", 3327 3401 "log", 3328 3402 "objc", 3329 3403 "paste", ··· 5213 5287 dependencies = [ 5214 5288 "libc", 5215 5289 "windows-sys 0.61.2", 5290 + ] 5291 + 5292 + [[package]] 5293 + name = "souvlaki" 5294 + version = "0.8.3" 5295 + source = "registry+https://github.com/rust-lang/crates.io-index" 5296 + checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" 5297 + dependencies = [ 5298 + "base64", 5299 + "block", 5300 + "cocoa 0.24.1", 5301 + "core-graphics 0.22.3", 5302 + "dbus", 5303 + "dbus-crossroads", 5304 + "dispatch", 5305 + "objc", 5306 + "thiserror 1.0.69", 5307 + "windows 0.44.0", 5216 5308 ] 5217 5309 5218 5310 [[package]] ··· 6681 6773 6682 6774 [[package]] 6683 6775 name = "windows" 6776 + version = "0.44.0" 6777 + source = "registry+https://github.com/rust-lang/crates.io-index" 6778 + checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" 6779 + dependencies = [ 6780 + "windows-targets 0.42.2", 6781 + ] 6782 + 6783 + [[package]] 6784 + name = "windows" 6684 6785 version = "0.57.0" 6685 6786 source = "registry+https://github.com/rust-lang/crates.io-index" 6686 6787 checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" ··· 6922 7023 6923 7024 [[package]] 6924 7025 name = "windows-targets" 7026 + version = "0.42.2" 7027 + source = "registry+https://github.com/rust-lang/crates.io-index" 7028 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 7029 + dependencies = [ 7030 + "windows_aarch64_gnullvm 0.42.2", 7031 + "windows_aarch64_msvc 0.42.2", 7032 + "windows_i686_gnu 0.42.2", 7033 + "windows_i686_msvc 0.42.2", 7034 + "windows_x86_64_gnu 0.42.2", 7035 + "windows_x86_64_gnullvm 0.42.2", 7036 + "windows_x86_64_msvc 0.42.2", 7037 + ] 7038 + 7039 + [[package]] 7040 + name = "windows-targets" 6925 7041 version = "0.48.5" 6926 7042 source = "registry+https://github.com/rust-lang/crates.io-index" 6927 7043 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 6979 7095 6980 7096 [[package]] 6981 7097 name = "windows_aarch64_gnullvm" 7098 + version = "0.42.2" 7099 + source = "registry+https://github.com/rust-lang/crates.io-index" 7100 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 7101 + 7102 + [[package]] 7103 + name = "windows_aarch64_gnullvm" 6982 7104 version = "0.48.5" 6983 7105 source = "registry+https://github.com/rust-lang/crates.io-index" 6984 7106 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 6997 7119 6998 7120 [[package]] 6999 7121 name = "windows_aarch64_msvc" 7122 + version = "0.42.2" 7123 + source = "registry+https://github.com/rust-lang/crates.io-index" 7124 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 7125 + 7126 + [[package]] 7127 + name = "windows_aarch64_msvc" 7000 7128 version = "0.48.5" 7001 7129 source = "registry+https://github.com/rust-lang/crates.io-index" 7002 7130 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 7012 7140 version = "0.53.1" 7013 7141 source = "registry+https://github.com/rust-lang/crates.io-index" 7014 7142 checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 7143 + 7144 + [[package]] 7145 + name = "windows_i686_gnu" 7146 + version = "0.42.2" 7147 + source = "registry+https://github.com/rust-lang/crates.io-index" 7148 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 7015 7149 7016 7150 [[package]] 7017 7151 name = "windows_i686_gnu" ··· 7045 7179 7046 7180 [[package]] 7047 7181 name = "windows_i686_msvc" 7182 + version = "0.42.2" 7183 + source = "registry+https://github.com/rust-lang/crates.io-index" 7184 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 7185 + 7186 + [[package]] 7187 + name = "windows_i686_msvc" 7048 7188 version = "0.48.5" 7049 7189 source = "registry+https://github.com/rust-lang/crates.io-index" 7050 7190 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" ··· 7063 7203 7064 7204 [[package]] 7065 7205 name = "windows_x86_64_gnu" 7206 + version = "0.42.2" 7207 + source = "registry+https://github.com/rust-lang/crates.io-index" 7208 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 7209 + 7210 + [[package]] 7211 + name = "windows_x86_64_gnu" 7066 7212 version = "0.48.5" 7067 7213 source = "registry+https://github.com/rust-lang/crates.io-index" 7068 7214 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" ··· 7081 7227 7082 7228 [[package]] 7083 7229 name = "windows_x86_64_gnullvm" 7230 + version = "0.42.2" 7231 + source = "registry+https://github.com/rust-lang/crates.io-index" 7232 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 7233 + 7234 + [[package]] 7235 + name = "windows_x86_64_gnullvm" 7084 7236 version = "0.48.5" 7085 7237 source = "registry+https://github.com/rust-lang/crates.io-index" 7086 7238 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" ··· 7096 7248 version = "0.53.1" 7097 7249 source = "registry+https://github.com/rust-lang/crates.io-index" 7098 7250 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 7251 + 7252 + [[package]] 7253 + name = "windows_x86_64_msvc" 7254 + version = "0.42.2" 7255 + source = "registry+https://github.com/rust-lang/crates.io-index" 7256 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 7099 7257 7100 7258 [[package]] 7101 7259 name = "windows_x86_64_msvc"
+1
gpui/Cargo.toml
··· 25 25 tonic-reflection = "0.12.3" 26 26 tonic-web = "0.12.3" 27 27 http = "1.4.0" 28 + souvlaki = "0.8" 28 29 29 30 [build-dependencies] 30 31 tonic-build = "0.12.3"
+17 -5
gpui/src/client.rs
··· 6 6 GetCurrentRequest, GetGlobalSettingsRequest, GetGlobalStatusRequest, GetLikedTracksRequest, 7 7 GetTracksRequest, InsertTracksRequest, LikeTrackRequest, NextRequest, PauseRequest, 8 8 PlayAlbumRequest, PlayAllTracksRequest, PlayArtistTracksRequest, PlayTrackRequest, 9 - PlaylistResumeRequest, PreviousRequest, RemoveTracksRequest, ResumeRequest, ResumeTrackRequest, 9 + FastForwardRewindRequest, PlaylistResumeRequest, PreviousRequest, RemoveTracksRequest, 10 + ResumeRequest, ResumeTrackRequest, 10 11 SaveSettingsRequest, SearchRequest, StartRequest, StreamCurrentTrackRequest, 11 12 StreamPlaylistRequest, StreamStatusRequest, UnlikeTrackRequest, 12 13 }; ··· 73 74 pub async fn pause() -> Result<()> { 74 75 let mut c = PlaybackServiceClient::connect(URL).await?; 75 76 c.pause(PauseRequest {}).await?; 77 + Ok(()) 78 + } 79 + 80 + /// Seek to `new_time_ms` milliseconds from the start of the current track. 81 + pub async fn seek(new_time_ms: i32) -> Result<()> { 82 + let mut c = PlaybackServiceClient::connect(URL).await?; 83 + c.fast_forward_rewind(FastForwardRewindRequest { new_time: new_time_ms }).await?; 76 84 Ok(()) 77 85 } 78 86 ··· 409 417 loop { 410 418 match stream.message().await { 411 419 Ok(Some(msg)) => { 412 - let new_status = match msg.status { 413 - 1 => PlaybackStatus::Playing, 414 - 2 => PlaybackStatus::Paused, 415 - _ => PlaybackStatus::Stopped, 420 + // audio_status() is a bitmask: PLAY=0x01, PAUSE=0x02. 421 + // Paused-while-playing reports 0x03 — check PAUSE bit first. 422 + let new_status = if msg.status & 0x02 != 0 { 423 + PlaybackStatus::Paused 424 + } else if msg.status & 0x01 != 0 { 425 + PlaybackStatus::Playing 426 + } else { 427 + PlaybackStatus::Stopped 416 428 }; 417 429 let _ = tx.send(StateUpdate::Status(new_status)).await; 418 430 }
+65 -3
gpui/src/controller.rs
··· 1 - use crate::state::{AppState, StateUpdate}; 1 + use crate::now_playing::{MediaCommand, NowPlayingManager}; 2 + use crate::state::{AppState, PlaybackStatus, StateUpdate}; 2 3 use crate::ui::components::LikedSongs; 3 4 use gpui::{App, Entity, Global}; 4 5 use std::sync::{ 5 6 atomic::{AtomicU64, Ordering}, 6 - Arc, 7 + Arc, Mutex, 7 8 }; 8 9 use std::time::Duration; 9 10 use tokio::sync::mpsc; ··· 13 14 rt: tokio::runtime::Runtime, 14 15 tx: mpsc::Sender<StateUpdate>, 15 16 search_gen: Arc<AtomicU64>, 17 + #[allow(dead_code)] 18 + now_playing: Option<Arc<Mutex<NowPlayingManager>>>, 16 19 } 17 20 18 21 impl Controller { ··· 34 37 rt.spawn(crate::client::run_current_track_stream(tx.clone())); 35 38 rt.spawn(crate::client::run_playlist_stream(tx.clone())); 36 39 40 + // Initialise OS media controls on the main thread (required by macOS). 41 + let now_playing = NowPlayingManager::new().map(|m| Arc::new(Mutex::new(m))); 42 + 37 43 // GPUI foreground poll task — not required to be Send 38 44 let state_for_poll = state.clone(); 45 + let np_for_poll = now_playing.clone(); 46 + let rt_handle = rt.handle().clone(); 39 47 cx.spawn(async move |cx| { 40 48 let mut rx = rx; 41 49 loop { ··· 71 79 state_for_poll.update(app, |_, cx| cx.notify()); 72 80 }); 73 81 } 82 + 83 + // Media-controls tick: drain OS key events and push now-playing info. 84 + if let Some(np) = &np_for_poll { 85 + if let Ok(mut np) = np.try_lock() { 86 + // Execute any media-key commands. 87 + for cmd in np.drain_commands() { 88 + match cmd { 89 + MediaCommand::Play => { 90 + rt_handle.spawn(crate::client::resume()); 91 + } 92 + MediaCommand::Pause => { 93 + rt_handle.spawn(crate::client::pause()); 94 + } 95 + MediaCommand::Toggle => { 96 + let status = cx 97 + .update(|app| state_for_poll.read(app).status) 98 + .unwrap_or(PlaybackStatus::Stopped); 99 + match status { 100 + PlaybackStatus::Playing => { 101 + rt_handle.spawn(crate::client::pause()); 102 + } 103 + _ => { 104 + rt_handle.spawn(crate::client::resume()); 105 + } 106 + } 107 + } 108 + MediaCommand::Next => { 109 + rt_handle.spawn(crate::client::next()); 110 + } 111 + MediaCommand::Prev => { 112 + rt_handle.spawn(crate::client::prev()); 113 + } 114 + MediaCommand::SeekTo(pos) => { 115 + let ms = pos.as_millis() as i32; 116 + rt_handle.spawn(crate::client::seek(ms)); 117 + } 118 + } 119 + } 120 + 121 + // Push current playback state to the OS notification bar. 122 + let _ = cx.update(|app| { 123 + let s = state_for_poll.read(app); 124 + np.update(s.current_track(), s.status, s.position); 125 + }); 126 + } 127 + } 128 + 74 129 cx.background_executor() 75 130 .timer(Duration::from_millis(100)) 76 131 .await; ··· 78 133 }) 79 134 .detach(); 80 135 81 - Controller { state, rt, tx, search_gen: Arc::new(AtomicU64::new(0)) } 136 + Controller { state, rt, tx, search_gen: Arc::new(AtomicU64::new(0)), now_playing } 82 137 } 83 138 84 139 /// Cloneable handle to the tokio runtime — use for fire-and-forget spawns. ··· 94 149 95 150 pub fn prev(&self) { 96 151 self.rt().spawn(crate::client::prev()); 152 + } 153 + 154 + /// Seek to `position_secs` seconds from the start of the current track. 155 + pub fn seek(&self, position_secs: u64, duration_secs: u64) { 156 + if duration_secs == 0 { return; } 157 + let ms = (position_secs as i32).saturating_mul(1000); 158 + self.rt().spawn(crate::client::seek(ms)); 97 159 } 98 160 99 161 pub fn play_track_at_idx(&self, idx: usize, cx: &App) {
+1
gpui/src/main.rs
··· 3 3 pub mod client; 4 4 pub mod controller; 5 5 pub mod http_client; 6 + pub mod now_playing; 6 7 pub mod startup; 7 8 pub mod state; 8 9 pub mod ui;
+113
gpui/src/now_playing.rs
··· 1 + use crate::state::{PlaybackStatus, Track}; 2 + use souvlaki::{ 3 + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, 4 + }; 5 + use std::sync::mpsc; 6 + use std::time::Duration; 7 + 8 + /// Commands forwarded from the OS media-control callbacks to the GPUI poll loop. 9 + pub enum MediaCommand { 10 + Play, 11 + Pause, 12 + Toggle, 13 + Next, 14 + Prev, 15 + SeekTo(Duration), 16 + } 17 + 18 + /// Owns the souvlaki `MediaControls` handle and a channel for incoming OS commands. 19 + /// 20 + /// Must be created on the main thread (macOS requires MPRemoteCommandCenter 21 + /// registration on the main thread). The GPUI foreground poll loop — also on 22 + /// the main thread — calls `drain_commands` and `update` each tick. 23 + pub struct NowPlayingManager { 24 + controls: MediaControls, 25 + cmd_rx: mpsc::Receiver<MediaCommand>, 26 + /// Last track id pushed to the OS so we only re-send metadata on change. 27 + last_track_id: String, 28 + } 29 + 30 + impl NowPlayingManager { 31 + /// Returns `None` if the OS media-control API is unavailable. 32 + pub fn new() -> Option<Self> { 33 + let (cmd_tx, cmd_rx) = mpsc::channel::<MediaCommand>(); 34 + 35 + let cfg = PlatformConfig { 36 + dbus_name: "org.rockbox.Rockbox", 37 + display_name: "Rockbox", 38 + hwnd: None, 39 + }; 40 + 41 + let mut controls = MediaControls::new(cfg).ok()?; 42 + 43 + controls 44 + .attach(move |event: MediaControlEvent| { 45 + let cmd = match event { 46 + MediaControlEvent::Play => Some(MediaCommand::Play), 47 + MediaControlEvent::Pause => Some(MediaCommand::Pause), 48 + MediaControlEvent::Toggle => Some(MediaCommand::Toggle), 49 + MediaControlEvent::Next => Some(MediaCommand::Next), 50 + MediaControlEvent::Previous => Some(MediaCommand::Prev), 51 + MediaControlEvent::SetPosition(MediaPosition(pos)) => { 52 + Some(MediaCommand::SeekTo(pos)) 53 + } 54 + _ => None, 55 + }; 56 + if let Some(c) = cmd { 57 + let _ = cmd_tx.send(c); 58 + } 59 + }) 60 + .ok()?; 61 + 62 + Some(NowPlayingManager { controls, cmd_rx, last_track_id: String::new() }) 63 + } 64 + 65 + /// Drain all pending OS media-key commands (non-blocking). 66 + pub fn drain_commands(&mut self) -> Vec<MediaCommand> { 67 + let mut out = Vec::new(); 68 + while let Ok(cmd) = self.cmd_rx.try_recv() { 69 + out.push(cmd); 70 + } 71 + out 72 + } 73 + 74 + /// Push the current playback state and track metadata to the OS. 75 + pub fn update(&mut self, track: Option<&Track>, status: PlaybackStatus, position: u64) { 76 + let progress = Some(MediaPosition(Duration::from_secs(position))); 77 + 78 + let playback = match status { 79 + PlaybackStatus::Playing => MediaPlayback::Playing { progress }, 80 + PlaybackStatus::Paused => MediaPlayback::Paused { progress }, 81 + PlaybackStatus::Stopped => MediaPlayback::Stopped, 82 + }; 83 + let _ = self.controls.set_playback(playback); 84 + 85 + // Metadata only on track change 86 + let track_id = track.map(|t| t.id.as_str()).unwrap_or(""); 87 + if track_id == self.last_track_id { 88 + return; 89 + } 90 + self.last_track_id = track_id.to_string(); 91 + 92 + // album_art is a bare filename served by rockboxd's cover HTTP server. 93 + let cover_url = track 94 + .and_then(|t| t.album_art.as_deref()) 95 + .filter(|s| !s.is_empty()) 96 + .map(|name| format!("http://localhost:6062/covers/{}", name)); 97 + 98 + let meta = track 99 + .map(|t| MediaMetadata { 100 + title: if t.title.is_empty() { None } else { Some(t.title.as_str()) }, 101 + artist: if t.artist.is_empty() { None } else { Some(t.artist.as_str()) }, 102 + album: if t.album.is_empty() { None } else { Some(t.album.as_str()) }, 103 + cover_url: cover_url.as_deref(), 104 + duration: if t.duration > 0 { 105 + Some(Duration::from_secs(t.duration)) 106 + } else { 107 + None 108 + }, 109 + }) 110 + .unwrap_or_default(); 111 + let _ = self.controls.set_metadata(meta); 112 + } 113 + }
+18 -15
gpui/src/ui/components/controlbar.rs
··· 1 1 use crate::controller::Controller; 2 2 use crate::state::format_duration; 3 3 use crate::ui::components::icons::{Icon, Icons}; 4 - use crate::ui::helpers::secs_to_slider; 4 + use crate::ui::components::seek_bar::SeekBar; 5 5 use crate::ui::theme::Theme; 6 - use gpui::{div, px, Context, IntoElement, ParentElement, Render, Styled, Window}; 6 + use gpui::{div, px, App, Context, IntoElement, ParentElement, Render, Styled, Window}; 7 7 8 8 pub struct ControlBar; 9 9 ··· 15 15 let duration = state.current_track().map(|t| t.duration).unwrap_or(0); 16 16 let position = state.position; 17 17 let vol_fill = crate::state::volume_fraction(state.volume); 18 - let fill_pct = secs_to_slider(position, duration); 18 + let fill_fraction = if duration > 0 { 19 + (position as f32 / duration as f32).clamp(0.0, 1.0) 20 + } else { 21 + 0.0 22 + }; 19 23 let vol_pct = (vol_fill * 100.0) as u32; 20 24 21 25 div() ··· 43 47 .child(format_duration(position)), 44 48 ) 45 49 .child( 46 - div() 47 - .flex_1() 48 - .h(px(4.0)) 49 - .rounded_full() 50 - .bg(theme.playback_slider_track) 51 - .child( 52 - div() 53 - .h_full() 54 - .rounded_full() 55 - .bg(theme.playback_slider_fill) 56 - .w(px(fill_pct / 100.0 * 800.0)), 57 - ), 50 + SeekBar::new( 51 + "controlbar-seek", 52 + fill_fraction, 53 + theme.playback_slider_track, 54 + theme.playback_slider_fill, 55 + px(4.0), 56 + ) 57 + .on_seek(move |frac, _window, cx: &mut App| { 58 + let seek_secs = (frac * duration as f32) as u64; 59 + cx.global::<Controller>().seek(seek_secs, duration); 60 + }), 58 61 ) 59 62 .child( 60 63 div()
+17 -14
gpui/src/ui/components/miniplayer.rs
··· 4 4 use crate::ui::components::icons::{Icon, Icons}; 5 5 use crate::ui::components::{LikedSongs, Page}; 6 6 use crate::ui::global_keybinds::play_pause; 7 - use crate::ui::helpers::secs_to_slider; 7 + use crate::ui::components::seek_bar::SeekBar; 8 8 use crate::ui::theme::Theme; 9 9 use gpui::prelude::FluentBuilder; 10 10 use gpui::{ ··· 38 38 .filter(|s| !s.is_empty()) 39 39 .map(|id| format!("http://localhost:6062/covers/{id}")); 40 40 let position = state.position; 41 - let fill = secs_to_slider(position, duration) / 100.0; 41 + let fill_fraction = if duration > 0 { 42 + (position as f32 / duration as f32).clamp(0.0, 1.0) 43 + } else { 44 + 0.0 45 + }; 42 46 let vol_fill = volume_fraction(state.volume); 43 47 let vol_pct = (vol_fill * 100.0) as u32; 44 48 let is_shuffling = state.shuffling; ··· 323 327 .child(format_duration(position)), 324 328 ) 325 329 .child( 326 - div() 327 - .flex_1() 328 - .h(px(3.0)) 329 - .rounded_full() 330 - .bg(theme.playback_slider_track) 331 - .child( 332 - div() 333 - .h_full() 334 - .rounded_full() 335 - .bg(theme.playback_slider_fill) 336 - .w(relative(fill)), 337 - ), 330 + SeekBar::new( 331 + "miniplayer-seek", 332 + fill_fraction, 333 + theme.playback_slider_track, 334 + theme.playback_slider_fill, 335 + px(3.0), 336 + ) 337 + .on_seek(move |frac, _window, cx: &mut App| { 338 + let seek_secs = (frac * duration as f32) as u64; 339 + cx.global::<Controller>().seek(seek_secs, duration); 340 + }), 338 341 ) 339 342 .child( 340 343 div()
+1
gpui/src/ui/components/mod.rs
··· 4 4 pub mod navbar; 5 5 pub mod pages; 6 6 pub mod search_input; 7 + pub mod seek_bar; 7 8 pub mod titlebar; 8 9 9 10 #[derive(Clone, Copy, PartialEq)]
+158
gpui/src/ui/components/seek_bar.rs
··· 1 + use gpui::{ 2 + App, BorderStyle, Bounds, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId, 3 + Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseDownEvent, Pixels, 4 + Window, px, 5 + }; 6 + 7 + /// A clickable horizontal seek bar. 8 + /// 9 + /// Fills left-to-right according to `fill_fraction` (0.0–1.0). 10 + /// Calls `on_seek(fraction, window, cx)` when the user presses anywhere on the track. 11 + pub struct SeekBar { 12 + id: ElementId, 13 + fill_fraction: f32, 14 + track_color: gpui::Rgba, 15 + fill_color: gpui::Rgba, 16 + height: Pixels, 17 + on_seek: Option<Box<dyn Fn(f32, &mut Window, &mut App) + 'static>>, 18 + } 19 + 20 + impl SeekBar { 21 + pub fn new( 22 + id: impl Into<ElementId>, 23 + fill_fraction: f32, 24 + track_color: gpui::Rgba, 25 + fill_color: gpui::Rgba, 26 + height: Pixels, 27 + ) -> Self { 28 + SeekBar { 29 + id: id.into(), 30 + fill_fraction, 31 + track_color, 32 + fill_color, 33 + height, 34 + on_seek: None, 35 + } 36 + } 37 + 38 + pub fn on_seek( 39 + mut self, 40 + f: impl Fn(f32, &mut Window, &mut App) + 'static, 41 + ) -> Self { 42 + self.on_seek = Some(Box::new(f)); 43 + self 44 + } 45 + } 46 + 47 + impl IntoElement for SeekBar { 48 + type Element = Self; 49 + fn into_element(self) -> Self { self } 50 + } 51 + 52 + pub struct SeekBarPrepaint { 53 + hitbox: Hitbox, 54 + } 55 + 56 + impl Element for SeekBar { 57 + type RequestLayoutState = LayoutId; 58 + type PrepaintState = SeekBarPrepaint; 59 + 60 + fn id(&self) -> Option<ElementId> { Some(self.id.clone()) } 61 + 62 + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } 63 + 64 + fn request_layout( 65 + &mut self, 66 + _id: Option<&GlobalElementId>, 67 + _inspector_id: Option<&InspectorElementId>, 68 + window: &mut Window, 69 + cx: &mut App, 70 + ) -> (LayoutId, LayoutId) { 71 + let mut style = gpui::Style::default(); 72 + style.size.width = gpui::relative(1.).into(); 73 + style.size.height = self.height.into(); 74 + let layout_id = window.request_layout(style, [], cx); 75 + (layout_id, layout_id) 76 + } 77 + 78 + fn prepaint( 79 + &mut self, 80 + _id: Option<&GlobalElementId>, 81 + _inspector_id: Option<&InspectorElementId>, 82 + bounds: Bounds<Pixels>, 83 + _layout: &mut LayoutId, 84 + window: &mut Window, 85 + _cx: &mut App, 86 + ) -> SeekBarPrepaint { 87 + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); 88 + SeekBarPrepaint { hitbox } 89 + } 90 + 91 + fn paint( 92 + &mut self, 93 + _id: Option<&GlobalElementId>, 94 + _inspector_id: Option<&InspectorElementId>, 95 + bounds: Bounds<Pixels>, 96 + _layout: &mut LayoutId, 97 + prepaint: &mut SeekBarPrepaint, 98 + window: &mut Window, 99 + _cx: &mut App, 100 + ) { 101 + let track_color = self.track_color; 102 + let fill_color = self.fill_color; 103 + let fill_fraction = self.fill_fraction.clamp(0.0, 1.0); 104 + let radius = px(2.0); 105 + 106 + // Track background 107 + window.paint_quad(gpui::PaintQuad { 108 + bounds, 109 + corner_radii: gpui::Corners::all(radius), 110 + background: track_color.into(), 111 + border_widths: gpui::Edges::default(), 112 + border_color: gpui::transparent_black(), 113 + border_style: BorderStyle::default(), 114 + }); 115 + 116 + // Filled portion 117 + if fill_fraction > 0.0 { 118 + let fill_bounds = Bounds { 119 + origin: bounds.origin, 120 + size: gpui::Size { 121 + width: bounds.size.width * fill_fraction, 122 + height: bounds.size.height, 123 + }, 124 + }; 125 + window.paint_quad(gpui::PaintQuad { 126 + bounds: fill_bounds, 127 + corner_radii: gpui::Corners::all(radius), 128 + background: fill_color.into(), 129 + border_widths: gpui::Edges::default(), 130 + border_color: gpui::transparent_black(), 131 + border_style: BorderStyle::default(), 132 + }); 133 + } 134 + 135 + // Pointer cursor on hover 136 + if prepaint.hitbox.is_hovered(window) { 137 + window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox); 138 + } 139 + 140 + // Seek on mouse-down 141 + if let Some(on_seek) = self.on_seek.take() { 142 + let hitbox = prepaint.hitbox.clone(); 143 + let origin_x = bounds.origin.x; 144 + let width = bounds.size.width; 145 + window.on_mouse_event( 146 + move |event: &MouseDownEvent, phase, window, cx| { 147 + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { 148 + let rel_x = f32::from(event.position.x - origin_x); 149 + let w = f32::from(width); 150 + let fraction = if w > 0.0 { (rel_x / w).clamp(0.0, 1.0) } else { 0.0 }; 151 + on_seek(fraction, window, cx); 152 + cx.stop_propagation(); 153 + } 154 + }, 155 + ); 156 + } 157 + } 158 + }