diff --git a/Cargo.lock b/Cargo.lock index 7970cb1..ab59d98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ "bitflags", + "block2", "objc2", "objc2-core-foundation", "objc2-core-media", @@ -911,6 +912,16 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-media" version = "0.3.2" @@ -1064,6 +1075,7 @@ dependencies = [ "objc2-app-kit", "objc2-av-foundation", "objc2-core-foundation", + "objc2-core-image", "objc2-core-media", "objc2-core-video", "objc2-foundation", diff --git a/Cargo.toml b/Cargo.toml index 01f6f2d..2e6ab9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,14 +42,20 @@ objc2-foundation = { version = "0.3", features = [ "NSDate", "NSDictionary", "NSError", + "NSKeyValueCoding", "NSObject", "NSObjCRuntime", "NSNotification", + "NSProcessInfo", "NSRunLoop", "NSString", + "NSTimer", "NSURL", "NSValue", ] } +objc2-core-image = { version = "0.3", default-features = false, features = [ + "CIFilter", +] } objc2-app-kit = { version = "0.3", default-features = false, features = [ "std", "objc2-quartz-core", @@ -64,10 +70,17 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics", "NSEvent", ] } -objc2-quartz-core = { version = "0.3", features = ["CALayer"] } +objc2-quartz-core = { version = "0.3", features = [ + "CAAnimation", + "CALayer", + "CAMediaTiming", + "CAMediaTimingFunction", + "CATransaction", +] } objc2-core-foundation = { version = "0.3", features = ["CFNumber"] } objc2-av-foundation = { version = "0.3", default-features = false, features = [ "std", + "block2", "objc2-core-foundation", "objc2-core-media", "objc2-quartz-core", diff --git a/README.md b/README.md index bf2b64d..b04df03 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,19 @@ Play a random wallpaper from your configured search paths: phonto --rand ``` +Play a random wallpaper from a named playlist defined in your config: +```bash +phonto --playlist work +``` + +Rotate through the pool at a fixed interval, crossfading between videos (macOS only): +```bash +phonto --rand --shuffle-every 10m +phonto --playlist work --shuffle-every 30s +``` + +`--shuffle-every` accepts `s` / `m` / `h` suffixes (e.g. `45s`, `10m`, `1h`), with a 2-second minimum so the next video has time to pre-roll. + On Linux/Wayland, choose the layer-shell layer to render on: ```bash phonto /path/to/video.mp4 --layer background @@ -159,6 +172,31 @@ depth = 2 `depth = 0` scans only the top-level directory. `depth = 1` includes one level of subdirectories, and so on. +### `playlists` + +Named pools you can select with `--playlist `. Entries can mix directories (`path` + `depth`, same semantics as `search_paths`) and individual files (`file`): + +```toml +[[playlists]] +name = "work" +entries = [ + { path = "/home/user/wallpapers/work", depth = 1 }, + { file = "/home/user/wallpapers/special.mp4" }, +] + +[[playlists]] +name = "nature" +entries = [ + { path = "/home/user/wallpapers/nature", depth = 0 }, +] +``` + +Combine with `--shuffle-every` to rotate within the pool: + +```bash +phonto --playlist work --shuffle-every 15m +``` + ### GLSL shaders (Wayland only) `--shader PATH` applies a custom GLSL ES fragment shader to every frame. Pass the path to any `.glsl` file: diff --git a/src/backend/macos/battery_observer.rs b/src/backend/macos/battery_observer.rs index ab818c4..71c457f 100644 --- a/src/backend/macos/battery_observer.rs +++ b/src/backend/macos/battery_observer.rs @@ -2,6 +2,8 @@ use std::ffi::c_void; use objc2::rc::Retained; use objc2_av_foundation::AVPlayer; + +use super::shuffle_observer::CrossfadeState; use objc2_core_foundation::{ CFArray, CFDictionary, CFNumber, CFNumberType, CFRetained, CFRunLoop, CFRunLoopSource, CFString, CFType, kCFRunLoopDefaultMode, @@ -32,14 +34,23 @@ pub struct BatteryObserver { } struct Context { - player: Retained, + players: Vec>, mode: PauseMode, + crossfade: Option, } impl BatteryObserver { /// Returns `None` only on IOKit setup failure (already logged). - pub fn install(player: Retained, mode: PauseMode) -> Option { - let mut ctx = Box::new(Context { player, mode }); + pub fn install( + players: Vec>, + mode: PauseMode, + crossfade: Option, + ) -> Option { + let mut ctx = Box::new(Context { + players, + mode, + crossfade, + }); let ctx_ptr: *mut Context = &raw mut *ctx; let Some(source) = (unsafe { @@ -61,7 +72,7 @@ impl BatteryObserver { }; main.add_source(Some(&source), Some(mode_ref)); - apply_state(&ctx.player, &ctx.mode); + apply_state(&ctx.players, &ctx.mode, ctx.crossfade.as_ref()); Some(Self { _ctx: ctx, source }) } @@ -77,10 +88,14 @@ impl Drop for BatteryObserver { unsafe extern "C-unwind" fn power_changed(context: *mut c_void) { let ctx = unsafe { &*(context.cast::()) }; - apply_state(&ctx.player, &ctx.mode); + apply_state(&ctx.players, &ctx.mode, ctx.crossfade.as_ref()); } -fn apply_state(player: &AVPlayer, mode: &PauseMode) { +fn apply_state( + players: &[Retained], + mode: &PauseMode, + crossfade: Option<&CrossfadeState>, +) { let on_batt = on_battery(); let pct = battery_percent(); @@ -90,12 +105,35 @@ fn apply_state(player: &AVPlayer, mode: &PauseMode) { PauseMode::BelowPercent(threshold) => on_batt && pct.is_some_and(|p| p < *threshold), }; + if let Some(state) = crossfade { + state.pause_gate.set(should_pause); + + // Don't yank players out from under a crossfade. The shuffle observer + // already checks the gate when the transition lands and will pause or + // resume to match. + if state.transition_pending.get() { + log::debug!( + "deferring pause/play to end of in-flight transition (on_battery={on_batt}, charge={pct:?}%, should_pause={should_pause})" + ); + return; + } + } + if should_pause { log::info!("pausing wallpaper (on_battery={on_batt}, charge={pct:?}%)"); - unsafe { player.pause() }; + for p in players { + unsafe { p.pause() }; + } } else { log::info!("playing wallpaper (on_battery={on_batt}, charge={pct:?}%)"); - unsafe { player.play() }; + let active = crossfade.map(|s| s.active.get()); + for (idx, p) in players.iter().enumerate() { + if active == Some(idx) || active.is_none() { + unsafe { p.play() }; + } else { + unsafe { p.pause() }; + } + } } } diff --git a/src/backend/macos/mod.rs b/src/backend/macos/mod.rs index e1d8b8d..f4db0d2 100644 --- a/src/backend/macos/mod.rs +++ b/src/backend/macos/mod.rs @@ -1,10 +1,12 @@ mod battery_observer; mod loop_observer; mod screen_observer; +mod shuffle_observer; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::Context; +use objc2::rc::Retained; use objc2::sel; use objc2_app_kit::{ NSApplication, NSApplicationActivationPolicy, @@ -16,17 +18,25 @@ use objc2_av_foundation::{ AVLayerVideoGravityResizeAspectFill, AVPlayer, AVPlayerItem, AVPlayerItemDidPlayToEndTimeNotification, AVPlayerLayer, }; -use objc2_foundation::{MainThreadMarker, NSNotificationCenter, NSString, NSURL}; +use objc2_foundation::{ + MainThreadMarker, NSActivityOptions, NSNotificationCenter, NSProcessInfo, NSString, NSURL, +}; use objc2_quartz_core::CAAutoresizingMask; use self::battery_observer::BatteryObserver; use self::loop_observer::LoopObserver; use self::screen_observer::ScreenObserver; -use super::{Backend, PauseMode, RunOptions}; +use self::shuffle_observer::{CrossfadeState, ShuffleObserver, schedule_timer}; +use super::{Backend, PauseMode, PlaybackSource, RunOptions}; use crate::scale::ScaleMode; -// One below kCGDesktopWindowLevel so a static system wallpaper sits on top of us. -const WALLPAPER_LEVEL: isize = -2_147_483_624; +// Between kCGDesktopWindowLevel (the system wallpaper layer) and +// kCGDesktopIconWindowLevel (the Finder icons). Sitting below the wallpaper +// gets us geometrically occluded. AppKit marks us occlusionState=hidden and +// CoreAnimation stops compositing our window, so subsequent swaps update the +// model but never reach the screen. Above the wallpaper, occlusion stays +// visible and the desktop icons keep drawing on top. +const WALLPAPER_LEVEL: isize = -2_147_483_604; pub struct MacosBackend { mtm: MainThreadMarker, @@ -41,7 +51,7 @@ impl MacosBackend { } impl Backend for MacosBackend { - fn run(self, video_path: String, options: RunOptions) -> anyhow::Result<()> { + fn run(self, source: PlaybackSource, options: RunOptions) -> anyhow::Result<()> { let mtm = self.mtm; let scale = options.scale; @@ -49,6 +59,14 @@ impl Backend for MacosBackend { app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); app.finishLaunching(); + // App Nap suspends the run loop (timers stop, AVPlayer stalls) as soon + // as a fullscreen window covers us. Holding a Background activity for + // the lifetime of the process keeps the wallpaper ticking when it's + // occluded. The returned token must outlive the app.run() call. + let activity_reason = NSString::from_str("phonto wallpaper playback"); + let _activity = NSProcessInfo::processInfo() + .beginActivityWithOptions_reason(NSActivityOptions::Background, &activity_reason); + let screen = NSScreen::mainScreen(mtm).context("no main screen")?; let frame = screen.frame(); let backing_scale = screen.backingScaleFactor(); @@ -79,52 +97,78 @@ impl Backend for MacosBackend { let content_view = NSView::initWithFrame(mtm.alloc::(), frame); content_view.setWantsLayer(true); window.setContentView(Some(&content_view)); + let root_layer = content_view + .layer() + .context("content view has no root layer")?; - // 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()); - let path_ns = NSString::from_str(&abs.to_string_lossy()); - let url = NSURL::fileURLWithPath(&path_ns); + let gravity = video_gravity_for(scale); + let cache_path = cache_current_path(); + let bounds = content_view.bounds(); - let item = unsafe { AVPlayerItem::playerItemWithURL(&url, mtm) }; - let player = unsafe { AVPlayer::playerWithPlayerItem(Some(&item), mtm) }; - unsafe { - player.setMuted(true); - } + let (initial_path, shuffle): (PathBuf, Option<(Vec, std::time::Duration, usize)>) = + match source { + PlaybackSource::Single(p) => (p, None), + PlaybackSource::Shuffle { pool, interval } => { + if pool.is_empty() { + return Err(anyhow::anyhow!("shuffle pool is empty")); + } + use rand::Rng; + let first_idx = rand::rng().random_range(0..pool.len()); + let first = pool[first_idx].clone(); + (first, Some((pool, interval, first_idx))) + } + }; - let player_layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(&player)) }; - if let Some(gravity) = video_gravity_for(scale) { - unsafe { player_layer.setVideoGravity(gravity) }; + if let Some(cache) = &cache_path { + let _ = std::fs::write(cache, initial_path.to_string_lossy().as_bytes()); } - 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 (player_a, layer_a) = + build_player_layer(mtm, &initial_path, bounds, backing_scale, gravity); + root_layer.addSublayer(&layer_a); - // 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( - &loop_observer, - sel!(itemEnded:), - Some(AVPlayerItemDidPlayToEndTimeNotification), - None, + let notif_center = NSNotificationCenter::defaultCenter(); + let loop_a = LoopObserver::new(player_a.clone()); + register_loop_observer(¬if_center, &loop_a); + + // For shuffle mode we keep a second layer/player ready behind the + // active one and crossfade with a Gaussian blur on every tick. + let mut loop_observers = vec![loop_a]; + let mut players = vec![player_a.clone()]; + let mut layers = vec![layer_a.clone()]; + let mut shuffle_observer: Option> = None; + let mut shuffle_timer = None; + let mut crossfade_state: Option = None; + + if let Some((pool, interval, first_idx)) = shuffle { + let (player_b, layer_b) = build_player_layer_empty(mtm, bounds, backing_scale, gravity); + layer_b.setOpacity(0.0); + root_layer.addSublayer(&layer_b); + + let loop_b = LoopObserver::new(player_b.clone()); + register_loop_observer(¬if_center, &loop_b); + + let state = CrossfadeState::default(); + let observer = ShuffleObserver::new( + pool, + [layer_a.clone(), layer_b.clone()], + [player_a.clone(), player_b.clone()], + cache_path.clone(), + first_idx, + state.clone(), ); + shuffle_timer = Some(schedule_timer(&observer, interval.as_secs_f64())); + shuffle_observer = Some(observer); + crossfade_state = Some(state); + + loop_observers.push(loop_b); + players.push(player_b); + layers.push(layer_b); } - // Re-apply geometry on display reconfiguration. - let screen_observer = ScreenObserver::new(window.clone(), player_layer.clone()); + let screen_observer = ScreenObserver::new(window.clone(), layers.clone()); unsafe { - NSNotificationCenter::defaultCenter().addObserver_selector_name_object( + notif_center.addObserver_selector_name_object( &screen_observer, sel!(screensChanged:), Some(NSApplicationDidChangeScreenParametersNotification), @@ -137,10 +181,12 @@ impl Backend for MacosBackend { let battery_observer = if matches!(options.pause, PauseMode::Never) { None } else { - BatteryObserver::install(player.clone(), options.pause) + BatteryObserver::install(players.clone(), options.pause, crossfade_state) }; if battery_observer.is_none() { - unsafe { player.play() }; + for p in &players { + unsafe { p.play() }; + } } log::info!( @@ -151,14 +197,98 @@ impl Backend for MacosBackend { ); app.run(); - drop(loop_observer); + drop(shuffle_timer); + drop(shuffle_observer); + drop(loop_observers); drop(screen_observer); drop(battery_observer); + drop(layers); + drop(players); Ok(()) } } +fn register_loop_observer(center: &NSNotificationCenter, observer: &LoopObserver) { + unsafe { + center.addObserver_selector_name_object( + observer, + sel!(itemEnded:), + Some(AVPlayerItemDidPlayToEndTimeNotification), + None, + ); + } +} + +fn build_player_layer( + mtm: MainThreadMarker, + video_path: &Path, + bounds: objc2_foundation::NSRect, + backing_scale: f64, + gravity: Option<&'static AVLayerVideoGravity>, +) -> (Retained, Retained) { + // AVAsset resolves relative paths against the process cwd, which isn't + // what users expect from `phonto ./video.mp4`. + let abs = video_path + .canonicalize() + .unwrap_or_else(|_| 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); + // Without this, AVPlayer can drop to WaitingToPlayAtSpecifiedRate when + // an overlapping window starves the display, and never recovers. + player.setAutomaticallyWaitsToMinimizeStalling(false); + } + + let layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(&player)) }; + if let Some(g) = gravity { + unsafe { layer.setVideoGravity(g) }; + } + layer.setFrame(bounds); + layer.setAutoresizingMask( + CAAutoresizingMask::LayerWidthSizable | CAAutoresizingMask::LayerHeightSizable, + ); + layer.setContentsScale(backing_scale); + + (player, layer) +} + +fn build_player_layer_empty( + mtm: MainThreadMarker, + bounds: objc2_foundation::NSRect, + backing_scale: f64, + gravity: Option<&'static AVLayerVideoGravity>, +) -> (Retained, Retained) { + let player = unsafe { AVPlayer::playerWithPlayerItem(None, mtm) }; + unsafe { + player.setMuted(true); + player.setAutomaticallyWaitsToMinimizeStalling(false); + } + + let layer = unsafe { AVPlayerLayer::playerLayerWithPlayer(Some(&player)) }; + if let Some(g) = gravity { + unsafe { layer.setVideoGravity(g) }; + } + layer.setFrame(bounds); + layer.setAutoresizingMask( + CAAutoresizingMask::LayerWidthSizable | CAAutoresizingMask::LayerHeightSizable, + ); + layer.setContentsScale(backing_scale); + + (player, layer) +} + +fn cache_current_path() -> Option { + let home = std::env::var("HOME").ok()?; + let cache_dir = std::path::Path::new(&home).join(".cache/phonto"); + std::fs::create_dir_all(&cache_dir).ok()?; + Some(cache_dir.join("current")) +} + fn video_gravity_for(scale: ScaleMode) -> Option<&'static AVLayerVideoGravity> { unsafe { match scale { diff --git a/src/backend/macos/screen_observer.rs b/src/backend/macos/screen_observer.rs index 437d058..cfd6223 100644 --- a/src/backend/macos/screen_observer.rs +++ b/src/backend/macos/screen_observer.rs @@ -5,7 +5,7 @@ use objc2_foundation::{MainThreadMarker, NSNotification}; pub struct ScreenObserverIvars { window: Retained, - layer: Retained, + layers: Vec>, } define_class!( @@ -22,8 +22,8 @@ define_class!( ); impl ScreenObserver { - pub fn new(window: Retained, layer: Retained) -> Retained { - let ivars = ScreenObserverIvars { window, layer }; + pub fn new(window: Retained, layers: Vec>) -> Retained { + let ivars = ScreenObserverIvars { window, layers }; let this = Self::alloc().set_ivars(ivars); unsafe { msg_send![super(this), init] } } @@ -41,7 +41,9 @@ impl ScreenObserver { let ivars = self.ivars(); ivars.window.setFrame_display(frame, false); - ivars.layer.setContentsScale(backing_scale); + for layer in &ivars.layers { + layer.setContentsScale(backing_scale); + } log::info!( "display reconfigured: {}x{} @ {}x backing", diff --git a/src/backend/macos/shuffle_observer.rs b/src/backend/macos/shuffle_observer.rs new file mode 100644 index 0000000..d1fa7b0 --- /dev/null +++ b/src/backend/macos/shuffle_observer.rs @@ -0,0 +1,263 @@ +use std::cell::Cell; +use std::path::PathBuf; +use std::rc::Rc; + +use objc2::{ + AnyThread, DefinedClass, define_class, msg_send, + rc::Retained, + runtime::{AnyObject, NSObject}, + sel, +}; +use objc2_av_foundation::{AVPlayer, AVPlayerItem, AVPlayerLayer}; +use objc2_core_image::CIFilter; +use objc2_foundation::{ + MainThreadMarker, NSArray, NSNumber, NSObjectNSDelayedPerforming, NSObjectNSKeyValueCoding, + NSRunLoop, NSRunLoopCommonModes, NSString, NSTimer, NSURL, +}; +use objc2_quartz_core::{ + CABasicAnimation, CAMediaTiming, CAMediaTimingFunction, CATransaction, + kCAMediaTimingFunctionEaseInEaseOut, +}; +use rand::Rng; + +const TRANSITION_DURATION: f64 = 0.25; +const WARMUP_DELAY: f64 = 0.15; +const BLUR_RADIUS: f64 = 10.0; + +/// State the shuffle observer shares with the battery observer so power +/// changes don't tear a crossfade apart. All access is single-threaded +/// (NSTimer + CFRunLoopSource both fire from the main run loop), so `Rc` +/// and `Cell` are sufficient. +#[derive(Clone, Default)] +pub struct CrossfadeState { + pub active: Rc>, + pub pause_gate: Rc>, + pub transition_pending: Rc>, +} + +pub struct ShuffleObserverIvars { + pool: Vec, + layers: [Retained; 2], + players: [Retained; 2], + last_index: Cell, + state: CrossfadeState, + cache_path: Option, +} + +define_class!( + #[unsafe(super(NSObject))] + #[ivars = ShuffleObserverIvars] + pub struct ShuffleObserver; + + impl ShuffleObserver { + #[unsafe(method(tick:))] + fn _tick(&self, _timer: &AnyObject) { + let ivars = self.ivars(); + let Some(mtm) = MainThreadMarker::new() else { return }; + // The battery observer already paused both players. Skipping the + // tick keeps us from calling play() and undoing that. + if ivars.state.pause_gate.get() { + log::debug!("shuffle tick skipped while paused"); + return; + } + if ivars.state.transition_pending.get() { + log::debug!("shuffle tick skipped while previous transition is pending"); + return; + } + + let last = ivars.last_index.get(); + let next_idx = match ivars.pool.len() { + 0 => return, + 1 => 0, + 2 => 1 - last, + n => { + // Sample from a pool of size n-1, then skip over `last` + // to avoid playing the same video twice in a row. + let mut i = rand::rng().random_range(0..n - 1); + if i >= last { + i += 1; + } + i + } + }; + ivars.last_index.set(next_idx); + + let next = ivars.pool[next_idx].clone(); + let abs = next.canonicalize().unwrap_or_else(|_| next.clone()); + let path_ns = NSString::from_str(&abs.to_string_lossy()); + let url = NSURL::fileURLWithPath(&path_ns); + let item = unsafe { AVPlayerItem::playerItemWithURL(&url, mtm) }; + + let active = ivars.state.active.get(); + let inactive = 1 - active; + + unsafe { + // Selector+object form matches the argument using the isEqual: + // selector. The pausePlayer:/clearFilters: performs were + // scheduled with non-nil args, so a pass-by-nil cancel + // wouldn't match them. + NSObject::cancelPreviousPerformRequestsWithTarget(self); + ivars.players[inactive].replaceCurrentItemWithPlayerItem(Some(&item)); + ivars.players[inactive].setMuted(true); + ivars.players[inactive].playImmediatelyAtRate(1.0); + } + apply_transition_blur(&ivars.layers[inactive], BLUR_RADIUS); + ivars.state.transition_pending.set(true); + + if let Some(cache) = &ivars.cache_path { + let _ = std::fs::write(cache, abs.to_string_lossy().as_bytes()); + } + log::info!("shuffle -> {}", abs.display()); + + // Give AVPlayer a beat to produce the first decoded frame before + // we crossfade it in. This keeps overlap short without waiting a + // full preroll window. + unsafe { + self.performSelector_withObject_afterDelay(sel!(animate:), None, WARMUP_DELAY); + } + } + + #[unsafe(method(animate:))] + fn _animate(&self, _arg: *mut AnyObject) { + let ivars = self.ivars(); + let active = ivars.state.active.get(); + let inactive = 1 - active; + animate_swap(&ivars.layers[active], &ivars.layers[inactive]); + ivars.state.active.set(inactive); + ivars.state.transition_pending.set(false); + + unsafe { + // Clear blur on the incoming (now-active) layer. Pause the + // outgoing player once the fade has fully covered it. + self.performSelector_withObject_afterDelay( + sel!(clearFilters:), + Some(ivars.layers[inactive].as_ref()), + TRANSITION_DURATION, + ); + self.performSelector_withObject_afterDelay( + sel!(pausePlayer:), + Some(ivars.players[active].as_ref()), + TRANSITION_DURATION, + ); + } + + // If a pause was requested mid-transition, the battery observer + // deferred to us. Now that the fade has settled, honor it. + if ivars.state.pause_gate.get() { + for p in &ivars.players { + unsafe { p.pause() }; + } + } + } + + #[unsafe(method(pausePlayer:))] + fn _pause_player(&self, player: &AnyObject) { + let player: &AVPlayer = unsafe { &*(player as *const AnyObject).cast::() }; + unsafe { + player.pause(); + } + } + + #[unsafe(method(clearFilters:))] + fn _clear_filters(&self, layer: &AnyObject) { + let layer: &AVPlayerLayer = + unsafe { &*(layer as *const AnyObject).cast::() }; + unsafe { + layer.setFilters(None); + } + } + } +); + +impl ShuffleObserver { + pub fn new( + pool: Vec, + layers: [Retained; 2], + players: [Retained; 2], + cache_path: Option, + initial_index: usize, + state: CrossfadeState, + ) -> Retained { + let ivars = ShuffleObserverIvars { + pool, + layers, + players, + last_index: Cell::new(initial_index), + state, + cache_path, + }; + let this = Self::alloc().set_ivars(ivars); + unsafe { msg_send![super(this), init] } + } +} + +pub fn schedule_timer(observer: &ShuffleObserver, interval_secs: f64) -> Retained { + // Scheduled in CommonModes (not just default) so modal panels and event + // tracking can't stop the wallpaper from advancing. + unsafe { + let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats( + interval_secs, + observer, + sel!(tick:), + None, + true, + ); + NSRunLoop::mainRunLoop().addTimer_forMode(&timer, NSRunLoopCommonModes); + timer + } +} + +fn animate_swap(out_layer: &AVPlayerLayer, in_layer: &AVPlayerLayer) { + // Promote the incoming layer above the outgoing one and keep the + // outgoing layer fully opaque underneath. Fading both opacities at + // once dips the composite below 100% (≈0.75 at midpoint) and exposes + // the desktop behind our wallpaper-level window during the transition. + CATransaction::begin(); + CATransaction::setDisableActions(true); + in_layer.setZPosition(1.0); + out_layer.setZPosition(0.0); + in_layer.setOpacity(0.0); + out_layer.setOpacity(1.0); + CATransaction::commit(); + + add_anim(in_layer, "opacity", 0.0, 1.0); + + CATransaction::begin(); + CATransaction::setDisableActions(true); + in_layer.setOpacity(1.0); + CATransaction::commit(); +} + +fn add_anim(layer: &AVPlayerLayer, key_path: &str, from: f64, to: f64) { + let kp = NSString::from_str(key_path); + let anim = CABasicAnimation::animationWithKeyPath(Some(&kp)); + + let from_num = NSNumber::numberWithDouble(from); + let to_num = NSNumber::numberWithDouble(to); + unsafe { + anim.setFromValue(Some(&from_num)); + anim.setToValue(Some(&to_num)); + } + anim.setDuration(TRANSITION_DURATION); + + let timing = + unsafe { CAMediaTimingFunction::functionWithName(kCAMediaTimingFunctionEaseInEaseOut) }; + anim.setTimingFunction(Some(&timing)); + + layer.addAnimation_forKey(&anim, Some(&kp)); +} + +fn apply_transition_blur(layer: &AVPlayerLayer, radius: f64) { + let name = NSString::from_str("CIGaussianBlur"); + let key = NSString::from_str("inputRadius"); + let radius = NSNumber::numberWithDouble(radius); + let Some(filter) = (unsafe { CIFilter::filterWithName(&name) }) else { + return; + }; + unsafe { + filter.setDefaults(); + filter.setValue_forKey(Some(radius.as_ref()), &key); + let filters = NSArray::from_retained_slice(&[filter]); + layer.setFilters(Some(filters.cast_unchecked::())); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 3b6248b..9ed4a9c 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,3 +1,6 @@ +use std::path::PathBuf; +use std::time::Duration; + use crate::scale::ScaleMode; #[derive(Debug, Clone, Copy)] @@ -14,10 +17,19 @@ pub enum PauseMode { BelowPercent(u8), } +#[derive(Debug, Clone)] +pub enum PlaybackSource { + Single(PathBuf), + Shuffle { + pool: Vec, + interval: Duration, + }, +} + pub trait Backend { /// Take ownership of the runtime. Blocks for the lifetime of the wallpaper — /// returns only on error or graceful shutdown. - fn run(self, video_path: String, options: RunOptions) -> anyhow::Result<()>; + fn run(self, source: PlaybackSource, options: RunOptions) -> anyhow::Result<()>; } #[cfg(target_os = "linux")] diff --git a/src/backend/wayland/mod.rs b/src/backend/wayland/mod.rs index c2930f4..ddc74a7 100644 --- a/src/backend/wayland/mod.rs +++ b/src/backend/wayland/mod.rs @@ -12,7 +12,7 @@ use wayland_client::{ use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use self::gl_renderer::GlRenderer; -use super::{Backend, PauseMode, RunOptions}; +use super::{Backend, PauseMode, PlaybackSource, RunOptions}; use clap::ValueEnum; #[derive(Debug, Clone, Copy, Default, ValueEnum)] @@ -68,7 +68,18 @@ impl WaylandBackend { const BATTERY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); impl Backend for WaylandBackend { - fn run(mut self, video_path: String, options: RunOptions) -> anyhow::Result<()> { + fn run(mut self, source: PlaybackSource, options: RunOptions) -> anyhow::Result<()> { + let video_path = match source { + PlaybackSource::Single(p) => p.to_string_lossy().into_owned(), + PlaybackSource::Shuffle { pool, interval } => { + anyhow::bail!( + "--shuffle-every is not yet implemented on wayland (requested {} entries every {:?})", + pool.len(), + interval, + ) + } + }; + let (tx, rx) = mpsc::sync_channel(1); let (gl_display, gl_context) = diff --git a/src/config.rs b/src/config.rs index febb816..95e77e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,17 @@ const DEFAULT_CONFIG: &str = "\ # [[search_paths]] # path = \"/mnt/media/videos\" # depth = 2 +# +# Named playlists let you group wallpapers by mood / context. Use them with +# `phonto --playlist ` (optionally combined with `--shuffle-every 10m`). +# Entries can mix directories (`path` + `depth`) and individual files (`file`). +# +# [[playlists]] +# name = \"chill\" +# entries = [ +# { path = \"/home/user/wallpapers/chill\", depth = 1 }, +# { file = \"/home/user/wallpapers/special.mp4\" }, +# ] "; #[derive(Debug, Deserialize)] @@ -25,10 +36,25 @@ pub struct SearchPath { pub depth: u32, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum PlaylistEntry { + Dir { path: String, depth: u32 }, + File { file: String }, +} + +#[derive(Debug, Deserialize)] +pub struct Playlist { + pub name: String, + pub entries: Vec, +} + #[derive(Debug, Deserialize)] pub struct Config { #[serde(default)] pub search_paths: Vec, + #[serde(default)] + pub playlists: Vec, } fn config_path() -> PathBuf { diff --git a/src/main.rs b/src/main.rs index d038b8c..d6e34c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; #[cfg(target_os = "linux")] use anyhow::Context; - use backend::{Backend, PauseMode, RunOptions}; use clap::Parser; #[cfg(target_os = "macos")] @@ -29,13 +28,25 @@ struct Args { command: Option, /// Path to the video file - #[arg(required_unless_present = "rand", conflicts_with = "rand")] + #[arg( + required_unless_present_any = ["rand", "playlist"], + conflicts_with_all = ["rand", "playlist"], + )] path: Option, - /// Play a random wallpaper from your playlist - #[arg(long, conflicts_with = "path")] + /// Play from your `search_paths` pool + #[arg(long, conflicts_with = "playlist")] rand: bool, + /// Play from a named playlist defined in config + #[arg(long, value_name = "NAME")] + playlist: Option, + + /// Swap wallpapers from the pool at this interval (e.g. "30s", "10m", "1h"). + /// Requires --rand or --playlist. + #[arg(long, value_name = "DURATION")] + shuffle_every: Option, + /// How to fit the video to the screen. #[arg(long, value_enum, default_value_t = ScaleMode::Fill)] scale: ScaleMode, @@ -99,23 +110,20 @@ fn main() -> anyhow::Result<()> { let config = config::load()?; - let path = if args.rand { - wallpaper::pick_random(&config.search_paths) - .ok_or_else(|| anyhow::anyhow!("no wallpapers found in configured search paths"))? - .to_string_lossy() - .into_owned() + let selection = if args.rand { + wallpaper::SourceSelection::SearchPaths + } else if let Some(name) = args.playlist.as_deref() { + wallpaper::SourceSelection::Playlist(name) } else { - args.path - .expect("clap ensures path is set when --rand is not used") + wallpaper::SourceSelection::Path( + args.path + .as_deref() + .expect("clap requires a path when no pool is selected"), + ) }; - // Persist the resolved path so other tools (e.g. hyprlock) can read it. - if let Ok(home) = std::env::var("HOME") { - let cache_dir = std::path::Path::new(&home).join(".cache/phonto"); - if std::fs::create_dir_all(&cache_dir).is_ok() { - let _ = std::fs::write(cache_dir.join("current"), &path); - } - } + let source = wallpaper::resolve_source(&config, selection, args.shuffle_every.as_deref())?; + wallpaper::write_current_if_single(&source); let pause = match (args.pause_on_battery, args.pause_below) { (true, _) => PauseMode::OnBattery, @@ -137,9 +145,9 @@ fn main() -> anyhow::Result<()> { .with_context(|| format!("failed to read shader file: {p}")) }) .transpose()?; - backend::wayland::WaylandBackend::new(args.layer, shader)?.run(path, options) + backend::wayland::WaylandBackend::new(args.layer, shader)?.run(source, options) } #[cfg(target_os = "macos")] - return backend::macos::MacosBackend::new()?.run(path, options); + return backend::macos::MacosBackend::new()?.run(source, options); } diff --git a/src/wallpaper.rs b/src/wallpaper.rs index 7cff746..4ebd05d 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -1,10 +1,62 @@ use std::path::{Path, PathBuf}; +use std::time::Duration; use rand::seq::IndexedRandom; -use crate::config::SearchPath; +use crate::{ + backend::PlaybackSource, + config::{Config, Playlist, PlaylistEntry, SearchPath}, +}; const WALLPAPER_EXTENSIONS: &[&str] = &["mp4", "mkv", "webm", "avi", "mov", "gif", "ogv"]; +const MIN_SHUFFLE_INTERVAL: Duration = Duration::from_secs(2); + +pub enum SourceSelection<'a> { + Path(&'a str), + SearchPaths, + Playlist(&'a str), +} + +pub fn resolve_source( + config: &Config, + selection: SourceSelection<'_>, + shuffle_every: Option<&str>, +) -> anyhow::Result { + match selection { + SourceSelection::Path(path) => { + if shuffle_every.is_some() { + anyhow::bail!("--shuffle-every requires --rand or --playlist"); + } + Ok(PlaybackSource::Single(PathBuf::from(path))) + } + SourceSelection::SearchPaths => { + let pool = collect(&config.search_paths); + source_from_pool(pool, shuffle_every) + } + SourceSelection::Playlist(name) => { + let playlist = config + .playlists + .iter() + .find(|p| p.name == name) + .ok_or_else(|| anyhow::anyhow!("no playlist named '{name}' in config"))?; + let pool = collect_playlist(playlist); + source_from_pool(pool, shuffle_every) + } + } +} + +pub fn write_current_if_single(source: &PlaybackSource) { + let PlaybackSource::Single(path) = source else { + return; + }; + + if let Ok(home) = std::env::var("HOME") { + let cache_dir = Path::new(&home).join(".cache/phonto"); + if std::fs::create_dir_all(&cache_dir).is_ok() { + let _ = std::fs::write(cache_dir.join("current"), path.to_string_lossy().as_bytes()); + } + } +} pub fn collect(search_paths: &[SearchPath]) -> Vec { let mut wallpapers = Vec::new(); @@ -14,9 +66,77 @@ pub fn collect(search_paths: &[SearchPath]) -> Vec { wallpapers } -pub fn pick_random(search_paths: &[SearchPath]) -> Option { - let wallpapers = collect(search_paths); - wallpapers.choose(&mut rand::rng()).cloned() +pub fn collect_playlist(playlist: &Playlist) -> Vec { + let mut wallpapers = Vec::new(); + for entry in &playlist.entries { + match entry { + PlaylistEntry::Dir { path, depth } => walk(Path::new(path), *depth, 0, &mut wallpapers), + PlaylistEntry::File { file } => { + let p = PathBuf::from(file); + if p.is_file() { + wallpapers.push(p); + } else { + log::warn!( + "playlist '{}' references missing file: {file}", + playlist.name + ); + } + } + } + } + wallpapers +} + +pub fn pick_random(pool: &[PathBuf]) -> Option { + pool.choose(&mut rand::rng()).cloned() +} + +fn source_from_pool( + pool: Vec, + shuffle_every: Option<&str>, +) -> anyhow::Result { + match shuffle_every { + Some(spec) => { + let interval = parse_duration(spec)?; + if pool.is_empty() { + anyhow::bail!("playback pool is empty"); + } + if interval < MIN_SHUFFLE_INTERVAL { + anyhow::bail!( + "--shuffle-every must be at least {}s to allow the next video to pre-roll", + MIN_SHUFFLE_INTERVAL.as_secs() + ); + } + Ok(PlaybackSource::Shuffle { pool, interval }) + } + None => { + let pick = + pick_random(&pool).ok_or_else(|| anyhow::anyhow!("playback pool is empty"))?; + Ok(PlaybackSource::Single(pick)) + } + } +} + +fn parse_duration(s: &str) -> anyhow::Result { + let s = s.trim(); + if s.is_empty() { + anyhow::bail!("empty duration"); + } + let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let (num_str, unit) = s.split_at(split); + let n: u64 = num_str + .parse() + .map_err(|_| anyhow::anyhow!("invalid duration number in '{s}'"))?; + let secs = match unit.trim() { + "" | "s" => n, + "m" => n * 60, + "h" => n * 3600, + other => anyhow::bail!("unknown duration unit '{other}' (use s, m, or h)"), + }; + if secs == 0 { + anyhow::bail!("duration must be greater than zero"); + } + Ok(Duration::from_secs(secs)) } fn walk(dir: &Path, max_depth: u32, depth: u32, out: &mut Vec) {