kopuz-player 0.8.1

A modern, lightweight music player built with Rust and Dioxus.
Documentation
// macOS system integration: media keys, Now Playing info, audio session, power
// management, and run loop heartbeat for remote command dispatching.
//
// Architecture:
// - init() sets up: NSProcessInfo (prevent App Nap), IOKit (no idle sleep),
//   AVAudioSession (background playback), MPRemoteCommandCenter (media keys),
//   CFRunLoopTimer (periodic heartbeat to wake the Tokio runtime)
// - update_now_playing() pushes metadata + artwork to MPNowPlayingInfoCenter
// - CFRunLoopWakeUp() is called to unblock the main thread from Tokio tasks
// - All Objective-C/CoreFoundation FFI is documented with // SAFETY: invariants

use std::ptr::NonNull;
use std::sync::Mutex as StdMutex;
use std::sync::{Arc, OnceLock};

use block2::RcBlock;
use objc2::AllocAnyThread;
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2_app_kit::NSImage;
use objc2_avf_audio::{AVAudioSession, AVAudioSessionCategoryPlayback};
use objc2_foundation::{
    NSCopying, NSDictionary, NSMutableDictionary, NSNumber, NSProcessInfo, NSString,
};
use objc2_media_player::{
    MPMediaItemArtwork, MPMediaItemPropertyAlbumTitle, MPMediaItemPropertyArtist,
    MPMediaItemPropertyArtwork, MPMediaItemPropertyPlaybackDuration, MPMediaItemPropertyTitle,
    MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime,
    MPNowPlayingInfoPropertyPlaybackRate, MPRemoteCommandCenter, MPRemoteCommandEvent,
    MPRemoteCommandHandlerStatus,
};

// SAFETY:
// These are well-documented CoreFoundation C functions. The FFI
// signatures match the official Apple headers. Each call site
// documents why the specific call is safe.
unsafe extern "C" {
    fn CFRunLoopGetMain() -> *mut std::ffi::c_void;
    fn CFRunLoopWakeUp(rl: *mut std::ffi::c_void);
    fn CFRunLoopAddTimer(
        rl: *mut std::ffi::c_void,
        timer: *mut std::ffi::c_void,
        mode: *const std::ffi::c_void,
    );
    fn CFRunLoopTimerCreate(
        allocator: *const std::ffi::c_void,
        fire_date: f64,
        interval: f64,
        flags: u64,
        order: i64,
        callout: unsafe extern "C" fn(*mut std::ffi::c_void, *mut std::ffi::c_void),
        context: *const std::ffi::c_void,
    ) -> *mut std::ffi::c_void;
    fn CFAbsoluteTimeGetCurrent() -> f64;
    static kCFRunLoopCommonModes: *const std::ffi::c_void;
}

type IOPMAssertionID = u32;
#[link(name = "IOKit", kind = "framework")]
// SAFETY:
// IOPMAssertionCreateWithName is a documented IOKit function.
// The FFI signature matches Apple's IOPMLib.h header. The call
// site documents the specific safety invariants.
unsafe extern "C" {
    fn IOPMAssertionCreateWithName(
        assertion_type: *const std::ffi::c_void,
        assertion_level: u32,
        reason: *const std::ffi::c_void,
        assertion_id: *mut IOPMAssertionID,
    ) -> i32;
}

pub fn wake_run_loop() {
    // SAFETY:
    // - CFRunLoopGetMain() always returns a valid reference to the main
    //   thread's run loop; it never returns null.
    // - CFRunLoopWakeUp is safe to call on any valid run loop and does
    //   not require additional synchronization.
    unsafe { CFRunLoopWakeUp(CFRunLoopGetMain()) }
}

#[derive(Debug)]
pub enum SystemEvent {
    Play,
    Pause,
    Toggle,
    Next,
    Prev,
}

static BACKGROUND_HANDLER: OnceLock<Arc<StdMutex<Option<Box<dyn Fn(SystemEvent) + Send + Sync>>>>> =
    OnceLock::new();

static TOKIO_WAKER: OnceLock<Arc<StdMutex<Option<Box<dyn Fn() + Send + Sync>>>>> = OnceLock::new();

fn get_bg_handler() -> Arc<StdMutex<Option<Box<dyn Fn(SystemEvent) + Send + Sync>>>> {
    BACKGROUND_HANDLER
        .get_or_init(|| Arc::new(StdMutex::new(None)))
        .clone()
}

fn get_tokio_waker() -> Arc<StdMutex<Option<Box<dyn Fn() + Send + Sync>>>> {
    TOKIO_WAKER
        .get_or_init(|| Arc::new(StdMutex::new(None)))
        .clone()
}

pub fn set_tokio_waker(waker: impl Fn() + Send + Sync + 'static) {
    let arc = get_tokio_waker();
    let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner());
    *guard = Some(Box::new(waker));
}

fn wake_tokio() {
    if let Ok(guard) = get_tokio_waker().lock() {
        if let Some(ref waker) = *guard {
            waker();
        }
    }
}

pub fn set_background_handler(handler: impl Fn(SystemEvent) + Send + Sync + 'static) {
    let arc = get_bg_handler();
    let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner());
    *guard = Some(Box::new(handler));
}

fn dispatch_event(event: SystemEvent) {
    if let Ok(guard) = get_bg_handler().lock() {
        if let Some(ref handler) = *guard {
            handler(event);
        }
    }
    wake_run_loop();
}

// SAFETY:
// - This function matches the C callback signature expected by
//   CFRunLoopTimerCreate (CFRunLoopTimerCallBack).
// - It only calls wake_tokio() which is a safe Rust function.
// - Parameters are unused, so their raw pointer values are never
//   dereferenced.
unsafe extern "C" fn main_loop_heartbeat(
    _timer: *mut std::ffi::c_void,
    _info: *mut std::ffi::c_void,
) {
    wake_tokio();
}

pub fn init() {
    static ONCE: OnceLock<()> = OnceLock::new();
    ONCE.get_or_init(|| {
        // SAFETY:
        // - msg_send! on NSProcessInfo, AVFoundation, and MediaPlayer objects
        //   is safe because these are well-known Apple frameworks that do not
        //   require special threading or memory management beyond retain/release,
        //   which objc2 handles automatically.
        // - transmute from &NSString to *const c_void is sound because
        //   NSString is a valid Objective-C object with a stable memory layout
        //   compatible with CoreFoundation's CFStringRef.
        // - IOPMAssertionCreateWithName expects a CFStringRef; transmuting
        //   NSString to *const c_void is valid for the same reason (toll-free
        //   bridging between CFStringRef and NSString).
        // - CFRunLoopGetMain() always returns a valid run loop reference.
        // - CFRunLoopTimerCreate with null allocator uses the default allocator,
        //   which is correct for CoreFoundation.
        // - kCFRunLoopCommonModes is a valid CFStringRef constant.
        // - main_loop_heartbeat matches the required C callback signature.
        // - All pointers passed to CoreFoundation functions are valid for the
        //   duration of the calls.
        // - The activity pointer from beginActivityWithOptions: is checked for
        //   null before being used.
        unsafe {
            use objc2::ClassType;
            let process_info: *mut AnyObject =
                objc2::msg_send![NSProcessInfo::class(), processInfo];
            let reason = NSString::from_str("Kopuz Background Audio Playback");
            let options: u64 = 0x00FFFFFF | 0xFF00000000;
            let activity: *mut AnyObject =
                objc2::msg_send![process_info, beginActivityWithOptions: options, reason: &*reason];
            if !activity.is_null() {
                let _: *mut AnyObject = objc2::msg_send![activity, retain];
                tracing::debug!("App Nap bypassed with NSProcessInfo activity (latency-critical)");
            }

            let assertion_type = NSString::from_str("NoIdleSleepAssertion");
            let assertion_reason = NSString::from_str("Kopuz is playing audio");
            let mut assertion_id: IOPMAssertionID = 0;
            let kr = IOPMAssertionCreateWithName(
                std::mem::transmute::<&objc2_foundation::NSString, *const std::ffi::c_void>(
                    &*assertion_type,
                ),
                255,
                std::mem::transmute::<&objc2_foundation::NSString, *const std::ffi::c_void>(
                    &*assertion_reason,
                ),
                &mut assertion_id,
            );
            if kr == 0 {
                tracing::debug!(id = assertion_id, "IOKit power assertion created");
            } else {
                tracing::warn!(kr, "failed to create IOKit power assertion");
            }

            let session = AVAudioSession::sharedInstance();
            if let Some(category) = AVAudioSessionCategoryPlayback {
                if let Err(e) = session.setCategory_error(category) {
                    tracing::warn!(error = ?e, "failed to set AVAudioSession category");
                }
                if let Err(e) = session.setActive_error(true) {
                    tracing::warn!(error = ?e, "failed to activate AVAudioSession");
                } else {
                    tracing::debug!("AVAudioSession configured for background playback");
                }
            } else {
                tracing::error!("AVAudioSessionCategoryPlayback not available");
            }

            let center = MPRemoteCommandCenter::sharedCommandCenter();

            center.playCommand().addTargetWithHandler(&RcBlock::new(
                move |_: NonNull<MPRemoteCommandEvent>| {
                    dispatch_event(SystemEvent::Play);
                    MPRemoteCommandHandlerStatus::Success
                },
            ));

            center.pauseCommand().addTargetWithHandler(&RcBlock::new(
                move |_: NonNull<MPRemoteCommandEvent>| {
                    dispatch_event(SystemEvent::Pause);
                    MPRemoteCommandHandlerStatus::Success
                },
            ));

            center
                .togglePlayPauseCommand()
                .addTargetWithHandler(&RcBlock::new(move |_: NonNull<MPRemoteCommandEvent>| {
                    dispatch_event(SystemEvent::Toggle);
                    MPRemoteCommandHandlerStatus::Success
                }));

            center
                .nextTrackCommand()
                .addTargetWithHandler(&RcBlock::new(move |_: NonNull<MPRemoteCommandEvent>| {
                    dispatch_event(SystemEvent::Next);
                    MPRemoteCommandHandlerStatus::Success
                }));

            center
                .previousTrackCommand()
                .addTargetWithHandler(&RcBlock::new(move |_: NonNull<MPRemoteCommandEvent>| {
                    dispatch_event(SystemEvent::Prev);
                    MPRemoteCommandHandlerStatus::Success
                }));

            let fire_date = CFAbsoluteTimeGetCurrent();
            let timer = CFRunLoopTimerCreate(
                std::ptr::null(),
                fire_date,
                0.25,
                0,
                0,
                main_loop_heartbeat,
                std::ptr::null(),
            );
            if !timer.is_null() {
                CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopCommonModes);
                tracing::debug!("CFRunLoopTimer heartbeat started on main run loop (250ms)");
            } else {
                tracing::warn!("failed to create CFRunLoopTimer, falling back to thread");
                std::thread::spawn(|| {
                    loop {
                        std::thread::sleep(std::time::Duration::from_millis(250));
                        wake_tokio();
                        wake_run_loop();
                    }
                });
            }
        }
    });
}

pub fn update_now_playing(
    title: &str,
    artist: &str,
    album: &str,
    duration: f64,
    position: f64,
    playing: bool,
    artwork_path: Option<&str>,
) {
    init();

    // SAFETY:
    // - This entire block interacts with MediaPlayer framework objects
    //   (MPNowPlayingInfoCenter, MPMediaItemArtwork) which are thread-safe
    //   and designed to be called from any thread on macOS.
    // - transmute from NSString/NSNumber to AnyObject is safe because
    //   these Foundation types inherit from NSObject and their memory
    //   layout is compatible. The protocol conformance is verified by
    //   the existing type system.
    // - transmute from NSMutableDictionary to NSDictionary is safe because
    //   NSMutableDictionary is a subclass of NSDictionary; the upcast is
    //   valid in Objective-C and matches what the framework expects.
    // - Retained::from_raw on the artwork pointer is safe because the
    //   pointer was just returned by initWithImage: which follows the
    //   "alloc-init" convention (returns +1 retain count), and from_raw
    //   takes ownership without over-releasing.
    // - The artwork pointer null-check ensures we only convert valid
    //   pointers to Retained.
    unsafe {
        let center = MPNowPlayingInfoCenter::defaultCenter();

        let title_ns = NSString::from_str(title);
        let artist_ns = NSString::from_str(artist);
        let album_ns = NSString::from_str(album);
        let duration_ns = NSNumber::numberWithDouble(duration);
        let position_ns = NSNumber::numberWithDouble(position);
        let rate_ns = NSNumber::numberWithDouble(if playing { 1.0 } else { 0.0 });

        let info = NSMutableDictionary::<ProtocolObject<dyn NSCopying>, AnyObject>::new();

        info.setObject_forKey(
            std::mem::transmute::<&NSString, &AnyObject>(&*title_ns),
            ProtocolObject::from_ref(MPMediaItemPropertyTitle),
        );
        info.setObject_forKey(
            std::mem::transmute::<&NSString, &AnyObject>(&*artist_ns),
            ProtocolObject::from_ref(MPMediaItemPropertyArtist),
        );
        info.setObject_forKey(
            std::mem::transmute::<&NSString, &AnyObject>(&*album_ns),
            ProtocolObject::from_ref(MPMediaItemPropertyAlbumTitle),
        );
        info.setObject_forKey(
            std::mem::transmute::<&NSNumber, &AnyObject>(&*duration_ns),
            ProtocolObject::from_ref(MPMediaItemPropertyPlaybackDuration),
        );
        info.setObject_forKey(
            std::mem::transmute::<&NSNumber, &AnyObject>(&*position_ns),
            ProtocolObject::from_ref(MPNowPlayingInfoPropertyElapsedPlaybackTime),
        );
        info.setObject_forKey(
            std::mem::transmute::<&NSNumber, &AnyObject>(&*rate_ns),
            ProtocolObject::from_ref(MPNowPlayingInfoPropertyPlaybackRate),
        );

        if let Some(path) = artwork_path {
            let ns_path = NSString::from_str(path);
            if let Some(image) = NSImage::initWithContentsOfFile(NSImage::alloc(), &ns_path) {
                use objc2::msg_send;
                let artwork_alloc = MPMediaItemArtwork::alloc();
                let artwork_ptr: *mut MPMediaItemArtwork = std::mem::transmute(artwork_alloc);
                let artwork_raw: *mut MPMediaItemArtwork =
                    msg_send![artwork_ptr, initWithImage: &*image];

                if !artwork_raw.is_null() {
                    let retained: objc2::rc::Retained<MPMediaItemArtwork> =
                        objc2::rc::Retained::from_raw(artwork_raw).expect("retained artwork");

                    let artwork_ref: &AnyObject =
                        &*(std::mem::transmute::<_, *const AnyObject>(&*retained));
                    info.setObject_forKey(
                        artwork_ref,
                        ProtocolObject::from_ref(MPMediaItemPropertyArtwork),
                    );
                }
            }
        }

        center.setNowPlayingInfo(Some(std::mem::transmute::<
            &NSMutableDictionary<_, _>,
            &NSDictionary<NSString, AnyObject>,
        >(&*info)));
    }
}

pub fn refresh_now_playing() {
    // SAFETY:
    // - MPNowPlayingInfoCenter::defaultCenter() returns a valid,
    //   thread-safe singleton.
    // - nowPlayingInfo() returns an Option that may be None; we pass
    //   None through as_deref() which maps to nil, which is a valid
    //   argument for setNowPlayingInfo (clears the info).
    // - setNowPlayingInfo accepts an optional dictionary; no
    //   preconditions are violated here.
    unsafe {
        let center = MPNowPlayingInfoCenter::defaultCenter();
        let existing = center.nowPlayingInfo();
        center.setNowPlayingInfo(existing.as_deref());
    }
}