#![allow(unexpected_cfgs)]
use fission_shell::{VideoBackend, VideoEvent, VideoPlayer};
#[cfg(target_os = "macos")]
pub use mac::MacVideoBackend;
#[cfg(not(target_os = "macos"))]
pub use mock::MockVideoBackend;
#[cfg(target_os = "macos")]
#[allow(unexpected_cfgs)]
mod mac {
use super::{VideoBackend, VideoEvent, VideoPlayer};
use cocoa::appkit::NSWindowOrderingMode;
use cocoa::base::{id, nil, YES};
use cocoa::foundation::{NSString, NSURL};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use fission_ir::WidgetNodeId;
use fission_render::LayoutRect;
use fission_shell::VideoSurfaceFrame;
use objc::rc::StrongPtr;
use objc::{class, msg_send, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use winit::window::Window;
#[derive(Clone)]
struct RetainedId(StrongPtr);
unsafe impl Send for RetainedId {}
unsafe impl Sync for RetainedId {}
impl RetainedId {
unsafe fn new(ptr: id) -> Self {
Self(StrongPtr::retain(ptr))
}
unsafe fn owned(ptr: id) -> Self {
Self(StrongPtr::new(ptr))
}
fn as_id(&self) -> id {
*self.0
}
}
impl From<StrongPtr> for RetainedId {
fn from(value: StrongPtr) -> Self {
Self(value)
}
}
struct LayerContext {
parent_view: id,
scale_factor: f64,
bounds_height: f64,
}
pub struct MacVideoBackend {
view: RetainedId,
layers: Mutex<HashMap<WidgetNodeId, VideoLayer>>,
registry: Arc<PlayerRegistry>,
}
impl MacVideoBackend {
pub fn new(window: &Window) -> Self {
let ns_view = ns_view_from_window(window);
Self {
view: unsafe { RetainedId::new(ns_view) },
layers: Mutex::new(HashMap::new()),
registry: Arc::new(PlayerRegistry::new()),
}
}
fn ensure_layer_backing(&self) -> LayerContext {
unsafe {
let view = self.view.as_id();
let wants_layer: bool = msg_send![view, wantsLayer];
if !wants_layer {
let () = msg_send![view, setWantsLayer: YES];
}
let mut layer: id = msg_send![view, layer];
if layer == nil {
layer = msg_send![class!(CALayer), layer];
let () = msg_send![view, setLayer: layer];
}
let window: id = msg_send![view, window];
let scale: f64 = if window != nil {
msg_send![window, backingScaleFactor]
} else {
1.0
};
let () = msg_send![layer, setContentsScale: scale];
let bounds: CGRect = msg_send![view, bounds];
LayerContext {
parent_view: view,
scale_factor: if scale == 0.0 { 1.0 } else { scale },
bounds_height: bounds.size.height,
}
}
}
fn update_video_layer(
&self,
layer_map: &mut HashMap<WidgetNodeId, VideoLayer>,
frame: &VideoSurfaceFrame,
ctx: &LayerContext,
) {
if let Some(player) = self.registry.get(frame.surface_id) {
let widget_id = frame.widget_id;
let entry = layer_map
.entry(widget_id)
.or_insert_with(|| VideoLayer::new(widget_id, &player, ctx));
entry.update(&player, ctx, frame.rect);
}
}
}
fn ns_view_from_window(window: &Window) -> id {
let handle = window
.window_handle()
.expect("window handle unavailable on macOS");
match handle.as_raw() {
RawWindowHandle::AppKit(handle) => handle.ns_view.as_ptr() as id,
other => panic!("expected AppKit window handle, got {other:?}"),
}
}
impl VideoBackend for MacVideoBackend {
fn create_player(&self, source: &str) -> Box<dyn VideoPlayer> {
let resolved_source = resolve_video_source(source);
let pending_error = resolved_source.error_message();
let player = unsafe {
create_av_player(
resolved_source
.resolved_path
.as_deref()
.unwrap_or_else(|| Path::new(source)),
)
};
let player_id = self.registry.register(player);
Box::new(MacVideoPlayer {
registry: Arc::clone(&self.registry),
player_id,
ready_sent: false,
error_sent: false,
pending_error,
})
}
fn present_surfaces(&self, frames: &[VideoSurfaceFrame]) {
let mut layers = self.layers.lock().unwrap();
if frames.is_empty() {
for layer in layers.values() {
unsafe {
layer.detach();
}
}
layers.clear();
return;
}
let ctx = self.ensure_layer_backing();
let mut seen = HashSet::new();
for frame in frames {
seen.insert(frame.widget_id);
self.update_video_layer(&mut layers, frame, &ctx);
}
layers.retain(|widget_id, layer| {
if seen.contains(widget_id) {
true
} else {
unsafe {
layer.detach();
}
false
}
});
}
}
impl Drop for MacVideoBackend {
fn drop(&mut self) {
if let Ok(mut layers) = self.layers.lock() {
for layer in layers.values() {
unsafe {
layer.detach();
}
}
layers.clear();
}
}
}
struct VideoLayer {
view: RetainedId,
layer: RetainedId,
}
impl VideoLayer {
fn new(_widget_id: WidgetNodeId, player: &RetainedId, ctx: &LayerContext) -> Self {
unsafe {
let frame = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(1.0, 1.0));
let view_alloc: id = msg_send![class!(NSView), alloc];
let view: id = msg_send![view_alloc, initWithFrame: frame];
let () = msg_send![view, setWantsLayer: YES];
let layer: id =
msg_send![class!(AVPlayerLayer), playerLayerWithPlayer: player.as_id()];
let gravity = NSString::alloc(nil).init_str("AVLayerVideoGravityResizeAspect");
let () = msg_send![layer, setVideoGravity: gravity];
let () = msg_send![layer, setMasksToBounds: YES];
let () = msg_send![layer, setContentsScale: ctx.scale_factor];
let () = msg_send![layer, setZPosition: 1.0f64];
let () = msg_send![view, setLayer: layer];
let () = msg_send![
ctx.parent_view,
addSubview: view
positioned: NSWindowOrderingMode::NSWindowAbove
relativeTo: nil
];
Self {
view: RetainedId::owned(view),
layer: RetainedId::new(layer),
}
}
}
fn update(&mut self, player: &RetainedId, ctx: &LayerContext, rect: LayoutRect) {
unsafe {
let layer_id = self.layer.as_id();
let () = msg_send![layer_id, setContentsScale: ctx.scale_factor];
let () = msg_send![layer_id, setPlayer: player.as_id()];
let cg_rect = cg_rect_from_layout(rect, ctx);
let view_id = self.view.as_id();
let () = msg_send![view_id, setFrame: cg_rect];
let () = msg_send![
ctx.parent_view,
addSubview: view_id
positioned: NSWindowOrderingMode::NSWindowAbove
relativeTo: nil
];
}
}
unsafe fn detach(&self) {
let layer_id = self.layer.as_id();
let () = msg_send![layer_id, setPlayer: nil];
let () = msg_send![self.view.as_id(), removeFromSuperview];
}
}
struct PlayerRegistry {
next_id: AtomicU64,
map: Mutex<HashMap<u64, RetainedId>>,
}
impl PlayerRegistry {
fn new() -> Self {
Self {
next_id: AtomicU64::new(1),
map: Mutex::new(HashMap::new()),
}
}
fn register(&self, player: StrongPtr) -> u64 {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.map
.lock()
.unwrap()
.insert(id, RetainedId::from(player));
id
}
fn unregister(&self, id_val: u64) {
self.map.lock().unwrap().remove(&id_val);
}
fn get(&self, id_val: u64) -> Option<RetainedId> {
self.map.lock().unwrap().get(&id_val).cloned()
}
}
pub struct MacVideoPlayer {
registry: Arc<PlayerRegistry>,
player_id: u64,
ready_sent: bool,
error_sent: bool,
pending_error: Option<String>,
}
impl Drop for MacVideoPlayer {
fn drop(&mut self) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), pause];
let () = msg_send![player.as_id(), setRate: 0.0f32];
}
}
self.registry.unregister(self.player_id);
}
}
impl VideoPlayer for MacVideoPlayer {
fn play(&mut self) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), play];
}
}
}
fn pause(&mut self) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), pause];
}
}
}
fn stop(&mut self) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), pause];
seek_to_ms(player.as_id(), 0);
}
}
}
fn position(&self) -> u64 {
if let Some(player) = self.registry.get(self.player_id) {
if let Some(ms) = unsafe { current_time_ms(player.as_id()) } {
return ms;
}
}
0
}
fn duration(&self) -> Option<u64> {
if let Some(player) = self.registry.get(self.player_id) {
unsafe { item_duration_ms(player.as_id()) }
} else {
None
}
}
fn surface_id(&self) -> u64 {
self.player_id
}
fn poll_events(&mut self) -> Vec<VideoEvent> {
if !self.error_sent {
if let Some(message) = self.pending_error.take() {
self.error_sent = true;
return vec![VideoEvent::Error(message)];
}
}
if !self.ready_sent {
self.ready_sent = true;
let duration = self
.registry
.get(self.player_id)
.and_then(|player| unsafe { item_duration_ms(player.as_id()) })
.unwrap_or(0);
vec![VideoEvent::Ready { duration }]
} else {
Vec::new()
}
}
fn seek_to(&mut self, position_ms: u64) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
seek_to_ms(player.as_id(), position_ms);
}
}
}
fn set_rate(&mut self, rate: f32) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), setRate: rate];
}
}
}
fn set_volume(&mut self, volume: f32) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), setVolume: volume];
}
}
}
fn set_muted(&mut self, muted: bool) {
if let Some(player) = self.registry.get(self.player_id) {
unsafe {
let () = msg_send![player.as_id(), setMuted: muted];
}
}
}
}
unsafe fn create_av_player(source_path: &Path) -> StrongPtr {
let url = file_url_from_path(source_path);
let player: id = msg_send![class!(AVPlayer), playerWithURL: url];
StrongPtr::retain(player)
}
fn file_url_from_path(path: &Path) -> id {
unsafe {
let ns_string = NSString::alloc(nil).init_str(path.to_string_lossy().as_ref());
NSURL::fileURLWithPath_(nil, ns_string)
}
}
struct ResolvedVideoSource {
requested: String,
resolved_path: Option<PathBuf>,
diagnostic: Option<String>,
}
impl ResolvedVideoSource {
fn error_message(&self) -> Option<String> {
self.diagnostic.as_ref().map(|diagnostic| {
if let Some(path) = self.resolved_path.as_ref() {
format!(
"{diagnostic} (requested='{}', resolved='{}')",
self.requested,
path.display()
)
} else {
format!("{diagnostic} (requested='{}')", self.requested)
}
})
}
}
fn resolve_video_source(source: &str) -> ResolvedVideoSource {
let requested = source.to_string();
let trimmed = source.trim();
if trimmed.is_empty() {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some("video source path is empty".to_string()),
};
}
if trimmed.contains("://") {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some(
"video backend only supports local filesystem paths for media sources"
.to_string(),
),
};
}
let candidate = if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
match std::env::current_dir() {
Ok(current_dir) => current_dir.join(trimmed),
Err(error) => {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some(format!(
"failed to resolve relative video source against current directory: {error}"
)),
};
}
}
};
let resolved_path = candidate
.canonicalize()
.ok()
.filter(|path| path.exists())
.unwrap_or(candidate);
let diagnostic = if resolved_path.exists() {
None
} else {
Some("video source path does not exist".to_string())
};
ResolvedVideoSource {
requested,
resolved_path: Some(resolved_path),
diagnostic,
}
}
unsafe fn current_time_ms(player: id) -> Option<u64> {
let current: CMTime = msg_send![player, currentTime];
current.to_millis()
}
unsafe fn item_duration_ms(player: id) -> Option<u64> {
let item: id = msg_send![player, currentItem];
if item == nil {
return None;
}
let duration: CMTime = msg_send![item, duration];
duration.to_millis()
}
unsafe fn seek_to_ms(player: id, position_ms: u64) {
let time = CMTime::from_millis(position_ms);
let zero_a = CMTime::zero();
let zero_b = CMTime::zero();
let () = msg_send![player, seekToTime: time toleranceBefore: zero_a toleranceAfter: zero_b];
}
fn cg_rect_from_layout(rect: LayoutRect, ctx: &LayerContext) -> CGRect {
let width = rect.size.width as f64;
let height = rect.size.height as f64;
let x = rect.origin.x as f64;
let y = rect.origin.y as f64;
let flipped_y = ctx.bounds_height - height - y;
CGRect::new(&CGPoint::new(x, flipped_y), &CGSize::new(width, height))
}
#[repr(C)]
struct CMTime {
value: i64,
timescale: i32,
flags: i32,
epoch: i64,
}
impl CMTime {
fn zero() -> Self {
Self {
value: 0,
timescale: 1,
flags: 1, epoch: 0,
}
}
fn from_millis(ms: u64) -> Self {
Self {
value: ms as i64,
timescale: 1000,
flags: 1, epoch: 0,
}
}
fn to_millis(&self) -> Option<u64> {
if self.timescale <= 0 {
return None;
}
let seconds = self.value as f64 / self.timescale as f64;
Some((seconds * 1000.0) as u64)
}
}
}
#[cfg(not(target_os = "macos"))]
mod mock {
use super::{VideoBackend, VideoEvent, VideoPlayer};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;
pub struct MockVideoBackend;
impl MockVideoBackend {
pub fn new() -> Self {
Self
}
}
impl VideoBackend for MockVideoBackend {
fn create_player(&self, source: &str) -> Box<dyn VideoPlayer> {
Box::new(MockPlayer::new(source))
}
fn present_surfaces(&self, _frames: &[fission_shell::VideoSurfaceFrame]) {}
}
static NEXT_SURFACE_ID: AtomicU64 = AtomicU64::new(1);
struct MockPlayer {
_source: String,
state: PlayerState,
start_time: Instant,
play_start_time: Option<Instant>,
accumulated_play_time: u64,
surface_id: u64,
duration: u64,
sent_ready: bool,
sent_ended: bool,
playback_rate: f32,
volume: f32,
muted: bool,
error_sent: bool,
pending_error: Option<String>,
}
#[derive(PartialEq)]
enum PlayerState {
Loading,
Ready,
Playing,
Paused,
Ended,
}
impl MockPlayer {
fn new(source: &str) -> Self {
let resolved_source = resolve_video_source(source);
let pending_error = resolved_source.error_message();
Self {
_source: resolved_source
.resolved_path
.as_ref()
.map(|path| path.to_string_lossy().to_string())
.unwrap_or_else(|| source.to_string()),
state: if pending_error.is_some() {
PlayerState::Ready
} else {
PlayerState::Loading
},
start_time: Instant::now(),
play_start_time: None,
accumulated_play_time: 0,
surface_id: NEXT_SURFACE_ID.fetch_add(1, Ordering::Relaxed),
duration: 5000,
sent_ready: false,
sent_ended: false,
playback_rate: 1.0,
volume: 1.0,
muted: false,
error_sent: false,
pending_error,
}
}
fn current_elapsed_ms(&self) -> u64 {
if let (PlayerState::Playing, Some(start)) = (&self.state, self.play_start_time) {
let elapsed = start.elapsed().as_millis() as f64;
(elapsed * self.playback_rate as f64) as u64
} else {
0
}
}
}
impl VideoPlayer for MockPlayer {
fn play(&mut self) {
if self.state == PlayerState::Ready || self.state == PlayerState::Paused {
self.state = PlayerState::Playing;
self.play_start_time = Some(Instant::now());
} else if self.state == PlayerState::Ended {
self.state = PlayerState::Playing;
self.accumulated_play_time = 0;
self.play_start_time = Some(Instant::now());
}
}
fn pause(&mut self) {
if self.state == PlayerState::Playing {
self.accumulated_play_time += self.current_elapsed_ms();
self.state = PlayerState::Paused;
self.play_start_time = None;
}
}
fn stop(&mut self) {
self.state = PlayerState::Ready;
self.play_start_time = None;
self.accumulated_play_time = 0;
self.sent_ended = false;
}
fn position(&self) -> u64 {
(self.accumulated_play_time + self.current_elapsed_ms()).min(self.duration)
}
fn duration(&self) -> Option<u64> {
Some(self.duration)
}
fn surface_id(&self) -> u64 {
self.surface_id
}
fn poll_events(&mut self) -> Vec<VideoEvent> {
let mut events = Vec::new();
if !self.error_sent {
if let Some(message) = self.pending_error.take() {
self.error_sent = true;
self.sent_ready = true;
events.push(VideoEvent::Error(message));
return events;
}
}
let elapsed = self.start_time.elapsed().as_millis() as u64;
if !self.sent_ready && elapsed > 500 {
if self.state == PlayerState::Loading {
self.state = PlayerState::Ready;
}
self.sent_ready = true;
events.push(VideoEvent::Ready {
duration: self.duration,
});
}
if self.state == PlayerState::Playing && self.position() >= self.duration {
self.state = PlayerState::Ended;
self.play_start_time = None;
if !self.sent_ended {
self.sent_ended = true;
events.push(VideoEvent::Ended);
}
}
events
}
fn seek_to(&mut self, position_ms: u64) {
let clamped = position_ms.min(self.duration);
self.accumulated_play_time = clamped;
if self.state == PlayerState::Playing {
self.play_start_time = Some(Instant::now());
} else {
self.play_start_time = None;
}
self.sent_ended = false;
}
fn set_rate(&mut self, rate: f32) {
let new_rate = rate.max(0.1);
if self.state == PlayerState::Playing {
self.accumulated_play_time = self.position();
self.play_start_time = Some(Instant::now());
}
self.playback_rate = new_rate;
}
fn set_volume(&mut self, volume: f32) {
self.volume = volume.clamp(0.0, 1.0);
if self.volume == 0.0 {
self.muted = true;
}
}
fn set_muted(&mut self, muted: bool) {
self.muted = muted;
}
}
struct ResolvedVideoSource {
requested: String,
resolved_path: Option<PathBuf>,
diagnostic: Option<String>,
}
impl ResolvedVideoSource {
fn error_message(&self) -> Option<String> {
self.diagnostic.as_ref().map(|diagnostic| {
if let Some(path) = self.resolved_path.as_ref() {
format!(
"{diagnostic} (requested='{}', resolved='{}')",
self.requested,
path.display()
)
} else {
format!("{diagnostic} (requested='{}')", self.requested)
}
})
}
}
fn resolve_video_source(source: &str) -> ResolvedVideoSource {
let requested = source.to_string();
let trimmed = source.trim();
if trimmed.is_empty() {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some("video source path is empty".to_string()),
};
}
if trimmed.contains("://") {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some(
"video backend only supports local filesystem paths for media sources"
.to_string(),
),
};
}
let candidate = if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
match std::env::current_dir() {
Ok(current_dir) => current_dir.join(trimmed),
Err(error) => {
return ResolvedVideoSource {
requested,
resolved_path: None,
diagnostic: Some(format!(
"failed to resolve relative video source against current directory: {error}"
)),
};
}
}
};
let resolved_path = candidate
.canonicalize()
.ok()
.filter(|path| path.exists())
.unwrap_or(candidate);
let diagnostic = if resolved_path.exists() {
None
} else {
Some("video source path does not exist".to_string())
};
ResolvedVideoSource {
requested,
resolved_path: Some(resolved_path),
diagnostic,
}
}
}