Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 71 additions & 54 deletions src/backend/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod screen_observer;
use std::path::Path;

use anyhow::Context;
use objc2::rc::Retained;
use objc2::sel;
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy,
Expand Down Expand Up @@ -49,39 +50,6 @@ impl Backend for MacosBackend {
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
app.finishLaunching();

let screen = NSScreen::mainScreen(mtm).context("no main screen")?;
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);
// `ReadOnly` so screen-capture / screen-sharing can read us. `None` blocks them.
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));

// AVAsset resolves relative paths against the process cwd, which isn't
// what users expect from `phonto ./video.mp4`.
let abs = Path::new(&video_path)
.canonicalize()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original comment about why we canonicalize got dropped in this refactor. It explained that AVAsset resolves relative paths against the process cwd, which isn't what users expect from phonto ./video.mp4. Worth restoring.

.unwrap_or_else(|_| Path::new(&video_path).to_path_buf());
Expand All @@ -94,23 +62,22 @@ impl Backend for MacosBackend {
player.setMuted(true);
}

let player_layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(&player)) };
if let Some(gravity) = video_gravity_for(scale) {
unsafe { player_layer.setVideoGravity(gravity) };
let screens = NSScreen::screens(mtm);
let screen_count = screens.count();
if screen_count == 0 {
return Err(anyhow::anyhow!("no screens found"));
}
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);
let gravity = video_gravity_for(scale);
let mut windows = Vec::with_capacity(screen_count);
let mut player_layers = Vec::with_capacity(screen_count);
for i in 0..screen_count {
let screen = screens.objectAtIndex(i);
let (window, layer) = build_wallpaper_window(mtm, &screen, &player, gravity)?;
windows.push(window);
player_layers.push(layer);
}

// Loop: AVPlayer posts AVPlayerItemDidPlayToEndTimeNotification when the
// item reaches its end. The observer seeks back to zero and resumes.
let loop_observer = LoopObserver::new(player.clone());
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
Expand All @@ -121,8 +88,7 @@ impl Backend for MacosBackend {
);
}

// Re-apply geometry on display reconfiguration.
let screen_observer = ScreenObserver::new(window.clone(), player_layer.clone());
let screen_observer = ScreenObserver::new(windows, player_layers);
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
&screen_observer,
Expand All @@ -132,8 +98,6 @@ impl Backend for MacosBackend {
);
}

window.makeKeyAndOrderFront(None);

let battery_observer = if matches!(options.pause, PauseMode::Never) {
None
} else {
Expand All @@ -144,9 +108,8 @@ impl Backend for MacosBackend {
}

log::info!(
"macOS backend ready: {}x{} window at level {}",
frame.size.width as u32,
frame.size.height as u32,
"macOS backend ready: {} screen(s) at level {}",
screen_count,
WALLPAPER_LEVEL,
);

Expand All @@ -170,3 +133,57 @@ fn video_gravity_for(scale: ScaleMode) -> Option<&'static AVLayerVideoGravity> {
}
}
}

fn build_wallpaper_window(
mtm: MainThreadMarker,
screen: &NSScreen,
player: &AVPlayer,
gravity: Option<&'static AVLayerVideoGravity>,
) -> anyhow::Result<(Retained<NSWindow>, Retained<AVPlayerLayer>)> {
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original comment about why we use ReadOnly rather than None got dropped. It noted that ReadOnly lets screen capture and screen sharing still read us, while None blocks them. Worth restoring.

window.setIgnoresMouseEvents(true);

let content_view = NSView::initWithFrame(mtm.alloc::<NSView>(), frame);
content_view.setWantsLayer(true);
window.setContentView(Some(&content_view));

let layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(player)) };
if let Some(gravity) = gravity {
unsafe { layer.setVideoGravity(gravity) };
}
layer.setFrame(content_view.bounds());
layer.setAutoresizingMask(
CAAutoresizingMask::LayerWidthSizable | CAAutoresizingMask::LayerHeightSizable,
);
layer.setContentsScale(backing_scale);

let root_layer = content_view
.layer()
.context("content view has no root layer")?;
root_layer.addSublayer(&layer);

window.makeKeyAndOrderFront(None);
Ok((window, layer))
}
49 changes: 24 additions & 25 deletions src/backend/macos/screen_observer.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use objc2::{AnyThread, DefinedClass, define_class, msg_send, rc::Retained, runtime::NSObject};
use objc2_app_kit::{NSScreen, NSWindow};
use objc2_app_kit::NSWindow;
use objc2_av_foundation::AVPlayerLayer;
use objc2_foundation::{MainThreadMarker, NSNotification};
use objc2_foundation::NSNotification;

pub struct ScreenObserverIvars {
window: Retained<NSWindow>,
layer: Retained<AVPlayerLayer>,
windows: Vec<Retained<NSWindow>>,
layers: Vec<Retained<AVPlayerLayer>>,
}

define_class!(
Expand All @@ -22,32 +22,31 @@ define_class!(
);

impl ScreenObserver {
pub fn new(window: Retained<NSWindow>, layer: Retained<AVPlayerLayer>) -> Retained<Self> {
let ivars = ScreenObserverIvars { window, layer };
pub fn new(
windows: Vec<Retained<NSWindow>>,
layers: Vec<Retained<AVPlayerLayer>>,
) -> Retained<Self> {
let ivars = ScreenObserverIvars { windows, layers };
let this = Self::alloc().set_ivars(ivars);
unsafe { msg_send![super(this), init] }
}

fn apply_current_screen(&self) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let Some(screen) = NSScreen::mainScreen(mtm) else {
return;
};

let frame = screen.frame();
let backing_scale = screen.backingScaleFactor();

let ivars = self.ivars();
ivars.window.setFrame_display(frame, false);
ivars.layer.setContentsScale(backing_scale);

log::info!(
"display reconfigured: {}x{} @ {}x backing",
frame.size.width as u32,
frame.size.height as u32,
backing_scale,
);
for (window, layer) in ivars.windows.iter().zip(ivars.layers.iter()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only repositions existing windows. If a new display is plugged in after launch, no window or layer gets created for it and that screen stays bare. Worth a follow up to rebuild the list when screen parameters change.

let Some(screen) = window.screen() else {
continue;
};
let frame = screen.frame();
let backing_scale = screen.backingScaleFactor();
window.setFrame_display(frame, false);
layer.setContentsScale(backing_scale);
log::info!(
"display reconfigured: {}x{} @ {}x backing",
frame.size.width as u32,
frame.size.height as u32,
backing_scale,
);
}
}
}