Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

Merge pull request #31 from ishbosamiya/os_media_controls

OS media controls

authored by tsiry-sandratraina.com and committed by GitHub 3a89672c 31f63006

+214 -1
Cargo.lock
··· 567 567 checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 568 568 569 569 [[package]] 570 + name = "block" 571 + version = "0.1.6" 572 + source = "registry+https://github.com/rust-lang/crates.io-index" 573 + checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" 574 + 575 + [[package]] 570 576 name = "block-buffer" 571 577 version = "0.9.0" 572 578 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 725 731 checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" 726 732 727 733 [[package]] 734 + name = "cocoa" 735 + version = "0.24.1" 736 + source = "registry+https://github.com/rust-lang/crates.io-index" 737 + checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" 738 + dependencies = [ 739 + "bitflags 1.3.2", 740 + "block", 741 + "cocoa-foundation", 742 + "core-foundation", 743 + "core-graphics", 744 + "foreign-types", 745 + "libc", 746 + "objc", 747 + ] 748 + 749 + [[package]] 750 + name = "cocoa-foundation" 751 + version = "0.1.2" 752 + source = "registry+https://github.com/rust-lang/crates.io-index" 753 + checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" 754 + dependencies = [ 755 + "bitflags 1.3.2", 756 + "block", 757 + "core-foundation", 758 + "core-graphics-types", 759 + "libc", 760 + "objc", 761 + ] 762 + 763 + [[package]] 728 764 name = "combine" 729 765 version = "4.6.7" 730 766 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 813 849 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 814 850 815 851 [[package]] 852 + name = "core-graphics" 853 + version = "0.22.3" 854 + source = "registry+https://github.com/rust-lang/crates.io-index" 855 + checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" 856 + dependencies = [ 857 + "bitflags 1.3.2", 858 + "core-foundation", 859 + "core-graphics-types", 860 + "foreign-types", 861 + "libc", 862 + ] 863 + 864 + [[package]] 865 + name = "core-graphics-types" 866 + version = "0.1.3" 867 + source = "registry+https://github.com/rust-lang/crates.io-index" 868 + checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" 869 + dependencies = [ 870 + "bitflags 1.3.2", 871 + "core-foundation", 872 + "libc", 873 + ] 874 + 875 + [[package]] 816 876 name = "coreaudio-rs" 817 877 version = "0.10.0" 818 878 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 852 912 "stdweb", 853 913 "thiserror 1.0.69", 854 914 "web-sys", 855 - "windows", 915 + "windows 0.37.0", 856 916 ] 857 917 858 918 [[package]] ··· 949 1009 checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" 950 1010 951 1011 [[package]] 1012 + name = "dbus" 1013 + version = "0.9.7" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" 1016 + dependencies = [ 1017 + "libc", 1018 + "libdbus-sys", 1019 + "winapi", 1020 + ] 1021 + 1022 + [[package]] 1023 + name = "dbus-crossroads" 1024 + version = "0.5.2" 1025 + source = "registry+https://github.com/rust-lang/crates.io-index" 1026 + checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" 1027 + dependencies = [ 1028 + "dbus", 1029 + ] 1030 + 1031 + [[package]] 952 1032 name = "deadpool" 953 1033 version = "0.7.0" 954 1034 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 989 1069 version = "1.0.4" 990 1070 source = "registry+https://github.com/rust-lang/crates.io-index" 991 1071 checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" 1072 + 1073 + [[package]] 1074 + name = "dispatch" 1075 + version = "0.2.0" 1076 + source = "registry+https://github.com/rust-lang/crates.io-index" 1077 + checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" 992 1078 993 1079 [[package]] 994 1080 name = "displaydoc" ··· 1109 1195 version = "0.1.4" 1110 1196 source = "registry+https://github.com/rust-lang/crates.io-index" 1111 1197 checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 1198 + 1199 + [[package]] 1200 + name = "foreign-types" 1201 + version = "0.3.2" 1202 + source = "registry+https://github.com/rust-lang/crates.io-index" 1203 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 1204 + dependencies = [ 1205 + "foreign-types-shared", 1206 + ] 1207 + 1208 + [[package]] 1209 + name = "foreign-types-shared" 1210 + version = "0.1.1" 1211 + source = "registry+https://github.com/rust-lang/crates.io-index" 1212 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 1112 1213 1113 1214 [[package]] 1114 1215 name = "form_urlencoded" ··· 2016 2117 checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 2017 2118 2018 2119 [[package]] 2120 + name = "libdbus-sys" 2121 + version = "0.2.5" 2122 + source = "registry+https://github.com/rust-lang/crates.io-index" 2123 + checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" 2124 + dependencies = [ 2125 + "pkg-config", 2126 + ] 2127 + 2128 + [[package]] 2019 2129 name = "libloading" 2020 2130 version = "0.8.6" 2021 2131 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2120 2230 version = "0.4.3" 2121 2231 source = "registry+https://github.com/rust-lang/crates.io-index" 2122 2232 checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" 2233 + dependencies = [ 2234 + "libc", 2235 + ] 2236 + 2237 + [[package]] 2238 + name = "malloc_buf" 2239 + version = "0.0.6" 2240 + source = "registry+https://github.com/rust-lang/crates.io-index" 2241 + checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 2123 2242 dependencies = [ 2124 2243 "libc", 2125 2244 ] ··· 2416 2535 version = "0.1.0" 2417 2536 source = "registry+https://github.com/rust-lang/crates.io-index" 2418 2537 checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 2538 + 2539 + [[package]] 2540 + name = "objc" 2541 + version = "0.2.7" 2542 + source = "registry+https://github.com/rust-lang/crates.io-index" 2543 + checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 2544 + dependencies = [ 2545 + "malloc_buf", 2546 + ] 2419 2547 2420 2548 [[package]] 2421 2549 name = "object" ··· 3385 3513 ] 3386 3514 3387 3515 [[package]] 3516 + name = "souvlaki" 3517 + version = "0.8.3" 3518 + source = "registry+https://github.com/rust-lang/crates.io-index" 3519 + checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" 3520 + dependencies = [ 3521 + "base64 0.22.1", 3522 + "block", 3523 + "cocoa", 3524 + "core-graphics", 3525 + "dbus", 3526 + "dbus-crossroads", 3527 + "dispatch", 3528 + "objc", 3529 + "thiserror 1.0.69", 3530 + "windows 0.44.0", 3531 + ] 3532 + 3533 + [[package]] 3388 3534 name = "spin" 3389 3535 version = "0.5.2" 3390 3536 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4235 4381 "rodio", 4236 4382 "rustfft", 4237 4383 "serde", 4384 + "souvlaki", 4238 4385 "surf", 4239 4386 "symphonia", 4240 4387 "termion", ··· 4567 4714 ] 4568 4715 4569 4716 [[package]] 4717 + name = "windows" 4718 + version = "0.44.0" 4719 + source = "registry+https://github.com/rust-lang/crates.io-index" 4720 + checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" 4721 + dependencies = [ 4722 + "windows-targets 0.42.2", 4723 + ] 4724 + 4725 + [[package]] 4570 4726 name = "windows-core" 4571 4727 version = "0.52.0" 4572 4728 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4604 4760 4605 4761 [[package]] 4606 4762 name = "windows-targets" 4763 + version = "0.42.2" 4764 + source = "registry+https://github.com/rust-lang/crates.io-index" 4765 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 4766 + dependencies = [ 4767 + "windows_aarch64_gnullvm 0.42.2", 4768 + "windows_aarch64_msvc 0.42.2", 4769 + "windows_i686_gnu 0.42.2", 4770 + "windows_i686_msvc 0.42.2", 4771 + "windows_x86_64_gnu 0.42.2", 4772 + "windows_x86_64_gnullvm 0.42.2", 4773 + "windows_x86_64_msvc 0.42.2", 4774 + ] 4775 + 4776 + [[package]] 4777 + name = "windows-targets" 4607 4778 version = "0.48.5" 4608 4779 source = "registry+https://github.com/rust-lang/crates.io-index" 4609 4780 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 4635 4806 4636 4807 [[package]] 4637 4808 name = "windows_aarch64_gnullvm" 4809 + version = "0.42.2" 4810 + source = "registry+https://github.com/rust-lang/crates.io-index" 4811 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 4812 + 4813 + [[package]] 4814 + name = "windows_aarch64_gnullvm" 4638 4815 version = "0.48.5" 4639 4816 source = "registry+https://github.com/rust-lang/crates.io-index" 4640 4817 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 4653 4830 4654 4831 [[package]] 4655 4832 name = "windows_aarch64_msvc" 4833 + version = "0.42.2" 4834 + source = "registry+https://github.com/rust-lang/crates.io-index" 4835 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 4836 + 4837 + [[package]] 4838 + name = "windows_aarch64_msvc" 4656 4839 version = "0.48.5" 4657 4840 source = "registry+https://github.com/rust-lang/crates.io-index" 4658 4841 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 4671 4854 4672 4855 [[package]] 4673 4856 name = "windows_i686_gnu" 4857 + version = "0.42.2" 4858 + source = "registry+https://github.com/rust-lang/crates.io-index" 4859 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 4860 + 4861 + [[package]] 4862 + name = "windows_i686_gnu" 4674 4863 version = "0.48.5" 4675 4864 source = "registry+https://github.com/rust-lang/crates.io-index" 4676 4865 checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" ··· 4695 4884 4696 4885 [[package]] 4697 4886 name = "windows_i686_msvc" 4887 + version = "0.42.2" 4888 + source = "registry+https://github.com/rust-lang/crates.io-index" 4889 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 4890 + 4891 + [[package]] 4892 + name = "windows_i686_msvc" 4698 4893 version = "0.48.5" 4699 4894 source = "registry+https://github.com/rust-lang/crates.io-index" 4700 4895 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" ··· 4713 4908 4714 4909 [[package]] 4715 4910 name = "windows_x86_64_gnu" 4911 + version = "0.42.2" 4912 + source = "registry+https://github.com/rust-lang/crates.io-index" 4913 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 4914 + 4915 + [[package]] 4916 + name = "windows_x86_64_gnu" 4716 4917 version = "0.48.5" 4717 4918 source = "registry+https://github.com/rust-lang/crates.io-index" 4718 4919 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" ··· 4722 4923 version = "0.52.6" 4723 4924 source = "registry+https://github.com/rust-lang/crates.io-index" 4724 4925 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 4926 + 4927 + [[package]] 4928 + name = "windows_x86_64_gnullvm" 4929 + version = "0.42.2" 4930 + source = "registry+https://github.com/rust-lang/crates.io-index" 4931 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 4725 4932 4726 4933 [[package]] 4727 4934 name = "windows_x86_64_gnullvm" ··· 4740 4947 version = "0.37.0" 4741 4948 source = "registry+https://github.com/rust-lang/crates.io-index" 4742 4949 checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" 4950 + 4951 + [[package]] 4952 + name = "windows_x86_64_msvc" 4953 + version = "0.42.2" 4954 + source = "registry+https://github.com/rust-lang/crates.io-index" 4955 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 4743 4956 4744 4957 [[package]] 4745 4958 name = "windows_x86_64_msvc"
+1
Cargo.toml
··· 78 78 tonic-web = "0.12.3" 79 79 tunein = "0.1.3" 80 80 url = "2.3.1" 81 + souvlaki = "0.8.3" 81 82 82 83 [build-dependencies] 83 84 tonic-build = "0.12.3"
+300 -64
src/app.rs
··· 3 3 prelude::*, 4 4 widgets::{block::*, *}, 5 5 }; 6 + use souvlaki::MediaControlEvent; 6 7 use std::{ 7 8 io, 8 9 ops::Range, ··· 11 12 time::{Duration, Instant}, 12 13 }; 13 14 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; 15 + use tunein_cli::os_media_controls::{self, OsMediaControls}; 14 16 15 17 use crate::{ 16 18 extract::get_currently_playing, ··· 76 78 self.is_muted = !self.is_muted; 77 79 } 78 80 81 + /// Set the volume to the given volume ratio. 82 + /// 83 + /// `1.0` is 100% volume. 84 + pub const fn set_volume_ratio(&mut self, volume: f32) { 85 + self.raw_volume_percent = volume * 100.0; 86 + self.raw_volume_percent = self.raw_volume_percent.max(0.0); 87 + } 88 + 79 89 /// Change the volume by the given step percent. 80 90 /// 81 91 /// To increase the volume, use a positive step. To decrease the ··· 135 145 spectroscope: Spectroscope, 136 146 mode: CurrentDisplayMode, 137 147 frame_rx: Receiver<minimp3::Frame>, 148 + /// [`OsMediaControls`]. 149 + os_media_controls: Option<OsMediaControls>, 150 + /// Poll for events every specified [`Duration`]. 151 + /// 152 + /// Allows user to decide the trade off between computational 153 + /// resource comsumption, animation smoothness and how responsive 154 + /// the application. Smaller durations lead to more resource 155 + /// consumption but smoother animations and better responsiveness. 156 + poll_events_every: Duration, 157 + /// [`Self::poll_events_every`] but when player is paused. 158 + /// 159 + /// This should generally be larger than 160 + /// [`Self::poll_events_every`]. 161 + poll_events_every_while_paused: Duration, 138 162 } 139 163 140 164 impl App { ··· 143 167 source: &crate::cfg::SourceOptions, 144 168 frame_rx: Receiver<minimp3::Frame>, 145 169 mode: CurrentDisplayMode, 170 + os_media_controls: Option<OsMediaControls>, 171 + poll_events_every: Duration, 172 + poll_events_every_while_paused: Duration, 146 173 ) -> Self { 147 174 let graph = GraphConfig { 148 175 axis_color: Color::DarkGray, ··· 175 202 mode, 176 203 channels: source.channels as u8, 177 204 frame_rx, 205 + os_media_controls, 206 + poll_events_every, 207 + poll_events_every_while_paused, 178 208 } 179 209 } 180 210 } ··· 307 337 id: &str, 308 338 ) { 309 339 let new_state = cmd_rx.recv().await.unwrap(); 340 + 341 + // report metadata to OS 342 + send_os_media_controls_command( 343 + self.os_media_controls.as_mut(), 344 + os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 345 + title: (!new_state.now_playing.is_empty()).then_some(&new_state.now_playing), 346 + album: (!new_state.name.is_empty()).then_some(&new_state.name), 347 + artist: None, 348 + cover_url: None, 349 + duration: None, 350 + }), 351 + ); 352 + // report started playing to OS 353 + send_os_media_controls_command( 354 + self.os_media_controls.as_mut(), 355 + os_media_controls::Command::Play, 356 + ); 357 + // report volume to OS 358 + send_os_media_controls_command( 359 + self.os_media_controls.as_mut(), 360 + os_media_controls::Command::SetVolume(new_state.volume.volume_ratio() as f64), 361 + ); 362 + 310 363 let new_state = Arc::new(Mutex::new(new_state)); 311 364 312 365 let id = id.to_string(); ··· 328 381 let mut last_poll = Instant::now(); 329 382 330 383 loop { 331 - let channels = (!self.graph.pause) 332 - .then(|| self.frame_rx.recv().unwrap()) 333 - .map(|audio_frame| { 334 - stream_to_matrix(audio_frame.data.iter().cloned(), audio_frame.channels, 1.) 335 - }); 384 + let channels = if self.graph.pause { 385 + None 386 + } else { 387 + let Ok(audio_frame) = self.frame_rx.recv() else { 388 + // other thread has closed so application has 389 + // closed 390 + return; 391 + }; 392 + Some(stream_to_matrix( 393 + audio_frame.data.iter().cloned(), 394 + audio_frame.channels, 395 + 1., 396 + )) 397 + }; 336 398 337 399 fps += 1; 338 400 ··· 387 449 .unwrap(); 388 450 } 389 451 390 - while event::poll(Duration::from_millis(0)).unwrap() { 452 + while let Some(event) = self 453 + .os_media_controls 454 + .as_mut() 455 + .and_then(|os_media_controls| os_media_controls.try_recv_os_event()) 456 + { 457 + if self.process_os_media_control_event(event, &new_state, &mut sink_cmd_tx) { 458 + return; 459 + } 460 + } 461 + 462 + let timeout_duration = if self.graph.pause { 463 + self.poll_events_every_while_paused 464 + } else { 465 + self.poll_events_every 466 + }; 467 + 468 + while event::poll(timeout_duration).unwrap() { 391 469 // process all enqueued events 392 470 let event = event::read().unwrap(); 393 471 ··· 434 512 ) -> Result<bool, io::Error> { 435 513 let mut quit = false; 436 514 437 - let play = |graph: &mut GraphConfig| { 438 - graph.pause = false; 439 - sink_cmd_tx 440 - .send(SinkCommand::Play) 441 - .expect("receiver never dropped"); 442 - }; 443 - 444 - let pause = |graph: &mut GraphConfig| { 445 - graph.pause = true; 446 - sink_cmd_tx 447 - .send(SinkCommand::Pause) 448 - .expect("receiver never dropped"); 449 - }; 450 - 451 - let toggle_play_pause = |graph: &mut GraphConfig| { 452 - graph.pause = !graph.pause; 453 - let sink_cmd = if graph.pause { 454 - SinkCommand::Pause 455 - } else { 456 - SinkCommand::Play 457 - }; 458 - sink_cmd_tx.send(sink_cmd).expect("receiver never dropped"); 459 - }; 460 - 461 - let lower_volume = || { 462 - let mut state = state.lock().unwrap(); 463 - state.volume.change_volume(-1.0); 464 - sink_cmd_tx 465 - .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 466 - .expect("receiver never dropped"); 467 - }; 468 - 469 - let raise_volume = || { 470 - let mut state = state.lock().unwrap(); 471 - state.volume.change_volume(1.0); 472 - sink_cmd_tx 473 - .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 474 - .expect("receiver never dropped"); 475 - }; 476 - 477 - let mute_volume = || { 478 - let mut state = state.lock().unwrap(); 479 - state.volume.toggle_mute(); 480 - sink_cmd_tx 481 - .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 482 - .expect("receiver never dropped"); 483 - }; 484 - 485 515 if let Event::Key(key) = event { 486 516 if let KeyModifiers::CONTROL = key.modifiers { 487 517 match key.code { ··· 500 530 KeyCode::Up => { 501 531 // inverted to act as zoom 502 532 update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0); 503 - raise_volume(); 533 + raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx); 504 534 } 505 535 KeyCode::Down => { 506 536 // inverted to act as zoom 507 537 update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0); 508 - lower_volume(); 538 + lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx); 509 539 } 510 540 KeyCode::Right => update_value_i( 511 541 &mut self.graph.samples, ··· 522 552 0..self.graph.width * 2, 523 553 ), 524 554 KeyCode::Char('q') => quit = true, 525 - KeyCode::Char(' ') => toggle_play_pause(&mut self.graph), 555 + KeyCode::Char(' ') => toggle_play_pause( 556 + &mut self.graph, 557 + self.os_media_controls.as_mut(), 558 + sink_cmd_tx, 559 + ), 526 560 KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter, 527 561 KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui, 528 562 KeyCode::Char('r') => self.graph.references = !self.graph.references, 529 - KeyCode::Char('m') => mute_volume(), 563 + KeyCode::Char('m') => { 564 + mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx) 565 + } 530 566 KeyCode::Esc => { 531 567 self.graph.samples = self.graph.width; 532 568 self.graph.scale = 1.; ··· 552 588 } 553 589 } 554 590 KeyCode::Media(media_key_code) => match media_key_code { 555 - MediaKeyCode::Play => play(&mut self.graph), 556 - MediaKeyCode::Pause => pause(&mut self.graph), 557 - MediaKeyCode::PlayPause => toggle_play_pause(&mut self.graph), 591 + MediaKeyCode::Play => play( 592 + &mut self.graph, 593 + self.os_media_controls.as_mut(), 594 + sink_cmd_tx, 595 + ), 596 + MediaKeyCode::Pause => pause( 597 + &mut self.graph, 598 + self.os_media_controls.as_mut(), 599 + sink_cmd_tx, 600 + ), 601 + MediaKeyCode::PlayPause => toggle_play_pause( 602 + &mut self.graph, 603 + self.os_media_controls.as_mut(), 604 + sink_cmd_tx, 605 + ), 558 606 MediaKeyCode::Stop => { 559 607 quit = true; 560 608 } 561 - MediaKeyCode::LowerVolume => lower_volume(), 562 - MediaKeyCode::RaiseVolume => raise_volume(), 563 - MediaKeyCode::MuteVolume => mute_volume(), 609 + MediaKeyCode::LowerVolume => { 610 + lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx) 611 + } 612 + MediaKeyCode::RaiseVolume => { 613 + raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx) 614 + } 615 + MediaKeyCode::MuteVolume => { 616 + mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx) 617 + } 564 618 MediaKeyCode::TrackNext 565 619 | MediaKeyCode::TrackPrevious 566 620 | MediaKeyCode::Reverse ··· 574 628 575 629 Ok(quit) 576 630 } 631 + 632 + /// Process OS media control event. 633 + /// 634 + /// Returns [`true`] if application should quit. 635 + fn process_os_media_control_event( 636 + &mut self, 637 + event: MediaControlEvent, 638 + state: &Mutex<State>, 639 + sink_cmd_tx: &mut UnboundedSender<SinkCommand>, 640 + ) -> bool { 641 + let mut quit = false; 642 + 643 + match event { 644 + MediaControlEvent::Play => { 645 + play( 646 + &mut self.graph, 647 + self.os_media_controls.as_mut(), 648 + sink_cmd_tx, 649 + ); 650 + } 651 + MediaControlEvent::Pause => { 652 + pause( 653 + &mut self.graph, 654 + self.os_media_controls.as_mut(), 655 + sink_cmd_tx, 656 + ); 657 + } 658 + MediaControlEvent::Toggle => { 659 + toggle_play_pause( 660 + &mut self.graph, 661 + self.os_media_controls.as_mut(), 662 + sink_cmd_tx, 663 + ); 664 + } 665 + MediaControlEvent::Stop | MediaControlEvent::Quit => { 666 + quit = true; 667 + } 668 + MediaControlEvent::SetVolume(volume) => { 669 + set_volume_ratio( 670 + volume as f32, 671 + state, 672 + self.os_media_controls.as_mut(), 673 + sink_cmd_tx, 674 + ); 675 + } 676 + MediaControlEvent::Next 677 + | MediaControlEvent::Previous 678 + | MediaControlEvent::Seek(_) 679 + | MediaControlEvent::SeekBy(_, _) 680 + | MediaControlEvent::SetPosition(_) 681 + | MediaControlEvent::OpenUri(_) 682 + | MediaControlEvent::Raise => {} 683 + } 684 + 685 + quit 686 + } 577 687 } 578 688 579 689 pub fn update_value_f(val: &mut f64, base: f64, magnitude: f64, range: Range<f64>) { ··· 635 745 ) 636 746 .style(Style::default().fg(cfg.labels_color)) 637 747 } 748 + 749 + /// Play music. 750 + fn play( 751 + graph: &mut GraphConfig, 752 + os_media_controls: Option<&mut OsMediaControls>, 753 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 754 + ) { 755 + graph.pause = false; 756 + send_os_media_controls_command(os_media_controls, os_media_controls::Command::Play); 757 + sink_cmd_tx 758 + .send(SinkCommand::Play) 759 + .expect("receiver never dropped"); 760 + } 761 + 762 + /// Pause music. 763 + fn pause( 764 + graph: &mut GraphConfig, 765 + os_media_controls: Option<&mut OsMediaControls>, 766 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 767 + ) { 768 + graph.pause = true; 769 + send_os_media_controls_command(os_media_controls, os_media_controls::Command::Pause); 770 + sink_cmd_tx 771 + .send(SinkCommand::Pause) 772 + .expect("receiver never dropped"); 773 + } 774 + 775 + /// Toggle between play and pause. 776 + fn toggle_play_pause( 777 + graph: &mut GraphConfig, 778 + os_media_controls: Option<&mut OsMediaControls>, 779 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 780 + ) { 781 + graph.pause = !graph.pause; 782 + let (sink_cmd, os_media_controls_command) = if graph.pause { 783 + (SinkCommand::Pause, os_media_controls::Command::Pause) 784 + } else { 785 + (SinkCommand::Play, os_media_controls::Command::Play) 786 + }; 787 + send_os_media_controls_command(os_media_controls, os_media_controls_command); 788 + sink_cmd_tx.send(sink_cmd).expect("receiver never dropped"); 789 + } 790 + 791 + /// Lower the volume. 792 + fn lower_volume( 793 + state: &Mutex<State>, 794 + os_media_controls: Option<&mut OsMediaControls>, 795 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 796 + ) { 797 + let mut state = state.lock().unwrap(); 798 + state.volume.change_volume(-1.0); 799 + send_os_media_controls_command( 800 + os_media_controls, 801 + os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64), 802 + ); 803 + sink_cmd_tx 804 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 805 + .expect("receiver never dropped"); 806 + } 807 + 808 + /// Raise the volume. 809 + fn raise_volume( 810 + state: &Mutex<State>, 811 + os_media_controls: Option<&mut OsMediaControls>, 812 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 813 + ) { 814 + let mut state = state.lock().unwrap(); 815 + state.volume.change_volume(1.0); 816 + send_os_media_controls_command( 817 + os_media_controls, 818 + os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64), 819 + ); 820 + sink_cmd_tx 821 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 822 + .expect("receiver never dropped"); 823 + } 824 + 825 + /// Mute the volume. 826 + fn mute_volume( 827 + state: &Mutex<State>, 828 + os_media_controls: Option<&mut OsMediaControls>, 829 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 830 + ) { 831 + let mut state = state.lock().unwrap(); 832 + state.volume.toggle_mute(); 833 + send_os_media_controls_command( 834 + os_media_controls, 835 + os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64), 836 + ); 837 + sink_cmd_tx 838 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 839 + .expect("receiver never dropped"); 840 + } 841 + 842 + /// Set the volume to the given volume ratio. 843 + fn set_volume_ratio( 844 + volume_ratio: f32, 845 + state: &Mutex<State>, 846 + os_media_controls: Option<&mut OsMediaControls>, 847 + sink_cmd_tx: &UnboundedSender<SinkCommand>, 848 + ) { 849 + let mut state = state.lock().unwrap(); 850 + state.volume.set_volume_ratio(volume_ratio); 851 + send_os_media_controls_command( 852 + os_media_controls, 853 + os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64), 854 + ); 855 + sink_cmd_tx 856 + .send(SinkCommand::SetVolume(state.volume.volume_ratio())) 857 + .expect("receiver never dropped"); 858 + } 859 + 860 + /// Send [`os_media_controls::Command`]. 861 + fn send_os_media_controls_command( 862 + os_media_controls: Option<&mut OsMediaControls>, 863 + command: os_media_controls::Command<'_>, 864 + ) { 865 + if let Some(os_media_controls) = os_media_controls { 866 + let _ = os_media_controls.send_to_os(command).inspect_err(|err| { 867 + eprintln!( 868 + "error: failed to send command to OS media controls due to `{}`", 869 + err 870 + ); 871 + }); 872 + } 873 + }
+1
src/lib.rs
··· 1 1 pub mod extract; 2 + pub mod os_media_controls; 2 3 pub mod provider; 3 4 pub mod types; 4 5
+26 -3
src/main.rs
··· 1 + use std::time::Duration; 2 + 1 3 use anyhow::Error; 2 4 use app::CurrentDisplayMode; 3 - use clap::{arg, Command}; 5 + use clap::{arg, builder::ValueParser, Command}; 4 6 5 7 mod app; 6 8 mod browse; ··· 48 50 .about("Play a radio station") 49 51 .arg(arg!(<station> "The station to play")) 50 52 .arg(arg!(--volume "Set the initial volume (as a percent)").default_value("100")) 51 - .arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope")), 53 + .arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope")) 54 + .arg(clap::Arg::new("enable-os-media-controls").long("enable-os-media-controls").help("Should enable OS media controls?").default_value("true").value_parser(ValueParser::bool())) 55 + .arg(clap::Arg::new("poll-events-every").long("poll-events-every").help("Poll for events every specified milliseconds.").default_value("16")) 56 + .arg(clap::Arg::new("poll-events-every-while-paused").long("poll-events-every-while-paused").help("Poll for events every specified milliseconds while player is paused.").default_value("100")), 52 57 ) 53 58 .subcommand( 54 59 Command::new("browse") ··· 99 104 .unwrap() 100 105 .parse::<CurrentDisplayMode>() 101 106 .unwrap(); 102 - play::exec(station, provider, volume, display_mode).await?; 107 + let enable_os_media_controls = args.get_one("enable-os-media-controls").unwrap(); 108 + let poll_events_every = 109 + Duration::from_millis(args.value_of("poll-events-every").unwrap().parse().unwrap()); 110 + let poll_events_every_while_paused = Duration::from_millis( 111 + args.value_of("poll-events-every-while-paused") 112 + .unwrap() 113 + .parse() 114 + .unwrap(), 115 + ); 116 + play::exec( 117 + station, 118 + provider, 119 + volume, 120 + display_mode, 121 + *enable_os_media_controls, 122 + poll_events_every, 123 + poll_events_every_while_paused, 124 + ) 125 + .await?; 103 126 } 104 127 Some(("browse", args)) => { 105 128 let category = args.value_of("category");
+87
src/os_media_controls.rs
··· 1 + //! Operating system level media controls. 2 + 3 + use tokio::sync::mpsc::UnboundedReceiver; 4 + 5 + /// Operating system level media controls. 6 + #[derive(Debug)] 7 + pub struct OsMediaControls { 8 + /// Controls that interface with the OS. 9 + controls: souvlaki::MediaControls, 10 + /// Receiver for events produced by OS level interaction. 11 + event_receiver: UnboundedReceiver<souvlaki::MediaControlEvent>, 12 + } 13 + 14 + impl OsMediaControls { 15 + /// Create new [`OsMediaControls`]. 16 + pub fn new() -> Result<Self, souvlaki::Error> { 17 + let mut controls = souvlaki::MediaControls::new(souvlaki::PlatformConfig { 18 + display_name: "tunein-cli", 19 + dbus_name: "tsirysndr.tunein-cli", 20 + // TODO: support windows platform 21 + hwnd: None, 22 + })?; 23 + 24 + let (event_sender, event_receiver) = 25 + tokio::sync::mpsc::unbounded_channel::<souvlaki::MediaControlEvent>(); 26 + 27 + controls.attach(move |event| { 28 + event_sender.send(event).expect("receiver always alive"); 29 + })?; 30 + 31 + Ok(Self { 32 + controls, 33 + event_receiver, 34 + }) 35 + } 36 + 37 + /// Try to receive event produced by the operating system. 38 + /// 39 + /// Is [`None`] if no event is produced. 40 + pub fn try_recv_os_event(&mut self) -> Option<souvlaki::MediaControlEvent> { 41 + self.event_receiver.try_recv().ok() 42 + } 43 + 44 + /// Send the given [`Command`] to the operating system. 45 + pub fn send_to_os(&mut self, command: Command) -> Result<(), souvlaki::Error> { 46 + match command { 47 + Command::Play => self 48 + .controls 49 + .set_playback(souvlaki::MediaPlayback::Playing { progress: None }), 50 + Command::Pause => self 51 + .controls 52 + .set_playback(souvlaki::MediaPlayback::Paused { progress: None }), 53 + Command::SetVolume(volume) => { 54 + // NOTE: is supported only for MPRIS backend, 55 + // `souvlaki` doesn't provide a way to know this, so 56 + // need to use `cfg` attribute like the way it exposes 57 + // the platform 58 + #[cfg(all( 59 + unix, 60 + not(any(target_os = "macos", target_os = "ios", target_os = "android")) 61 + ))] 62 + { 63 + self.controls.set_volume(volume) 64 + } 65 + #[cfg(not(all( 66 + unix, 67 + not(any(target_os = "macos", target_os = "ios", target_os = "android")) 68 + )))] 69 + { 70 + Ok(()) 71 + } 72 + } 73 + Command::SetMetadata(metadata) => self.controls.set_metadata(metadata), 74 + } 75 + } 76 + } 77 + 78 + /// Commands understood by OS media controls. 79 + #[derive(Debug, Clone)] 80 + pub enum Command<'a> { 81 + Play, 82 + Pause, 83 + /// Volume must be between `0.0..=1.0`. 84 + SetVolume(f64), 85 + /// Set the [`souvlaki::MediaMetadata`]. 86 + SetMetadata(souvlaki::MediaMetadata<'a>), 87 + }
+26 -1
src/play.rs
··· 2 2 3 3 use anyhow::Error; 4 4 use hyper::header::HeaderValue; 5 + use tunein_cli::os_media_controls::OsMediaControls; 5 6 6 7 use crate::{ 7 8 app::{App, CurrentDisplayMode, State, Volume}, ··· 16 17 provider: &str, 17 18 volume: f32, 18 19 display_mode: CurrentDisplayMode, 20 + enable_os_media_controls: bool, 21 + poll_events_every: Duration, 22 + poll_events_every_while_paused: Duration, 19 23 ) -> Result<(), Error> { 20 24 let _provider = provider; 21 25 let provider: Box<dyn Provider> = match provider { ··· 57 61 tune: None, 58 62 }; 59 63 60 - let mut app = App::new(&ui, &opts, frame_rx, display_mode); 64 + let os_media_controls = if enable_os_media_controls { 65 + OsMediaControls::new() 66 + .inspect_err(|err| { 67 + eprintln!( 68 + "error: failed to initialize os media controls due to `{}`", 69 + err 70 + ); 71 + }) 72 + .ok() 73 + } else { 74 + None 75 + }; 76 + 77 + let mut app = App::new( 78 + &ui, 79 + &opts, 80 + frame_rx, 81 + display_mode, 82 + os_media_controls, 83 + poll_events_every, 84 + poll_events_every_while_paused, 85 + ); 61 86 let station_name = station.name.clone(); 62 87 63 88 thread::spawn(move || {