mod battery_observer;
mod displays;
mod loop_observer;
mod screen_observer;
use std::collections::HashMap;
use std::path::Path;
use anyhow::Context;
use objc2::rc::Retained;
use objc2::sel;
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy,
NSApplicationDidChangeScreenParametersNotification, NSBackingStoreType, NSColor, NSScreen,
NSView, NSWindow, NSWindowCollectionBehavior, NSWindowSharingType, NSWindowStyleMask,
};
use objc2_av_foundation::{
AVLayerVideoGravity, AVLayerVideoGravityResize, AVLayerVideoGravityResizeAspect,
AVLayerVideoGravityResizeAspectFill, AVPlayer, AVPlayerItem,
AVPlayerItemDidPlayToEndTimeNotification, AVPlayerLayer,
};
use objc2_foundation::{MainThreadMarker, NSNotificationCenter, NSString, NSURL};
use objc2_quartz_core::CAAutoresizingMask;
use self::battery_observer::BatteryObserver;
use self::loop_observer::LoopObserver;
use self::screen_observer::{AttachPolicy, MirrorSurface, ScreenObserver};
use super::{Backend, PauseMode, RunOptions};
use crate::displays::DisplayInfo;
use crate::plan::Playback;
use crate::scale::ScaleMode;
const WALLPAPER_LEVEL: isize = -2_147_483_624;
pub struct MacosBackend {
mtm: MainThreadMarker,
}
impl MacosBackend {
pub fn new() -> anyhow::Result<Self> {
let mtm = MainThreadMarker::new()
.context("macOS backend must be constructed on the main thread")?;
Ok(Self { mtm })
}
}
impl Backend for MacosBackend {
fn list_displays() -> anyhow::Result<Vec<DisplayInfo>> {
displays::list_displays()
}
fn run(self, playback: Playback, options: RunOptions) -> anyhow::Result<()> {
let mtm = self.mtm;
let scale = options.scale;
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
app.finishLaunching();
let screens = NSScreen::screens(mtm);
let mut surfaces: Vec<MirrorSurface> = Vec::new();
let mut players: Vec<Retained<AVPlayer>> = Vec::new();
let mut loop_observers: Vec<Retained<LoopObserver>> = Vec::new();
let policy = match playback {
Playback::Mirror(path) => {
let (item, player) = make_player(mtm, &path)?;
loop_observers.push(install_loop_observer(player.clone(), &item));
players.push(player.clone());
for screen in screens.iter() {
surfaces.push(build_surface(mtm, &screen, &player, scale)?);
}
if surfaces.is_empty() {
anyhow::bail!("no screens detected");
}
AttachPolicy::Mirror(player)
}
Playback::PerDisplay(assignments) => {
let mut by_id: HashMap<String, Retained<AVPlayer>> = HashMap::new();
for a in &assignments {
let (item, player) = make_player(mtm, &a.path)?;
loop_observers.push(install_loop_observer(player.clone(), &item));
players.push(player.clone());
by_id.insert(a.native_id.clone(), player);
}
for screen in screens.iter() {
let name = screen.localizedName().to_string();
if let Some(player) = by_id.get(&name) {
surfaces.push(build_surface(mtm, &screen, player, scale)?);
} else {
log::info!("display {name:?} not in assignments; leaving alone");
}
}
AttachPolicy::PerDisplay(by_id)
}
};
let screen_observer = ScreenObserver::new(surfaces, policy, scale);
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
&screen_observer,
sel!(screensChanged:),
Some(NSApplicationDidChangeScreenParametersNotification),
None,
);
}
let battery_observer = if matches!(options.pause, PauseMode::Never) {
None
} else {
BatteryObserver::install(players.clone(), options.pause)
};
if battery_observer.is_none() {
for player in &players {
unsafe { player.play() };
}
}
log::info!("macOS backend ready at level {}", WALLPAPER_LEVEL);
app.run();
drop(loop_observers);
drop(screen_observer);
drop(battery_observer);
Ok(())
}
}
fn make_player(
mtm: MainThreadMarker,
video_path: &str,
) -> anyhow::Result<(Retained<AVPlayerItem>, Retained<AVPlayer>)> {
let abs = Path::new(video_path)
.canonicalize()
.unwrap_or_else(|_| Path::new(video_path).to_path_buf());
let path_ns = NSString::from_str(&abs.to_string_lossy());
let url = NSURL::fileURLWithPath(&path_ns);
let item = unsafe { AVPlayerItem::playerItemWithURL(&url, mtm) };
let player = unsafe { AVPlayer::playerWithPlayerItem(Some(&item), mtm) };
unsafe {
player.setMuted(true);
}
Ok((item, player))
}
fn install_loop_observer(
player: Retained<AVPlayer>,
item: &AVPlayerItem,
) -> Retained<LoopObserver> {
let observer = LoopObserver::new(player);
let item_obj: &objc2::runtime::AnyObject = item;
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
&observer,
sel!(itemEnded:),
Some(AVPlayerItemDidPlayToEndTimeNotification),
Some(item_obj),
);
}
observer
}
pub(super) fn build_surface(
mtm: MainThreadMarker,
screen: &NSScreen,
player: &AVPlayer,
scale: ScaleMode,
) -> anyhow::Result<MirrorSurface> {
let name = screen.localizedName().to_string();
let frame = screen.frame();
let backing_scale = screen.backingScaleFactor();
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc::<NSWindow>(),
frame,
NSWindowStyleMask::Borderless,
NSBackingStoreType::Buffered,
false,
)
};
window.setLevel(WALLPAPER_LEVEL);
window.setCollectionBehavior(
NSWindowCollectionBehavior::CanJoinAllSpaces
| NSWindowCollectionBehavior::FullScreenAuxiliary
| NSWindowCollectionBehavior::Stationary
| NSWindowCollectionBehavior::IgnoresCycle,
);
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setHasShadow(false);
window.setSharingType(NSWindowSharingType::ReadOnly);
window.setIgnoresMouseEvents(true);
let content_view = NSView::initWithFrame(mtm.alloc::<NSView>(), frame);
content_view.setWantsLayer(true);
window.setContentView(Some(&content_view));
let player_layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(player)) };
if let Some(gravity) = video_gravity_for(scale) {
unsafe { player_layer.setVideoGravity(gravity) };
}
player_layer.setFrame(content_view.bounds());
player_layer.setAutoresizingMask(
CAAutoresizingMask::LayerWidthSizable | CAAutoresizingMask::LayerHeightSizable,
);
player_layer.setContentsScale(backing_scale);
let root_layer = content_view
.layer()
.context("content view has no root layer")?;
root_layer.addSublayer(&player_layer);
window.makeKeyAndOrderFront(None);
log::info!(
"surface ready on {}: {}x{} @ {}x",
name,
frame.size.width as u32,
frame.size.height as u32,
backing_scale,
);
Ok(MirrorSurface {
name,
window,
layer: player_layer,
})
}
fn video_gravity_for(scale: ScaleMode) -> Option<&'static AVLayerVideoGravity> {
unsafe {
match scale {
ScaleMode::Stretch => AVLayerVideoGravityResize,
ScaleMode::Fit => AVLayerVideoGravityResizeAspect,
ScaleMode::Fill | ScaleMode::Center => AVLayerVideoGravityResizeAspectFill,
}
}
}