spottedcat 2.0.0

Rusty SpottedCat simple game engine
Documentation
use super::{BackendSnapshot, MediaSource, VideoError, VideoState};
use objc2::encode::{Encode, Encoding, RefEncode};
use objc2::{
    class, msg_send,
    rc::{Allocated, Retained},
    runtime::AnyObject,
};
use objc2_foundation::{NSBundle, NSString, NSURL};
use std::ffi::c_void;
use std::path::Path;
use std::time::Duration;

#[repr(C)]
struct CVBuffer {
    _private: [u8; 0],
}

unsafe impl RefEncode for CVBuffer {
    const ENCODING_REF: Encoding = Encoding::Pointer(&Encoding::Struct("__CVBuffer", &[]));
}

#[repr(C)]
#[derive(Clone, Copy, Default)]
struct CMTime {
    value: i64,
    timescale: i32,
    flags: u32,
    epoch: i64,
}

unsafe impl Encode for CMTime {
    const ENCODING: Encoding = Encoding::Struct(
        "?",
        &[i64::ENCODING, i32::ENCODING, u32::ENCODING, i64::ENCODING],
    );
}

unsafe impl RefEncode for CMTime {
    const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING);
}

#[link(name = "AVFoundation", kind = "framework")]
unsafe extern "C" {}

#[link(name = "CoreMedia", kind = "framework")]
unsafe extern "C" {
    fn CMTimeGetSeconds(time: CMTime) -> f64;
    fn CMTimeMakeWithSeconds(seconds: f64, preferred_timescale: i32) -> CMTime;
}

#[link(name = "CoreVideo", kind = "framework")]
unsafe extern "C" {
    #[link_name = "kCVPixelBufferPixelFormatTypeKey"]
    static PIXEL_BUFFER_PIXEL_FORMAT_TYPE_KEY: *const AnyObject;
    fn CVPixelBufferLockBaseAddress(pixel_buffer: *mut CVBuffer, flags: u64) -> i32;
    fn CVPixelBufferUnlockBaseAddress(pixel_buffer: *mut CVBuffer, flags: u64) -> i32;
    fn CVPixelBufferGetBaseAddress(pixel_buffer: *mut CVBuffer) -> *mut c_void;
    fn CVPixelBufferGetBytesPerRow(pixel_buffer: *mut CVBuffer) -> usize;
    fn CVPixelBufferGetWidth(pixel_buffer: *mut CVBuffer) -> usize;
    fn CVPixelBufferGetHeight(pixel_buffer: *mut CVBuffer) -> usize;
    fn CVPixelBufferRelease(pixel_buffer: *mut CVBuffer);
}

pub(crate) struct AppleVideoBackend {
    player: Retained<AnyObject>,
    item: Retained<AnyObject>,
    output: Retained<AnyObject>,
    width: Option<u32>,
    height: Option<u32>,
    duration: Option<Duration>,
    play_requested: bool,
    looping: bool,
    muted: bool,
    volume: f32,
}

impl AppleVideoBackend {
    pub(crate) fn open(source: &MediaSource) -> Result<Self, VideoError> {
        let path = match source {
            MediaSource::Asset(asset) => asset.clone(),
            _ => source.uri(),
        };
        let url = match source {
            MediaSource::File(_) => NSURL::from_path(Path::new(&path), false, None),
            MediaSource::Url(_) => NSURL::URLWithString(&NSString::from_str(&path)),
            MediaSource::Asset(_) => bundle_asset_url(&path),
        }
        .ok_or_else(|| VideoError::Backend(format!("invalid Apple video URL: {path}")))?;

        unsafe {
            let asset: Option<Retained<AnyObject>> = msg_send![
                class!(AVURLAsset),
                URLAssetWithURL: &*url,
                options: std::ptr::null::<AnyObject>(),
            ];
            let asset = asset
                .ok_or_else(|| VideoError::Backend("failed to create AVURLAsset".to_string()))?;
            let item: Option<Retained<AnyObject>> =
                msg_send![class!(AVPlayerItem), playerItemWithAsset: &*asset];
            let item = item
                .ok_or_else(|| VideoError::Backend("failed to create AVPlayerItem".to_string()))?;
            let pixel_format: Option<Retained<AnyObject>> = msg_send![
                class!(NSNumber),
                numberWithUnsignedInt: u32::from_be_bytes(*b"BGRA"),
            ];
            let pixel_format = pixel_format.ok_or_else(|| {
                VideoError::Backend("failed to create Apple BGRA pixel format".to_string())
            })?;
            let pixel_attributes: Option<Retained<AnyObject>> = msg_send![
                class!(NSDictionary),
                dictionaryWithObject: &*pixel_format,
                forKey: PIXEL_BUFFER_PIXEL_FORMAT_TYPE_KEY,
            ];
            let pixel_attributes = pixel_attributes.ok_or_else(|| {
                VideoError::Backend("failed to create Apple pixel buffer attributes".to_string())
            })?;
            let output_alloc: Allocated<AnyObject> =
                msg_send![class!(AVPlayerItemVideoOutput), alloc];
            let output: Option<Retained<AnyObject>> = msg_send![
                output_alloc,
                initWithPixelBufferAttributes: &*pixel_attributes,
            ];
            let output = output.ok_or_else(|| {
                VideoError::Backend("failed to initialize AVPlayerItemVideoOutput".to_string())
            })?;
            let _: () = msg_send![
                &*item,
                addOutput: &*output,
            ];
            let player: Option<Retained<AnyObject>> = msg_send![
                class!(AVPlayer),
                playerWithPlayerItem: &*item,
            ];
            let player = player
                .ok_or_else(|| VideoError::Backend("failed to create AVPlayer".to_string()))?;
            let _: () = msg_send![&*player, setMuted: false];
            let _: () = msg_send![&*player, setVolume: 1.0_f32];

            Ok(Self {
                player,
                item,
                output,
                width: None,
                height: None,
                duration: None,
                play_requested: false,
                looping: false,
                muted: false,
                volume: 1.0,
            })
        }
    }

    pub(crate) fn close(&mut self) {
        unsafe {
            let _: () = msg_send![&*self.player, cancelPendingPrerolls];
            let _: () = msg_send![&*self.player, pause];
        }
    }

    pub(crate) fn update(&mut self) -> Result<BackendSnapshot, VideoError> {
        unsafe {
            let item_status: isize = msg_send![&*self.item, status];
            if item_status == 2 {
                return Err(VideoError::Backend(
                    "Apple video item failed to become ready".to_string(),
                ));
            }
            let duration_time: CMTime = msg_send![&*self.item, duration];
            let duration_seconds = CMTimeGetSeconds(duration_time);
            if duration_seconds.is_finite() && duration_seconds > 0.0 {
                self.duration = Some(Duration::from_secs_f64(duration_seconds));
            }
            let time: CMTime = msg_send![&*self.player, currentTime];
            let position_seconds = CMTimeGetSeconds(time).max(0.0);
            let mut rate: f32 = msg_send![&*self.player, rate];
            let mut rgba = None;

            let has_new_frame: bool = msg_send![&*self.output, hasNewPixelBufferForItemTime: time];
            if has_new_frame {
                let mut display_time = CMTime::default();
                let pixel_buffer: *mut CVBuffer = msg_send![
                    &*self.output,
                    copyPixelBufferForItemTime: time,
                    itemTimeForDisplay: &mut display_time,
                ];
                if !pixel_buffer.is_null() {
                    let (frame, width, height) = copy_pixel_buffer(pixel_buffer)?;
                    CVPixelBufferRelease(pixel_buffer);
                    self.width = Some(width);
                    self.height = Some(height);
                    rgba = Some(frame);
                }
            }

            let ended = self
                .duration
                .map(|duration| {
                    position_seconds >= duration.as_secs_f64().max(0.001) - 0.08 && rate == 0.0
                })
                .unwrap_or(false);
            if self.play_requested && item_status == 1 && rate == 0.0 && !ended {
                let _: () = msg_send![&*self.player, play];
                rate = msg_send![&*self.player, rate];
            }
            if ended && self.looping {
                let zero = CMTimeMakeWithSeconds(0.0, 600);
                let _: () = msg_send![&*self.player, seekToTime: zero];
                let _: () = msg_send![&*self.player, play];
            }

            let state = if ended && !self.looping {
                VideoState::Ended
            } else if rate > 0.0 || (self.play_requested && item_status == 1) {
                VideoState::Playing
            } else if item_status == 0 {
                VideoState::Opening
            } else if position_seconds > 0.0 {
                VideoState::Paused
            } else {
                VideoState::Ready
            };

            Ok(BackendSnapshot {
                state,
                duration: self.duration,
                position: Duration::from_secs_f64(position_seconds),
                dimensions: self.width.zip(self.height),
                rgba,
                #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
                external_video: None,
            })
        }
    }

    pub(crate) fn play(&mut self) -> Result<(), VideoError> {
        self.play_requested = true;
        unsafe {
            let _: () = msg_send![&*self.player, setMuted: self.muted];
            let effective_volume = if self.muted { 0.0 } else { self.volume };
            let _: () = msg_send![&*self.player, setVolume: effective_volume];
            let item_status: isize = msg_send![&*self.item, status];
            if item_status == 1 {
                let _: () = msg_send![&*self.player, play];
            }
        }
        Ok(())
    }

    pub(crate) fn pause(&mut self) -> Result<(), VideoError> {
        self.play_requested = false;
        unsafe {
            let _: () = msg_send![&*self.player, pause];
        }
        Ok(())
    }

    pub(crate) fn stop(&mut self) -> Result<(), VideoError> {
        self.pause()?;
        self.seek(Duration::ZERO)
    }

    pub(crate) fn seek(&mut self, position: Duration) -> Result<(), VideoError> {
        unsafe {
            let time = CMTimeMakeWithSeconds(position.as_secs_f64(), 600);
            let _: () = msg_send![&*self.player, seekToTime: time];
        }
        Ok(())
    }

    pub(crate) fn set_loop(&mut self, looping: bool) -> Result<(), VideoError> {
        self.looping = looping;
        Ok(())
    }

    pub(crate) fn set_muted(&mut self, muted: bool) -> Result<(), VideoError> {
        self.muted = muted;
        self.set_volume(self.volume)
    }

    pub(crate) fn set_volume(&mut self, volume: f32) -> Result<(), VideoError> {
        self.volume = volume.clamp(0.0, 1.0);
        let effective = if self.muted { 0.0 } else { self.volume };
        unsafe {
            let _: () = msg_send![&*self.player, setVolume: effective];
        }
        Ok(())
    }

    pub(crate) fn set_playback_rate(&mut self, rate: f64) -> Result<(), VideoError> {
        unsafe {
            let _: () = msg_send![&*self.player, setRate: rate as f32];
        }
        Ok(())
    }
}

fn bundle_asset_url(path: &str) -> Option<Retained<NSURL>> {
    let path = path.strip_prefix("./").unwrap_or(path);
    let path = path.strip_prefix("assets/").unwrap_or(path);
    let resource_path = Path::new(path);
    let name = resource_path.file_stem()?.to_str()?;
    let extension = resource_path.extension().and_then(|value| value.to_str());
    let name = NSString::from_str(name);
    let extension = extension.map(NSString::from_str);
    let subdirectory = resource_path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .and_then(|parent| parent.to_str())
        .map(NSString::from_str);

    let bundle = NSBundle::mainBundle();
    bundle.URLForResource_withExtension_subdirectory(
        Some(&name),
        extension.as_deref(),
        subdirectory.as_deref(),
    )
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe fn copy_pixel_buffer(
    pixel_buffer: *mut CVBuffer,
) -> Result<(Vec<u8>, u32, u32), VideoError> {
    if CVPixelBufferLockBaseAddress(pixel_buffer, 0) != 0 {
        return Err(VideoError::Backend(
            "failed to lock Apple video pixel buffer".to_string(),
        ));
    }

    let width = CVPixelBufferGetWidth(pixel_buffer);
    let height = CVPixelBufferGetHeight(pixel_buffer);
    let stride = CVPixelBufferGetBytesPerRow(pixel_buffer);
    let base = CVPixelBufferGetBaseAddress(pixel_buffer).cast::<u8>();
    if base.is_null() || width == 0 || height == 0 || stride < width * 4 {
        let _ = CVPixelBufferUnlockBaseAddress(pixel_buffer, 0);
        return Err(VideoError::Backend(
            "Apple video pixel buffer has an unsupported layout".to_string(),
        ));
    }

    let mut rgba = vec![0; width * height * 4];
    for row in 0..height {
        let src = std::slice::from_raw_parts(base.add(row * stride), width * 4);
        let dst = &mut rgba[row * width * 4..(row + 1) * width * 4];
        for (source, target) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) {
            target.copy_from_slice(&[source[2], source[1], source[0], source[3]]);
        }
    }
    let _ = CVPixelBufferUnlockBaseAddress(pixel_buffer, 0);
    Ok((rgba, width as u32, height as u32))
}