From 61b8200622b00145a85ab7e4a9a4bbf917b8d6de Mon Sep 17 00:00:00 2001 From: maxfrai Date: Tue, 19 May 2026 16:14:55 +0200 Subject: [PATCH] macos: render wallpaper on every connected display Previously the macOS backend only created a window on NSScreen.mainScreen, so users with multiple displays saw the wallpaper on a single screen. - Enumerate NSScreen.screens() and build one borderless window plus one AVPlayerLayer per display. A single AVPlayer is shared across every layer so all screens stay in sync and the decoder only runs once. - Use each screen's backingScaleFactor for its layer's contentsScale so mixed Retina / non-Retina setups render at native resolution. - ScreenObserver now holds Vecs of windows and layers and re-applies geometry on NSApplicationDidChangeScreenParametersNotification by asking each window for its current screen(), so windows track their display across rearrangements. - Extract per-screen window/layer construction into build_wallpaper_window so run() stays readable as orchestration. --- src/backend/macos/mod.rs | 125 +++++++++++++++------------ src/backend/macos/screen_observer.rs | 49 +++++------ 2 files changed, 95 insertions(+), 79 deletions(-) diff --git a/src/backend/macos/mod.rs b/src/backend/macos/mod.rs index e1d8b8d..958c7d8 100644 --- a/src/backend/macos/mod.rs +++ b/src/backend/macos/mod.rs @@ -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, @@ -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::(), - 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::(), 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() .unwrap_or_else(|_| Path::new(&video_path).to_path_buf()); @@ -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( @@ -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, @@ -132,8 +98,6 @@ impl Backend for MacosBackend { ); } - window.makeKeyAndOrderFront(None); - let battery_observer = if matches!(options.pause, PauseMode::Never) { None } else { @@ -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, ); @@ -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, Retained)> { + let frame = screen.frame(); + let backing_scale = screen.backingScaleFactor(); + + let window = unsafe { + NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc::(), + 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::(), 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)) +} diff --git a/src/backend/macos/screen_observer.rs b/src/backend/macos/screen_observer.rs index 437d058..5b11caf 100644 --- a/src/backend/macos/screen_observer.rs +++ b/src/backend/macos/screen_observer.rs @@ -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, - layer: Retained, + windows: Vec>, + layers: Vec>, } define_class!( @@ -22,32 +22,31 @@ define_class!( ); impl ScreenObserver { - pub fn new(window: Retained, layer: Retained) -> Retained { - let ivars = ScreenObserverIvars { window, layer }; + pub fn new( + windows: Vec>, + layers: Vec>, + ) -> Retained { + 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()) { + 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, + ); + } } }