use anyhow::Result;
use block2::RcBlock;
use log::info;
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
use objc2::AnyThread;
use objc2_app_kit::NSImage;
use objc2_foundation::{NSData, NSDate, NSMutableDictionary, NSNumber, NSRunLoop, NSString};
use objc2_media_player::{
MPMediaItemArtwork, MPMediaItemPropertyAlbumTitle, MPMediaItemPropertyArtist,
MPMediaItemPropertyArtwork, MPMediaItemPropertyPlaybackDuration, MPMediaItemPropertyTitle,
MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPNowPlayingInfoPropertyPlaybackRate, MPNowPlayingPlaybackState, MPRemoteCommandCenter,
MPRemoteCommandEvent, MPRemoteCommandHandlerStatus,
};
use std::ptr::NonNull;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub enum MacMediaEvent {
PlayPause,
Play,
Pause,
Next,
Previous,
Stop,
}
#[derive(Debug, Clone)]
#[allow(dead_code, clippy::enum_variant_names)]
pub enum MacMediaCommand {
SetMetadata {
title: String,
artists: Vec<String>,
album: String,
duration_ms: u32,
art_url: Option<String>,
},
SetPlaybackStatus(bool), SetPosition(u64), SetVolume(u8), SetStopped,
}
pub struct MacMediaManager {
event_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<MacMediaEvent>>>,
command_tx: mpsc::UnboundedSender<MacMediaCommand>,
}
impl MacMediaManager {
pub fn new() -> Result<Self> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<MacMediaCommand>();
let event_tx = Arc::new(event_tx);
thread::spawn(move || {
unsafe {
let cls = AnyClass::get(c"NSApplication").expect("NSApplication class not found");
let app: objc2::rc::Retained<AnyObject> = msg_send![cls, sharedApplication];
let _activation_policy_set: bool = msg_send![&app, setActivationPolicy: 2isize];
}
info!("macos media: NSApplication initialized with Prohibited activation policy");
let command_center = unsafe { MPRemoteCommandCenter::sharedCommandCenter() };
let tx = Arc::clone(&event_tx);
let play_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received Play event");
let _ = tx.send(MacMediaEvent::Play);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.playCommand()
.addTargetWithHandler(&play_handler);
}
let tx = Arc::clone(&event_tx);
let pause_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received Pause event");
let _ = tx.send(MacMediaEvent::Pause);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.pauseCommand()
.addTargetWithHandler(&pause_handler);
}
let tx = Arc::clone(&event_tx);
let toggle_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received PlayPause event");
let _ = tx.send(MacMediaEvent::PlayPause);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.togglePlayPauseCommand()
.addTargetWithHandler(&toggle_handler);
}
let tx = Arc::clone(&event_tx);
let next_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received Next event");
let _ = tx.send(MacMediaEvent::Next);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.nextTrackCommand()
.addTargetWithHandler(&next_handler);
}
let tx = Arc::clone(&event_tx);
let prev_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received Previous event");
let _ = tx.send(MacMediaEvent::Previous);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.previousTrackCommand()
.addTargetWithHandler(&prev_handler);
}
let tx = Arc::clone(&event_tx);
let stop_handler: RcBlock<
dyn Fn(NonNull<MPRemoteCommandEvent>) -> MPRemoteCommandHandlerStatus,
> = RcBlock::new(move |_event: NonNull<MPRemoteCommandEvent>| {
info!("macos media: received Stop event");
let _ = tx.send(MacMediaEvent::Stop);
MPRemoteCommandHandlerStatus::Success
});
unsafe {
command_center
.stopCommand()
.addTargetWithHandler(&stop_handler);
}
info!("macos media: remote command handlers registered");
let info_center = unsafe { MPNowPlayingInfoCenter::defaultCenter() };
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create macOS media runtime");
rt.block_on(async move {
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
tokio::select! {
Some(cmd) = command_rx.recv() => {
handle_now_playing_command(&cmd, &info_center).await;
}
_ = interval.tick() => {
NSRunLoop::currentRunLoop()
.runUntilDate(&NSDate::dateWithTimeIntervalSinceNow(0.01));
}
}
}
});
});
Ok(Self {
event_rx: std::sync::Mutex::new(Some(event_rx)),
command_tx,
})
}
pub fn take_event_rx(&self) -> Option<mpsc::UnboundedReceiver<MacMediaEvent>> {
self.event_rx.lock().ok()?.take()
}
pub fn set_metadata(
&self,
title: &str,
artists: &[String],
album: &str,
duration_ms: u32,
art_url: Option<String>,
) {
let _ = self.command_tx.send(MacMediaCommand::SetMetadata {
title: title.to_string(),
artists: artists.to_vec(),
album: album.to_string(),
duration_ms,
art_url,
});
}
pub fn set_playback_status(&self, is_playing: bool) {
let _ = self
.command_tx
.send(MacMediaCommand::SetPlaybackStatus(is_playing));
}
pub fn set_position(&self, position_ms: u64) {
let _ = self
.command_tx
.send(MacMediaCommand::SetPosition(position_ms));
}
#[allow(dead_code)]
pub fn set_volume(&self, volume_percent: u8) {
let _ = self
.command_tx
.send(MacMediaCommand::SetVolume(volume_percent));
}
pub fn set_stopped(&self) {
let _ = self.command_tx.send(MacMediaCommand::SetStopped);
}
}
async fn handle_now_playing_command(cmd: &MacMediaCommand, info_center: &MPNowPlayingInfoCenter) {
match cmd {
MacMediaCommand::SetMetadata {
title,
artists,
album,
duration_ms,
art_url,
} => {
let artwork = match art_url.as_deref() {
Some(url) => fetch_artwork_from_url(url).await,
None => None,
};
unsafe {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::new();
let title_ns = NSString::from_str(title);
dict.insert(MPMediaItemPropertyTitle, &*title_ns);
let artist_ns = NSString::from_str(&artists.join(", "));
dict.insert(MPMediaItemPropertyArtist, &*artist_ns);
let album_ns = NSString::from_str(album);
dict.insert(MPMediaItemPropertyAlbumTitle, &*album_ns);
let duration = NSNumber::numberWithDouble(f64::from(*duration_ms) / 1000.0);
dict.insert(MPMediaItemPropertyPlaybackDuration, &*duration);
let rate = NSNumber::numberWithDouble(1.0);
dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate);
if let Some(artwork) = artwork.as_ref() {
dict.insert(MPMediaItemPropertyArtwork, &**artwork);
}
info_center.setNowPlayingInfo(Some(&dict));
}
}
MacMediaCommand::SetPlaybackStatus(is_playing) => unsafe {
let state = if *is_playing {
MPNowPlayingPlaybackState::Playing
} else {
MPNowPlayingPlaybackState::Paused
};
info_center.setPlaybackState(state);
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let rate = NSNumber::numberWithDouble(if *is_playing { 1.0 } else { 0.0 });
dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate);
info_center.setNowPlayingInfo(Some(&dict));
}
},
MacMediaCommand::SetPosition(position_ms) => unsafe {
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let elapsed = NSNumber::numberWithDouble(*position_ms as f64 / 1000.0);
dict.insert(MPNowPlayingInfoPropertyElapsedPlaybackTime, &*elapsed);
info_center.setNowPlayingInfo(Some(&dict));
}
},
MacMediaCommand::SetVolume(_) => {
}
MacMediaCommand::SetStopped => unsafe {
info_center.setPlaybackState(MPNowPlayingPlaybackState::Stopped);
info_center.setNowPlayingInfo(None);
},
}
}
async fn fetch_artwork_from_url(art_url: &str) -> Option<objc2::rc::Retained<MPMediaItemArtwork>> {
let response = reqwest::get(art_url).await.ok()?;
if !response.status().is_success() {
return None;
}
let bytes = response.bytes().await.ok()?;
if bytes.is_empty() {
return None;
}
unsafe {
let data = NSData::dataWithBytes_length(bytes.as_ptr().cast(), bytes.len());
let image = NSImage::initWithData(NSImage::alloc(), &data)?;
let image_for_handler = image.clone();
let request_handler =
RcBlock::new(move |_requested_size| NonNull::from(image_for_handler.as_ref()));
Some(MPMediaItemArtwork::initWithBoundsSize_requestHandler(
MPMediaItemArtwork::alloc(),
image.size(),
&request_handler,
))
}
}