1#![allow(clippy::manual_is_multiple_of)]
14
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
17pub struct MediaInfo {
18 pub player: Option<String>,
20 pub status: Option<String>,
22 pub artist: Option<String>,
24 pub title: Option<String>,
26 pub album: Option<String>,
28}
29
30impl MediaInfo {
31 pub fn format_player(&self) -> Option<String> {
33 let player = self.player.as_deref()?;
34 if let Some(status) = self.status.as_deref() {
35 if !status.is_empty() {
36 return Some(format!("{player} ({status})"));
37 }
38 }
39 Some(player.to_string())
40 }
41
42 pub fn format_media(&self) -> Option<String> {
44 match (&self.artist, &self.title) {
45 (Some(artist), Some(title)) if !artist.is_empty() && !title.is_empty() => {
46 Some(format!("{artist} - {title}"))
47 }
48 (_, Some(title)) if !title.is_empty() => Some(title.clone()),
49 (Some(artist), _) if !artist.is_empty() => Some(artist.clone()),
50 _ => None,
51 }
52 }
53}
54
55pub fn detect_media() -> (Option<String>, Option<String>) {
59 let info = detect_media_info();
60 let media_str = info.as_ref().and_then(|i| i.format_media());
61 let player_str = info.as_ref().and_then(|i| i.format_player());
62 (media_str, player_str)
63}
64
65pub fn detect_media_info() -> Option<MediaInfo> {
67 #[cfg(target_os = "linux")]
68 {
69 linux_dbus::detect_mpris_media()
70 }
71
72 #[cfg(target_os = "windows")]
73 {
74 win_media::detect_winrt_media()
75 }
76
77 #[cfg(target_os = "macos")]
78 {
79 macos_media::detect_macos_media()
80 }
81
82 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
83 {
84 None
85 }
86}
87
88#[cfg(target_os = "windows")]
91pub mod win_media {
92 use super::MediaInfo;
93 use std::ffi::c_void;
94 use std::ptr;
95
96 type HResult = i32;
97 type HString = *mut c_void;
98
99 #[repr(C)]
100 #[derive(Debug, Clone, Copy)]
101 pub struct Guid {
102 pub data1: u32,
103 pub data2: u16,
104 pub data3: u16,
105 pub data4: [u8; 8],
106 }
107
108 #[repr(C)]
109 pub struct HstringHeader {
110 pub flags: u32,
111 pub length: u32,
112 pub padding1: u32,
113 pub padding2: u32,
114 pub data: *const c_void,
115 }
116
117 #[repr(C)]
118 struct IUnknownVtbl {
119 pub query_interface: unsafe extern "system" fn(
120 this: *mut c_void,
121 riid: *const Guid,
122 ppv: *mut *mut c_void,
123 ) -> HResult,
124 pub add_ref: unsafe extern "system" fn(this: *mut c_void) -> u32,
125 pub release: unsafe extern "system" fn(this: *mut c_void) -> u32,
126 }
127
128 #[repr(C)]
129 struct IInspectableVtbl {
130 pub base: IUnknownVtbl,
131 pub get_iids: unsafe extern "system" fn(
132 this: *mut c_void,
133 count: *mut u32,
134 iids: *mut *mut Guid,
135 ) -> HResult,
136 pub get_runtime_class_name:
137 unsafe extern "system" fn(this: *mut c_void, name: *mut HString) -> HResult,
138 pub get_trust_level:
139 unsafe extern "system" fn(this: *mut c_void, trust_level: *mut i32) -> HResult,
140 }
141
142 #[repr(C)]
143 struct IAsyncInfoVtbl {
144 pub base: IInspectableVtbl,
145 pub get_id: unsafe extern "system" fn(this: *mut c_void, id: *mut u32) -> HResult,
146 pub get_status: unsafe extern "system" fn(this: *mut c_void, status: *mut u32) -> HResult,
147 pub get_error_code:
148 unsafe extern "system" fn(this: *mut c_void, error_code: *mut HResult) -> HResult,
149 pub cancel: unsafe extern "system" fn(this: *mut c_void) -> HResult,
150 pub close: unsafe extern "system" fn(this: *mut c_void) -> HResult,
151 }
152
153 #[repr(C)]
154 struct IAsyncOperationVtbl {
155 pub base: IInspectableVtbl,
156 pub put_completed:
157 unsafe extern "system" fn(this: *mut c_void, handler: *mut c_void) -> HResult,
158 pub get_completed:
159 unsafe extern "system" fn(this: *mut c_void, handler: *mut *mut c_void) -> HResult,
160 pub get_results:
161 unsafe extern "system" fn(this: *mut c_void, results: *mut *mut c_void) -> HResult,
162 }
163
164 #[repr(C)]
165 struct IStaticsVtbl {
166 pub base: IInspectableVtbl,
167 pub request_async:
168 unsafe extern "system" fn(this: *mut c_void, operation: *mut *mut c_void) -> HResult,
169 }
170
171 #[repr(C)]
172 struct ISessionManagerVtbl {
173 pub base: IInspectableVtbl,
174 pub get_current_session:
175 unsafe extern "system" fn(this: *mut c_void, result: *mut *mut c_void) -> HResult,
176 }
177
178 #[repr(C)]
179 struct ISessionVtbl {
180 pub base: IInspectableVtbl,
181 pub get_source_app_user_model_id:
182 unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
183 pub try_get_media_properties_async:
184 unsafe extern "system" fn(this: *mut c_void, operation: *mut *mut c_void) -> HResult,
185 pub get_playback_info:
186 unsafe extern "system" fn(this: *mut c_void, result: *mut *mut c_void) -> HResult,
187 }
188
189 #[repr(C)]
190 struct IPlaybackInfoVtbl {
191 pub base: IInspectableVtbl,
192 pub get_controls:
193 unsafe extern "system" fn(this: *mut c_void, value: *mut *mut c_void) -> HResult,
194 pub get_playback_status:
195 unsafe extern "system" fn(this: *mut c_void, value: *mut i32) -> HResult,
196 }
197
198 #[repr(C)]
199 struct IMediaPropertiesVtbl {
200 pub base: IInspectableVtbl,
201 pub get_title: unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
202 pub get_subtitle:
203 unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
204 pub get_artist:
205 unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
206 pub get_album_artist:
207 unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
208 pub get_album_title:
209 unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
210 }
211
212 const IID_IASYNC_INFO: Guid = Guid {
213 data1: 0x0000_0036,
214 data2: 0x0000,
215 data3: 0x0000,
216 data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46],
217 };
218
219 const IID_ISTATICS: Guid = Guid {
220 data1: 0x2050c4ee,
221 data2: 0x11a0,
222 data3: 0x57de,
223 data4: [0xae, 0xd7, 0xc9, 0x7c, 0x70, 0x33, 0x82, 0x45],
224 };
225
226 type FnRoInitialize = unsafe extern "system" fn(init_type: i32) -> HResult;
227 type FnRoGetActivationFactory = unsafe extern "system" fn(
228 activatable_class_id: HString,
229 iid: *const Guid,
230 factory: *mut *mut c_void,
231 ) -> HResult;
232 type FnWindowsCreateStringReference = unsafe extern "system" fn(
233 source_string: *const u16,
234 length: u32,
235 hstring_header: *mut HstringHeader,
236 string: *mut HString,
237 ) -> HResult;
238 type FnWindowsGetStringRawBuffer =
239 unsafe extern "system" fn(string: HString, length: *mut u32) -> *const u16;
240 type FnWindowsDeleteString = unsafe extern "system" fn(string: HString) -> HResult;
241
242 #[link(name = "kernel32")]
243 extern "system" {
244 fn LoadLibraryA(name: *const i8) -> *mut c_void;
245 fn GetProcAddress(module: *mut c_void, name: *const i8) -> *mut c_void;
246 }
247
248 unsafe fn get_string_buffer(
249 get_buf: FnWindowsGetStringRawBuffer,
250 hs: HString,
251 ) -> Option<String> {
252 if hs.is_null() {
253 return None;
254 }
255 let mut len: u32 = 0;
256 let ptr = get_buf(hs, &mut len);
257 if ptr.is_null() || len == 0 {
258 return None;
259 }
260 let slice = std::slice::from_raw_parts(ptr, len as usize);
261 let s = String::from_utf16_lossy(slice).trim().to_string();
262 if s.is_empty() {
263 None
264 } else {
265 Some(s)
266 }
267 }
268
269 unsafe fn release_com(ptr: *mut c_void) {
270 if !ptr.is_null() {
271 let vtbl = *(ptr as *mut *mut IUnknownVtbl);
272 ((*vtbl).release)(ptr);
273 }
274 }
275
276 unsafe fn wait_async(async_op: *mut c_void, timeout_ms: u64) -> bool {
277 let mut async_info: *mut c_void = ptr::null_mut();
278 let vtbl = *(async_op as *mut *mut IUnknownVtbl);
279 let hr = ((*vtbl).query_interface)(async_op, &IID_IASYNC_INFO, &mut async_info);
280 if hr < 0 || async_info.is_null() {
281 return false;
282 }
283 let info_vtbl = *(async_info as *mut *mut IAsyncInfoVtbl);
284 let start = std::time::Instant::now();
285 let mut completed = false;
286 while start.elapsed().as_millis() < timeout_ms as u128 {
287 let mut status: u32 = 0;
288 let hr = ((*info_vtbl).get_status)(async_info, &mut status);
289 if hr >= 0 && status != 0 {
290 completed = status == 1; break;
292 }
293 std::thread::sleep(std::time::Duration::from_millis(1));
294 }
295 release_com(async_info);
296 completed
297 }
298
299 pub fn clean_app_user_model_id(id: &str) -> String {
301 let lower = id.to_lowercase();
302 if lower.contains("spotify") {
303 "Spotify".to_string()
304 } else if lower.contains("zune")
305 || lower.contains("mediaplayer")
306 || lower.contains("groove")
307 {
308 "Media Player".to_string()
309 } else if lower.contains("msedge") {
310 "Microsoft Edge".to_string()
311 } else if lower.contains("chrome") {
312 "Google Chrome".to_string()
313 } else if lower.contains("firefox") {
314 "Firefox".to_string()
315 } else if lower.contains("vlc") {
316 "VLC media player".to_string()
317 } else if lower.contains("foobar2000") {
318 "foobar2000".to_string()
319 } else if lower.contains("aimp") {
320 "AIMP".to_string()
321 } else if lower.contains("itunes") {
322 "iTunes".to_string()
323 } else if lower.contains("apple.music") || lower.contains("applemusic") {
324 "Apple Music".to_string()
325 } else {
326 let base = id
328 .split('!')
329 .next()
330 .unwrap_or(id)
331 .trim_end_matches(".exe")
332 .trim();
333 base.to_string()
334 }
335 }
336
337 pub fn format_win_playback_status(status: i32) -> Option<String> {
339 match status {
340 4 => Some("Playing".to_string()),
341 5 => Some("Paused".to_string()),
342 3 => Some("Stopped".to_string()),
343 1 | 2 => Some("Buffering".to_string()),
344 _ => None,
345 }
346 }
347
348 pub fn detect_winrt_media() -> Option<MediaInfo> {
350 unsafe {
353 let combase = LoadLibraryA(c"combase.dll".as_ptr());
354 if combase.is_null() {
355 return None;
356 }
357
358 let ro_init_ptr = GetProcAddress(combase, c"RoInitialize".as_ptr());
359 let ro_get_factory_ptr = GetProcAddress(combase, c"RoGetActivationFactory".as_ptr());
360 let str_ref_ptr = GetProcAddress(combase, c"WindowsCreateStringReference".as_ptr());
361 let str_buf_ptr = GetProcAddress(combase, c"WindowsGetStringRawBuffer".as_ptr());
362 let str_del_ptr = GetProcAddress(combase, c"WindowsDeleteString".as_ptr());
363
364 if ro_init_ptr.is_null()
365 || ro_get_factory_ptr.is_null()
366 || str_ref_ptr.is_null()
367 || str_buf_ptr.is_null()
368 || str_del_ptr.is_null()
369 {
370 return None;
371 }
372
373 let ro_init: FnRoInitialize = std::mem::transmute(ro_init_ptr);
374 let ro_get_factory: FnRoGetActivationFactory = std::mem::transmute(ro_get_factory_ptr);
375 let str_ref: FnWindowsCreateStringReference = std::mem::transmute(str_ref_ptr);
376 let str_buf: FnWindowsGetStringRawBuffer = std::mem::transmute(str_buf_ptr);
377 let str_del: FnWindowsDeleteString = std::mem::transmute(str_del_ptr);
378
379 ro_init(1);
381
382 let class_name =
383 "Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager\0";
384 let wide_class: Vec<u16> = class_name.encode_utf16().collect();
385 let mut header = std::mem::zeroed::<HstringHeader>();
386 let mut hs_class: HString = ptr::null_mut();
387 str_ref(
388 wide_class.as_ptr(),
389 (wide_class.len() - 1) as u32,
390 &mut header,
391 &mut hs_class,
392 );
393
394 let mut factory: *mut c_void = ptr::null_mut();
395 let hr = ro_get_factory(hs_class, &IID_ISTATICS, &mut factory);
396 if hr < 0 || factory.is_null() {
397 return None;
398 }
399
400 let statics_vtbl = *(factory as *mut *mut IStaticsVtbl);
401 let mut async_op: *mut c_void = ptr::null_mut();
402 let hr = ((*statics_vtbl).request_async)(factory, &mut async_op);
403 release_com(factory);
404
405 if hr < 0 || async_op.is_null() {
406 return None;
407 }
408
409 if !wait_async(async_op, 30) {
410 release_com(async_op);
411 return None;
412 }
413
414 let op_vtbl = *(async_op as *mut *mut IAsyncOperationVtbl);
415 let mut session_mgr: *mut c_void = ptr::null_mut();
416 let hr = ((*op_vtbl).get_results)(async_op, &mut session_mgr);
417 release_com(async_op);
418
419 if hr < 0 || session_mgr.is_null() {
420 return None;
421 }
422
423 let mgr_vtbl = *(session_mgr as *mut *mut ISessionManagerVtbl);
424 let mut session: *mut c_void = ptr::null_mut();
425 let hr = ((*mgr_vtbl).get_current_session)(session_mgr, &mut session);
426 release_com(session_mgr);
427
428 if hr < 0 || session.is_null() {
429 return None;
430 }
431
432 let session_vtbl = *(session as *mut *mut ISessionVtbl);
433 let mut app_id: HString = ptr::null_mut();
434 let _ = ((*session_vtbl).get_source_app_user_model_id)(session, &mut app_id);
435 let player = get_string_buffer(str_buf, app_id).map(|s| clean_app_user_model_id(&s));
436 str_del(app_id);
437
438 let mut playback_info: *mut c_void = ptr::null_mut();
439 let mut status = None;
440 let hr = ((*session_vtbl).get_playback_info)(session, &mut playback_info);
441 if hr >= 0 && !playback_info.is_null() {
442 let pb_vtbl = *(playback_info as *mut *mut IPlaybackInfoVtbl);
443 let mut status_code: i32 = 0;
444 if ((*pb_vtbl).get_playback_status)(playback_info, &mut status_code) >= 0 {
445 status = format_win_playback_status(status_code);
446 }
447 release_com(playback_info);
448 }
449
450 let mut title = None;
451 let mut artist = None;
452 let mut album = None;
453
454 let mut async_props: *mut c_void = ptr::null_mut();
455 let hr = ((*session_vtbl).try_get_media_properties_async)(session, &mut async_props);
456 if hr >= 0 && !async_props.is_null() {
457 if wait_async(async_props, 30) {
458 let props_op_vtbl = *(async_props as *mut *mut IAsyncOperationVtbl);
459 let mut props: *mut c_void = ptr::null_mut();
460 let hr = ((*props_op_vtbl).get_results)(async_props, &mut props);
461 if hr >= 0 && !props.is_null() {
462 let props_vtbl = *(props as *mut *mut IMediaPropertiesVtbl);
463 let mut title_hs: HString = ptr::null_mut();
464 let mut artist_hs: HString = ptr::null_mut();
465 let mut album_hs: HString = ptr::null_mut();
466
467 if ((*props_vtbl).get_title)(props, &mut title_hs) >= 0 {
468 title = get_string_buffer(str_buf, title_hs);
469 }
470 if ((*props_vtbl).get_artist)(props, &mut artist_hs) >= 0 {
471 artist = get_string_buffer(str_buf, artist_hs);
472 }
473 if ((*props_vtbl).get_album_title)(props, &mut album_hs) >= 0 {
474 album = get_string_buffer(str_buf, album_hs);
475 }
476
477 str_del(title_hs);
478 str_del(artist_hs);
479 str_del(album_hs);
480 release_com(props);
481 }
482 }
483 release_com(async_props);
484 }
485
486 release_com(session);
487
488 if player.is_none() && title.is_none() && artist.is_none() {
489 None
490 } else {
491 Some(MediaInfo {
492 player,
493 status,
494 artist,
495 title,
496 album,
497 })
498 }
499 }
500 }
501}
502
503#[cfg(any(target_os = "linux", test))]
508pub mod linux_dbus {
509 use super::MediaInfo;
510 use std::io::{Read, Write};
511 use std::path::PathBuf;
512 use std::time::Duration;
513
514 #[cfg(target_os = "linux")]
515 use std::os::unix::net::UnixStream;
516
517 pub fn get_session_bus_path() -> Option<PathBuf> {
519 if let Ok(addr) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
520 for transport in addr.split(';') {
521 for part in transport.split(',') {
522 if let Some(path) = part.strip_prefix("unix:path=") {
523 let p = PathBuf::from(path);
524 if p.exists() {
525 return Some(p);
526 }
527 }
528 }
529 }
530 }
531 #[cfg(target_os = "linux")]
532 {
533 let uid = unsafe { libc::getuid() };
534 let default_path = PathBuf::from(format!("/run/user/{uid}/bus"));
535 if default_path.exists() {
536 return Some(default_path);
537 }
538 }
539 None
540 }
541
542 #[cfg(target_os = "linux")]
544 fn authenticate_dbus(stream: &mut UnixStream) -> bool {
545 let uid = unsafe { libc::getuid() };
546 let uid_str = uid.to_string();
547 let hex_uid: String = uid_str.bytes().map(|b| format!("{b:02x}")).collect();
548
549 let auth_cmd = format!("\0AUTH EXTERNAL {hex_uid}\r\n");
551 if stream.write_all(auth_cmd.as_bytes()).is_err() {
552 return false;
553 }
554
555 let mut line = Vec::new();
557 let mut b = [0u8; 1];
558 while line.len() < 256 {
559 match stream.read(&mut b) {
560 Ok(1) => {
561 line.push(b[0]);
562 if line.ends_with(b"\r\n") {
563 break;
564 }
565 }
566 _ => return false,
567 }
568 }
569
570 let resp = String::from_utf8_lossy(&line);
571 if !resp.starts_with("OK") {
572 return false;
573 }
574
575 if stream.write_all(b"BEGIN\r\n").is_err() {
577 return false;
578 }
579
580 true
581 }
582
583 pub fn encode_dbus_call(
585 serial: u32,
586 destination: &str,
587 path: &str,
588 interface: &str,
589 member: &str,
590 signature: Option<&str>,
591 body: &[u8],
592 ) -> Vec<u8> {
593 let mut header_fields = Vec::new();
594
595 encode_header_field(&mut header_fields, 1, b'o', path.as_bytes());
597 encode_header_field(&mut header_fields, 2, b's', interface.as_bytes());
599 encode_header_field(&mut header_fields, 3, b's', member.as_bytes());
601 encode_header_field(&mut header_fields, 6, b's', destination.as_bytes());
603 if let Some(sig) = signature {
604 encode_header_field(&mut header_fields, 8, b'g', sig.as_bytes());
606 }
607
608 let mut msg = vec![
609 b'l', 1, 0, 1, ];
614 msg.extend_from_slice(&(body.len() as u32).to_le_bytes());
615 msg.extend_from_slice(&serial.to_le_bytes());
616 msg.extend_from_slice(&(header_fields.len() as u32).to_le_bytes());
617 msg.extend_from_slice(&header_fields);
618
619 while msg.len() % 8 != 0 {
621 msg.push(0);
622 }
623 msg.extend_from_slice(body);
624 msg
625 }
626
627 fn encode_header_field(out: &mut Vec<u8>, field_code: u8, sig_byte: u8, val: &[u8]) {
628 while out.len() % 8 != 0 {
630 out.push(0);
631 }
632 out.push(field_code);
633 out.push(1); out.push(sig_byte);
635 out.push(0); if sig_byte == b'g' {
637 out.push(val.len() as u8);
638 out.extend_from_slice(val);
639 out.push(0);
640 } else {
641 while out.len() % 4 != 0 {
643 out.push(0);
644 }
645 out.extend_from_slice(&(val.len() as u32).to_le_bytes());
646 out.extend_from_slice(val);
647 out.push(0);
648 }
649 }
650
651 #[cfg(target_os = "linux")]
653 pub fn read_dbus_message(stream: &mut UnixStream) -> Option<Vec<u8>> {
654 let mut header_buf = [0u8; 16];
655 stream.read_exact(&mut header_buf).ok()?;
656
657 let body_len = u32::from_le_bytes(header_buf[4..8].try_into().ok()?) as usize;
658 let fields_len = u32::from_le_bytes(header_buf[12..16].try_into().ok()?) as usize;
659 let header_padding = (8 - (fields_len % 8)) % 8;
660 let total_remaining = fields_len + header_padding + body_len;
661
662 let mut rest = vec![0u8; total_remaining];
663 stream.read_exact(&mut rest).ok()?;
664
665 let mut full = Vec::with_capacity(16 + total_remaining);
666 full.extend_from_slice(&header_buf);
667 full.extend_from_slice(&rest);
668 Some(full)
669 }
670
671 pub fn get_message_type(msg: &[u8]) -> Option<u8> {
674 if msg.len() >= 2 {
675 Some(msg[1])
676 } else {
677 None
678 }
679 }
680
681 pub fn get_reply_serial(msg: &[u8]) -> Option<u32> {
683 if msg.len() < 24 {
684 return None;
685 }
686 let fields_len = u32::from_le_bytes(msg[12..16].try_into().ok()?) as usize;
687 let end = (16 + fields_len).min(msg.len());
688 let mut pos = 16;
689 while pos + 8 <= end {
690 while pos % 8 != 0 && pos < end {
691 pos += 1;
692 }
693 if pos + 8 > end {
694 break;
695 }
696 if msg[pos] == 5 && msg[pos + 1] == 1 && msg[pos + 2] == b'u' && msg[pos + 3] == 0 {
697 return Some(u32::from_le_bytes(msg[pos + 4..pos + 8].try_into().ok()?));
698 }
699 pos += 1;
700 }
701 None
702 }
703
704 #[cfg(target_os = "linux")]
707 pub fn read_dbus_reply(stream: &mut UnixStream, expected_serial: u32) -> Option<Vec<u8>> {
708 for _ in 0..10 {
709 let msg = read_dbus_message(stream)?;
710 let msg_type = get_message_type(&msg)?;
711 if msg_type == 2 {
712 if let Some(serial) = get_reply_serial(&msg) {
714 if serial == expected_serial {
715 return Some(msg);
716 }
717 } else {
718 return Some(msg);
719 }
720 } else if msg_type == 3 {
721 if let Some(serial) = get_reply_serial(&msg) {
723 if serial == expected_serial {
724 return None;
725 }
726 }
727 }
728 }
730 None
731 }
732
733 pub fn parse_name_list(msg: &[u8]) -> Vec<String> {
735 if msg.len() < 16 {
736 return Vec::new();
737 }
738 let fields_len = u32::from_le_bytes(msg[12..16].try_into().unwrap_or([0; 4])) as usize;
739 let header_padding = (8 - (fields_len % 8)) % 8;
740 let body_offset = 16 + fields_len + header_padding;
741 if body_offset + 4 > msg.len() {
742 return Vec::new();
743 }
744
745 let array_len = u32::from_le_bytes(
746 msg[body_offset..body_offset + 4]
747 .try_into()
748 .unwrap_or([0; 4]),
749 ) as usize;
750 let mut pos = body_offset + 4;
751 let end = (pos + array_len).min(msg.len());
752 let mut names = Vec::new();
753
754 while pos + 4 <= end {
755 while pos % 4 != 0 && pos < end {
756 pos += 1;
757 }
758 if pos + 4 > end {
759 break;
760 }
761 let s_len = u32::from_le_bytes(msg[pos..pos + 4].try_into().unwrap_or([0; 4])) as usize;
762 pos += 4;
763 if pos + s_len <= end {
764 if let Ok(s) = std::str::from_utf8(&msg[pos..pos + s_len]) {
765 names.push(s.to_string());
766 }
767 pos += s_len + 1; } else {
769 break;
770 }
771 }
772 names
773 }
774
775 pub fn clean_mpris_service_name(service: &str) -> String {
777 let raw_name = service
778 .strip_prefix("org.mpris.MediaPlayer2.")
779 .unwrap_or(service);
780
781 let base_name = raw_name
783 .split(".instance")
784 .next()
785 .unwrap_or(raw_name)
786 .trim();
787
788 let lower = base_name.to_lowercase();
789 if lower == "spotify" {
790 "Spotify".to_string()
791 } else if lower == "vlc" {
792 "VLC media player".to_string()
793 } else if lower == "chrome" || lower == "google-chrome" {
794 "Google Chrome".to_string()
795 } else if lower == "chromium" {
796 "Chromium".to_string()
797 } else if lower == "firefox" {
798 "Firefox".to_string()
799 } else if lower == "brave" {
800 "Brave".to_string()
801 } else if lower == "mpv" {
802 "mpv".to_string()
803 } else if lower == "celluloid" {
804 "Celluloid".to_string()
805 } else if lower == "rhythmbox" {
806 "Rhythmbox".to_string()
807 } else if lower == "cider" {
808 "Cider".to_string()
809 } else if lower == "audacious" {
810 "Audacious".to_string()
811 } else if lower == "clementine" {
812 "Clementine".to_string()
813 } else if lower == "strawberry" {
814 "Strawberry".to_string()
815 } else if lower == "amberol" {
816 "Amberol".to_string()
817 } else if lower == "elisa" {
818 "Elisa".to_string()
819 } else if lower == "lollypop" {
820 "Lollypop".to_string()
821 } else {
822 let mut chars = base_name.chars();
824 match chars.next() {
825 None => String::new(),
826 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
827 }
828 }
829 }
830
831 pub fn extract_mpris_string(body: &[u8], key: &str) -> Option<String> {
833 let key_bytes = key.as_bytes();
834 let mut pos = 0;
835 while pos + key_bytes.len() + 6 < body.len() {
836 if &body[pos..pos + key_bytes.len()] == key_bytes {
837 let search_start = pos + key_bytes.len();
838 let search_end = (search_start + 32).min(body.len());
839 for s in search_start..search_end.saturating_sub(6) {
840 if body[s] == 1 && body[s + 1] == b's' && body[s + 2] == 0 {
841 let mut str_pos = s + 3;
843 while str_pos < search_end {
844 if str_pos + 4 <= body.len() {
845 let slen = u32::from_le_bytes(
846 body[str_pos..str_pos + 4].try_into().unwrap_or([0; 4]),
847 ) as usize;
848 if slen > 0 && slen < 4096 && str_pos + 4 + slen <= body.len() {
849 if let Ok(val) =
850 std::str::from_utf8(&body[str_pos + 4..str_pos + 4 + slen])
851 {
852 let trimmed = val.trim();
853 if !trimmed.is_empty()
854 && val.chars().all(|c| !c.is_control())
855 {
856 return Some(trimmed.to_string());
857 }
858 }
859 }
860 }
861 str_pos += 1;
862 }
863 }
864 }
865 }
866 pos += 1;
867 }
868 None
869 }
870
871 pub fn extract_mpris_string_list(body: &[u8], key: &str) -> Option<String> {
873 let key_bytes = key.as_bytes();
874 let mut pos = 0;
875 while pos + key_bytes.len() + 8 < body.len() {
876 if &body[pos..pos + key_bytes.len()] == key_bytes {
877 let search_start = pos + key_bytes.len();
878 let search_end = (search_start + 32).min(body.len());
879 for s in search_start..search_end.saturating_sub(8) {
880 if body[s] == 2
881 && body[s + 1] == b'a'
882 && body[s + 2] == b's'
883 && body[s + 3] == 0
884 {
885 let mut arr_header_pos = s + 4;
886 while arr_header_pos < search_end {
887 if arr_header_pos + 4 <= body.len() {
888 let arr_len = u32::from_le_bytes(
889 body[arr_header_pos..arr_header_pos + 4]
890 .try_into()
891 .unwrap_or([0; 4]),
892 ) as usize;
893 if arr_len > 0
894 && arr_len < 65536
895 && arr_header_pos + 4 + arr_len <= body.len()
896 {
897 let mut elem_pos = arr_header_pos + 4;
898 let end = elem_pos + arr_len;
899 let mut items = Vec::new();
900 while elem_pos + 4 <= end {
901 while elem_pos < end && body[elem_pos] == 0 {
902 elem_pos += 1;
903 }
904 if elem_pos + 4 > end {
905 break;
906 }
907 let slen = u32::from_le_bytes(
908 body[elem_pos..elem_pos + 4]
909 .try_into()
910 .unwrap_or([0; 4]),
911 )
912 as usize;
913 elem_pos += 4;
914 if slen > 0 && elem_pos + slen <= end {
915 if let Ok(item_str) = std::str::from_utf8(
916 &body[elem_pos..elem_pos + slen],
917 ) {
918 let trimmed = item_str.trim();
919 if !trimmed.is_empty() {
920 items.push(trimmed.to_string());
921 }
922 }
923 elem_pos += slen + 1; } else {
925 break;
926 }
927 }
928 if !items.is_empty() {
929 return Some(items.join(", "));
930 }
931 }
932 }
933 arr_header_pos += 1;
934 }
935 }
936 }
937 }
938 pos += 1;
939 }
940 None
941 }
942
943 #[cfg(target_os = "linux")]
945 pub fn detect_mpris_media() -> Option<MediaInfo> {
946 let bus_path = get_session_bus_path()?;
947 let mut stream = UnixStream::connect(bus_path).ok()?;
948 stream
949 .set_read_timeout(Some(Duration::from_millis(100)))
950 .ok()?;
951 stream
952 .set_write_timeout(Some(Duration::from_millis(100)))
953 .ok()?;
954
955 if !authenticate_dbus(&mut stream) {
956 return None;
957 }
958
959 let hello_msg = encode_dbus_call(
961 1,
962 "org.freedesktop.DBus",
963 "/org/freedesktop/DBus",
964 "org.freedesktop.DBus",
965 "Hello",
966 None,
967 &[],
968 );
969 stream.write_all(&hello_msg).ok()?;
970 let _ = read_dbus_reply(&mut stream, 1)?;
971
972 let list_msg = encode_dbus_call(
974 2,
975 "org.freedesktop.DBus",
976 "/org/freedesktop/DBus",
977 "org.freedesktop.DBus",
978 "ListNames",
979 None,
980 &[],
981 );
982 stream.write_all(&list_msg).ok()?;
983 let list_reply = read_dbus_reply(&mut stream, 2)?;
984 let names = parse_name_list(&list_reply);
985
986 let mpris_services: Vec<_> = names
987 .into_iter()
988 .filter(|n| n.starts_with("org.mpris.MediaPlayer2."))
989 .collect();
990
991 if mpris_services.is_empty() {
992 return None;
993 }
994
995 let mut candidate_info: Option<MediaInfo> = None;
996
997 for (i, service) in mpris_services.iter().enumerate() {
999 let mut arg_body = Vec::new();
1000 let iface_name = "org.mpris.MediaPlayer2.Player";
1001 arg_body.extend_from_slice(&(iface_name.len() as u32).to_le_bytes());
1002 arg_body.extend_from_slice(iface_name.as_bytes());
1003 arg_body.push(0);
1004
1005 let serial = 10 + i as u32;
1006 let get_all_msg = encode_dbus_call(
1007 serial,
1008 service,
1009 "/org/mpris/MediaPlayer2",
1010 "org.freedesktop.DBus.Properties",
1011 "GetAll",
1012 Some("s"),
1013 &arg_body,
1014 );
1015
1016 if stream.write_all(&get_all_msg).is_ok() {
1017 if let Some(reply) = read_dbus_reply(&mut stream, serial) {
1018 let status = extract_mpris_string(&reply, "PlaybackStatus");
1019 let title = extract_mpris_string(&reply, "xesam:title");
1020 let artist = extract_mpris_string_list(&reply, "xesam:artist")
1021 .or_else(|| extract_mpris_string(&reply, "xesam:artist"));
1022 let album = extract_mpris_string(&reply, "xesam:album");
1023 let player = Some(clean_mpris_service_name(service));
1024
1025 let info = MediaInfo {
1026 player,
1027 status: status.clone(),
1028 artist,
1029 title,
1030 album,
1031 };
1032
1033 if status.as_deref() == Some("Playing") {
1034 return Some(info);
1035 }
1036 if candidate_info.is_none() {
1037 candidate_info = Some(info);
1038 }
1039 }
1040 }
1041 }
1042
1043 candidate_info
1044 }
1045}
1046
1047#[cfg(target_os = "macos")]
1050pub mod macos_media {
1051 use super::MediaInfo;
1052 use crate::macos_ffi::{cf_string_to_rust, CFStringRef};
1053 use std::ffi::{c_void, CString};
1054
1055 #[link(name = "objc")]
1056 extern "C" {
1057 fn objc_getClass(name: *const i8) -> *mut c_void;
1058 fn sel_registerName(name: *const i8) -> *mut c_void;
1059 fn objc_msgSend(self_: *mut c_void, op: *mut c_void, ...) -> *mut c_void;
1060 }
1061
1062 pub fn detect_macos_media() -> Option<MediaInfo> {
1064 unsafe {
1065 for (bundle_id, player_name) in [
1066 ("com.apple.Music", "Apple Music"),
1067 ("com.spotify.client", "Spotify"),
1068 ] {
1069 if let Some(info) = query_sb_player(bundle_id, player_name) {
1070 return Some(info);
1071 }
1072 }
1073 None
1074 }
1075 }
1076
1077 unsafe fn query_sb_player(bundle_id: &str, player_name: &str) -> Option<MediaInfo> {
1078 let sb_cls_name = CString::new("SBApplication").ok()?;
1079 let sb_cls = objc_getClass(sb_cls_name.as_ptr());
1080 if sb_cls.is_null() {
1081 return None;
1082 }
1083
1084 let app_with_bundle_sel = sel_registerName(
1085 CString::new("applicationWithBundleIdentifier:")
1086 .ok()?
1087 .as_ptr(),
1088 );
1089 let is_running_sel = sel_registerName(CString::new("isRunning").ok()?.as_ptr());
1090 let current_track_sel = sel_registerName(CString::new("currentTrack").ok()?.as_ptr());
1091 let player_state_sel = sel_registerName(CString::new("playerState").ok()?.as_ptr());
1092 let name_sel = sel_registerName(CString::new("name").ok()?.as_ptr());
1093 let artist_sel = sel_registerName(CString::new("artist").ok()?.as_ptr());
1094 let album_sel = sel_registerName(CString::new("album").ok()?.as_ptr());
1095
1096 let ns_str_cls = objc_getClass(CString::new("NSString").ok()?.as_ptr());
1097 let str_with_utf8_sel =
1098 sel_registerName(CString::new("stringWithUTF8String:").ok()?.as_ptr());
1099 let bundle_c = CString::new(bundle_id).ok()?;
1100 let bundle_ns = objc_msgSend(ns_str_cls, str_with_utf8_sel, bundle_c.as_ptr());
1101 if bundle_ns.is_null() {
1102 return None;
1103 }
1104
1105 let app = objc_msgSend(sb_cls, app_with_bundle_sel, bundle_ns);
1106 if app.is_null() {
1107 return None;
1108 }
1109
1110 let is_running = objc_msgSend(app, is_running_sel) as usize != 0;
1111 if !is_running {
1112 return None;
1113 }
1114
1115 let track = objc_msgSend(app, current_track_sel);
1116 if track.is_null() {
1117 return None;
1118 }
1119
1120 let name_ref = objc_msgSend(track, name_sel) as CFStringRef;
1121 let artist_ref = objc_msgSend(track, artist_sel) as CFStringRef;
1122 let album_ref = objc_msgSend(track, album_sel) as CFStringRef;
1123
1124 let title = cf_string_to_rust(name_ref);
1125 let artist = cf_string_to_rust(artist_ref);
1126 let album = cf_string_to_rust(album_ref);
1127
1128 let state_val = objc_msgSend(app, player_state_sel) as usize;
1129 let status = if state_val == 1800426352 || state_val == 1 {
1130 Some("Playing".to_string())
1132 } else if state_val == 1800426353 || state_val == 2 {
1133 Some("Paused".to_string())
1135 } else {
1136 None
1137 };
1138
1139 if title.is_none() && artist.is_none() {
1140 None
1141 } else {
1142 Some(MediaInfo {
1143 player: Some(player_name.to_string()),
1144 status,
1145 artist,
1146 title,
1147 album,
1148 })
1149 }
1150 }
1151}
1152
1153#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 #[test]
1160 fn test_format_player() {
1161 let with_status = MediaInfo {
1162 player: Some("Spotify".to_string()),
1163 status: Some("Playing".to_string()),
1164 artist: Some("Daft Punk".to_string()),
1165 title: Some("Get Lucky".to_string()),
1166 album: None,
1167 };
1168 assert_eq!(
1169 with_status.format_player(),
1170 Some("Spotify (Playing)".to_string())
1171 );
1172
1173 let without_status = MediaInfo {
1174 player: Some("VLC media player".to_string()),
1175 status: None,
1176 artist: None,
1177 title: None,
1178 album: None,
1179 };
1180 assert_eq!(
1181 without_status.format_player(),
1182 Some("VLC media player".to_string())
1183 );
1184
1185 let empty = MediaInfo::default();
1186 assert_eq!(empty.format_player(), None);
1187 }
1188
1189 #[test]
1190 fn test_format_media() {
1191 let full = MediaInfo {
1192 player: Some("Spotify".to_string()),
1193 status: Some("Playing".to_string()),
1194 artist: Some("Queen".to_string()),
1195 title: Some("Bohemian Rhapsody".to_string()),
1196 album: Some("A Night at the Opera".to_string()),
1197 };
1198 assert_eq!(
1199 full.format_media(),
1200 Some("Queen - Bohemian Rhapsody".to_string())
1201 );
1202
1203 let title_only = MediaInfo {
1204 player: Some("Firefox".to_string()),
1205 status: Some("Playing".to_string()),
1206 artist: None,
1207 title: Some("YouTube Video Title".to_string()),
1208 album: None,
1209 };
1210 assert_eq!(
1211 title_only.format_media(),
1212 Some("YouTube Video Title".to_string())
1213 );
1214
1215 let artist_only = MediaInfo {
1216 player: None,
1217 status: None,
1218 artist: Some("Unknown Artist".to_string()),
1219 title: None,
1220 album: None,
1221 };
1222 assert_eq!(
1223 artist_only.format_media(),
1224 Some("Unknown Artist".to_string())
1225 );
1226
1227 let empty = MediaInfo::default();
1228 assert_eq!(empty.format_media(), None);
1229 }
1230
1231 #[test]
1232 fn test_clean_app_user_model_id() {
1233 #[cfg(target_os = "windows")]
1234 {
1235 use win_media::clean_app_user_model_id;
1236 assert_eq!(clean_app_user_model_id("Spotify.exe"), "Spotify");
1237 assert_eq!(
1238 clean_app_user_model_id("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic"),
1239 "Media Player"
1240 );
1241 assert_eq!(clean_app_user_model_id("msedge.exe"), "Microsoft Edge");
1242 assert_eq!(clean_app_user_model_id("chrome.exe"), "Google Chrome");
1243 assert_eq!(clean_app_user_model_id("vlc.exe"), "VLC media player");
1244 assert_eq!(clean_app_user_model_id("foobar2000.exe"), "foobar2000");
1245 }
1246 }
1247
1248 #[test]
1249 fn test_linux_dbus_mpris_parsing() {
1250 use linux_dbus::{
1251 clean_mpris_service_name, encode_dbus_call, extract_mpris_string,
1252 extract_mpris_string_list, get_message_type, get_reply_serial, parse_name_list,
1253 };
1254
1255 assert_eq!(
1256 clean_mpris_service_name("org.mpris.MediaPlayer2.spotify"),
1257 "Spotify"
1258 );
1259 assert_eq!(
1260 clean_mpris_service_name("org.mpris.MediaPlayer2.vlc.instance1234"),
1261 "VLC media player"
1262 );
1263 assert_eq!(
1264 clean_mpris_service_name("org.mpris.MediaPlayer2.firefox.instance_1_42"),
1265 "Firefox"
1266 );
1267 assert_eq!(
1268 clean_mpris_service_name("org.mpris.MediaPlayer2.mpv"),
1269 "mpv"
1270 );
1271
1272 let mut body = Vec::new();
1274 let name1 = "org.freedesktop.DBus";
1275 let name2 = "org.mpris.MediaPlayer2.spotify";
1276 let name3 = ":1.42";
1277
1278 let mut arr_bytes = Vec::new();
1279 for n in [name1, name2, name3] {
1280 while arr_bytes.len() % 4 != 0 {
1281 arr_bytes.push(0);
1282 }
1283 arr_bytes.extend_from_slice(&(n.len() as u32).to_le_bytes());
1284 arr_bytes.extend_from_slice(n.as_bytes());
1285 arr_bytes.push(0); }
1287
1288 body.extend_from_slice(&(arr_bytes.len() as u32).to_le_bytes());
1289 body.extend_from_slice(&arr_bytes);
1290
1291 let msg = encode_dbus_call(
1292 1,
1293 ":1.42",
1294 "/org/freedesktop/DBus",
1295 "org.freedesktop.DBus",
1296 "ListNames",
1297 Some("as"),
1298 &body,
1299 );
1300
1301 assert_eq!(get_message_type(&msg), Some(1)); let names = parse_name_list(&msg);
1304 assert!(names.contains(&"org.mpris.MediaPlayer2.spotify".to_string()));
1305 assert!(names.contains(&"org.freedesktop.DBus".to_string()));
1306
1307 let mut reply_header_fields = Vec::new();
1309 while reply_header_fields.len() % 8 != 0 {
1311 reply_header_fields.push(0);
1312 }
1313 reply_header_fields.push(5); reply_header_fields.push(1); reply_header_fields.push(b'u'); reply_header_fields.push(0); reply_header_fields.extend_from_slice(&42u32.to_le_bytes()); let mut reply_msg = Vec::new();
1320 reply_msg.push(b'l'); reply_msg.push(2); reply_msg.push(0);
1323 reply_msg.push(1);
1324 reply_msg.extend_from_slice(&0u32.to_le_bytes()); reply_msg.extend_from_slice(&100u32.to_le_bytes()); reply_msg.extend_from_slice(&(reply_header_fields.len() as u32).to_le_bytes());
1327 reply_msg.extend_from_slice(&reply_header_fields);
1328
1329 assert_eq!(get_message_type(&reply_msg), Some(2));
1330 assert_eq!(get_reply_serial(&reply_msg), Some(42));
1331
1332 let mut prop_buf = Vec::new();
1334 prop_buf.extend_from_slice(b"PlaybackStatus");
1336 prop_buf.push(0); prop_buf.push(1); prop_buf.push(b's'); prop_buf.push(0); prop_buf.extend_from_slice(&(7u32).to_le_bytes());
1341 prop_buf.extend_from_slice(b"Playing");
1342 prop_buf.push(0);
1343
1344 prop_buf.extend_from_slice(b"xesam:title");
1346 prop_buf.push(0);
1347 prop_buf.push(1);
1348 prop_buf.push(b's');
1349 prop_buf.push(0);
1350 prop_buf.extend_from_slice(&(9u32).to_le_bytes());
1351 prop_buf.extend_from_slice(b"Get Lucky");
1352 prop_buf.push(0);
1353
1354 prop_buf.extend_from_slice(b"xesam:artist");
1356 prop_buf.push(0);
1357 prop_buf.push(2); prop_buf.push(b'a');
1359 prop_buf.push(b's');
1360 prop_buf.push(0); prop_buf.push(0); let mut artist_arr = Vec::new();
1363 for a in ["Daft Punk", "Pharrell Williams"] {
1364 artist_arr.extend_from_slice(&(a.len() as u32).to_le_bytes());
1365 artist_arr.extend_from_slice(a.as_bytes());
1366 artist_arr.push(0);
1367 }
1368 prop_buf.extend_from_slice(&(artist_arr.len() as u32).to_le_bytes());
1369 prop_buf.extend_from_slice(&artist_arr);
1370
1371 assert_eq!(
1372 extract_mpris_string(&prop_buf, "PlaybackStatus"),
1373 Some("Playing".to_string())
1374 );
1375 assert_eq!(
1376 extract_mpris_string(&prop_buf, "xesam:title"),
1377 Some("Get Lucky".to_string())
1378 );
1379 assert_eq!(
1380 extract_mpris_string_list(&prop_buf, "xesam:artist"),
1381 Some("Daft Punk, Pharrell Williams".to_string())
1382 );
1383 }
1384}