Skip to content
Open
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>`. 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:
Expand Down
54 changes: 46 additions & 8 deletions src/backend/macos/battery_observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,14 +34,23 @@ pub struct BatteryObserver {
}

struct Context {
player: Retained<AVPlayer>,
players: Vec<Retained<AVPlayer>>,
mode: PauseMode,
crossfade: Option<CrossfadeState>,
}

impl BatteryObserver {
/// Returns `None` only on IOKit setup failure (already logged).
pub fn install(player: Retained<AVPlayer>, mode: PauseMode) -> Option<Self> {
let mut ctx = Box::new(Context { player, mode });
pub fn install(
players: Vec<Retained<AVPlayer>>,
mode: PauseMode,
crossfade: Option<CrossfadeState>,
) -> Option<Self> {
let mut ctx = Box::new(Context {
players,
mode,
crossfade,
});
let ctx_ptr: *mut Context = &raw mut *ctx;

let Some(source) = (unsafe {
Expand All @@ -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 })
}
Expand All @@ -77,10 +88,14 @@ impl Drop for BatteryObserver {

unsafe extern "C-unwind" fn power_changed(context: *mut c_void) {
let ctx = unsafe { &*(context.cast::<Context>()) };
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<AVPlayer>],
mode: &PauseMode,
crossfade: Option<&CrossfadeState>,
) {
let on_batt = on_battery();
let pct = battery_percent();

Expand All @@ -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() };
}
}
}
}

Expand Down
Loading