Make video wallpapers survive first contact

Shipping met reality tonight, and reality won three rounds before we
did. mpvpaper's -f forks it into the background, which a supervisor
reads as instant death — the respawn loop repainted the desktop black
once a second. The reaper's own kills fired onExited like crashes, so
the supervisor ate its young until deliberate deaths got marked. And
the video's path shared wallpaperPath with the still pipeline, whose
transactional persistence clobbered it — it now lives under its own
videoWallpaperPath key, read by the lock screen and the doctor too.

Restore is the service's own now, reactive and once per session: a
one-shot timer raced the async preference load and availability probe
at cold start and silently lost. A video-wallpaper IPC target drives
start/stop/pause from a terminal and from the contract sweep to come.

Verified live: one player per output, same PID across restarts, VAAPI
engaged, 0.2% CPU steady for 1440p30 h264, pause from the bar pill.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:57:04 -04:00
parent cb7c09d208
commit 4d7a194300
7 changed files with 148 additions and 24 deletions
@@ -980,6 +980,15 @@ Singleton {
label: "Video wallpaper folder", label: "Video wallpaper folder",
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /" detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
}, },
{
// Its own key, not wallpaperPath: the still pipeline persists its
// policy transactionally and once clobbered a stored video path.
// Two owners, two keys.
key: "videoWallpaperPath", type: "string", def: "", group: "wallpaper", internal: true,
pattern: "^(|/[^,\n]+)$",
label: "Video wallpaper",
detail: "The video playing as the desktop background"
},
{ {
key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper", key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
label: "Pause video wallpaper on battery", label: "Pause video wallpaper on battery",
+1 -1
View File
@@ -590,7 +590,7 @@ def check_video_wallpaper(config: DoctorConfig) -> Check:
video = "" video = ""
try: try:
stored = json.loads(settings_path.read_text()) stored = json.loads(settings_path.read_text())
candidate = str(stored.get("wallpaperPath", "")) candidate = str(stored.get("videoWallpaperPath", ""))
if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE): if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE):
video = candidate video = candidate
except (OSError, ValueError): except (OSError, ValueError):
+8 -1
View File
@@ -145,7 +145,14 @@ load_preferences() {
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single || wallpaper_mode=single
wallpaper_warning="" wallpaper_warning=""
resolve_wallpaper_path "$(read_string wallpaperPath '')" # A video wallpaper lives under its own key; when one is set it wins,
# standing in as its cached still frame via resolve_wallpaper_path.
video_wallpaper="$(read_string videoWallpaperPath '')"
if [[ -n "$video_wallpaper" ]]; then
resolve_wallpaper_path "$video_wallpaper"
else
resolve_wallpaper_path "$(read_string wallpaperPath '')"
fi
wallpaper_path="$resolved_wallpaper" wallpaper_path="$resolved_wallpaper"
wallpaper_assignments="$(read_object wallpaperPerMonitor)" wallpaper_assignments="$(read_object wallpaperPerMonitor)"
@@ -50,6 +50,9 @@ Singleton {
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png" readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
readonly property var outputs: Quickshell.screens.map(screen => screen.name) readonly property var outputs: Quickshell.screens.map(screen => screen.name)
// A string, because the array above is a fresh identity every evaluation
// and respawning on identity churn stacked players on the same output.
readonly property string outputSignature: root.outputs.join(",")
function videoDir(): string { function videoDir(): string {
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers"); const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
@@ -101,8 +104,11 @@ Singleton {
// One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause; // One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is // the real pausing happens over the IPC socket below. `hwdec=vaapi` is
// named explicitly so a silent software-decode fallback cannot hide. // named explicitly so a silent software-decode fallback cannot hide.
// No `-f`: that flag forks mpvpaper into the background, which reads to a
// supervisor as the process dying instantly — it must stay foreground so
// onExited means what it says.
function mpvpaperCommand(output: string): var { function mpvpaperCommand(output: string): var {
return ["mpvpaper", "-f", "-o", return ["mpvpaper", "-o",
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 " "hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
+ "input-ipc-server=" + root.socketFor(output), + "input-ipc-server=" + root.socketFor(output),
"-p", "-a", "FULL", "-p", "-a", "FULL",
@@ -132,11 +138,17 @@ Singleton {
function stop(): void { function stop(): void {
if (!root.active) if (!root.active)
return; return;
root.restoreConsumed = true;
root.path = ""; root.path = "";
root.manuallyPaused = false; root.manuallyPaused = false;
playerRespawn.stop(); playerRespawn.stop();
for (const player of root.players) spawnDelay.stop();
for (const player of root.players) {
player.retiring = true;
player.running = false; player.running = false;
}
root.players = [];
reaper.running = true;
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"]; hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
hyprpaperControl.running = true; hyprpaperControl.running = true;
Qt.callLater(() => Wallpaper.refreshActive()); Qt.callLater(() => Wallpaper.refreshActive());
@@ -152,18 +164,45 @@ Singleton {
onTriggered: root.spawnPlayers() onTriggered: root.spawnPlayers()
} }
// Reap-then-spawn, always in that order. Every mpvpaper on this session
// is Panama's, so a blanket pkill is the reliable way to guarantee one
// player per output — politely asking Process objects to stop proved
// racy: a replaced object could orphan its child, and three players once
// stacked on one output.
function spawnPlayers(): void { function spawnPlayers(): void {
for (const player of root.players) // Retiring marks a deliberate death: the reaper's kill fires each
// player's onExited exactly like a crash would, and without the mark
// the supervisor respawned in response to its own reaping, forever.
for (const player of root.players) {
player.retiring = true;
player.running = false; player.running = false;
const spawned = [];
for (const output of root.outputs) {
const player = playerComponent.createObject(root, { output: output });
player.command = root.mpvpaperCommand(output);
player.running = true;
spawned.push(player);
} }
root.players = spawned; root.players = [];
pauseSync.restart(); reaper.running = true;
}
Process {
id: reaper
command: ["pkill", "-x", "mpvpaper"]
onExited: spawnDelay.restart()
}
Timer {
id: spawnDelay
interval: 300
onTriggered: {
if (!root.active)
return;
const spawned = [];
for (const output of root.outputs) {
const player = playerComponent.createObject(root, { output: output });
player.command = root.mpvpaperCommand(output);
player.running = true;
spawned.push(player);
}
root.players = spawned;
pauseSync.restart();
}
} }
Component { Component {
@@ -172,17 +211,20 @@ Singleton {
Process { Process {
id: player id: player
property string output: "" property string output: ""
property bool retiring: false
onExited: { onExited: {
// A dead player while a video is meant to be active is a // A dead player while a video is meant to be active is a
// crash (mpvpaper has a known hotplug segfault): respawn the // crash (mpvpaper has a known hotplug segfault): respawn the
// whole set after a beat rather than reasoning per-output. // whole set after a beat rather than reasoning per-output.
if (root.active && !playerRespawn.running) // A retiring player died because the supervisor killed it —
// reacting to that is how the reap loop once ate its young.
if (!player.retiring && root.active && !playerRespawn.running)
playerRespawn.restart(); playerRespawn.restart();
} }
} }
} }
onOutputsChanged: if (root.active) playerRespawn.restart() onOutputSignatureChanged: if (root.active) playerRespawn.restart()
Process { Process {
id: hyprpaperControl id: hyprpaperControl
@@ -240,5 +282,32 @@ Singleton {
root.manuallyPaused = !root.manuallyPaused; root.manuallyPaused = !root.manuallyPaused;
} }
Component.onCompleted: root.rescan() // ── Startup restore ─────────────────────────────────────────────────────
// The service restores its own video, reactively: at cold start the
// preferences file and the mpvpaper probe both land asynchronously, so a
// one-shot timer (the still pipeline's approach) raced them and lost.
// Once per session — a user's stop() is not to be overridden.
property bool restoreConsumed: false
function tryRestore(): void {
if (root.restoreConsumed || root.active || !root.available)
return;
const video = String(DesktopPreferences.get("videoWallpaperPath") || "");
if (!root.isVideo(video))
return;
root.restoreConsumed = true;
root.start(video);
}
onAvailableChanged: root.tryRestore()
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { root.tryRestore(); }
}
Component.onCompleted: {
root.rescan();
root.tryRestore();
}
} }
+9 -7
View File
@@ -128,11 +128,11 @@ Singleton {
onTriggered: { onTriggered: {
if (!root.startupRestoreEnabled) if (!root.startupRestoreEnabled)
return; return;
// A stored video wallpaper restores through mpvpaper; handing its // A stored video wallpaper restores through mpvpaper — the video
// path to the hyprpaper transaction would only fail validation. // service owns that reactively (VideoWallpaper.tryRestore), so
if (DesktopPreferences.get("wallpaperMode") === "single" // this only avoids fighting it with a still policy.
&& VideoWallpaper.isVideo(root.configured)) { if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) {
VideoWallpaper.start(root.configured); VideoWallpaper.tryRestore();
return; return;
} }
root.applyCurrentPolicy(false); root.applyCurrentPolicy(false);
@@ -398,10 +398,11 @@ Singleton {
return false; return false;
} }
root.lastError = ""; root.lastError = "";
DesktopPreferences.set("wallpaperMode", "single"); DesktopPreferences.set("videoWallpaperPath", path);
DesktopPreferences.set("wallpaperPath", path);
return true; return true;
} }
if (DesktopPreferences.get("videoWallpaperPath") !== "")
DesktopPreferences.set("videoWallpaperPath", "");
const effectivePath = path === "" ? root.shippedPath : path; const effectivePath = path === "" ? root.shippedPath : path;
const allowed = root.candidates([]); const allowed = root.candidates([]);
if (!WallpaperPolicy.validPath(effectivePath, allowed)) { if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
@@ -454,6 +455,7 @@ Singleton {
policy.slideshowPath = collection[0] || policy.globalPath; policy.slideshowPath = collection[0] || policy.globalPath;
} }
if (VideoWallpaper.active) { if (VideoWallpaper.active) {
DesktopPreferences.set("videoWallpaperPath", "");
VideoWallpaper.stop(); VideoWallpaper.stop();
root.pendingStillPolicy = policy; root.pendingStillPolicy = policy;
stillAfterVideo.restart(); stillAfterVideo.restart();
+25
View File
@@ -477,6 +477,31 @@ ShellRoot {
} }
} }
// Video wallpapers: what the bar pill and Settings do, reachable from a
// terminal and from test harnesses. start() routes through Wallpaper so a
// video rides wallpaperPath exactly like a click in the picker.
IpcHandler {
target: "video-wallpaper"
function start(path: string): void { Wallpaper.setSingle(path); }
function stop(): void { VideoWallpaper.stop(); }
function pause(): void { VideoWallpaper.manuallyPaused = true; }
function resume(): void { VideoWallpaper.manuallyPaused = false; }
function rescan(): void { VideoWallpaper.rescan(); }
function status(): string {
return JSON.stringify({
available: VideoWallpaper.available,
active: VideoWallpaper.active,
path: VideoWallpaper.path,
paused: VideoWallpaper.paused,
manuallyPaused: VideoWallpaper.manuallyPaused,
gamePaused: VideoWallpaper.gamePaused,
batteryPaused: VideoWallpaper.batteryPaused,
candidates: VideoWallpaper.candidates.length,
lastError: VideoWallpaper.lastError
});
}
}
IpcHandler { IpcHandler {
target: "settings" target: "settings"
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); } function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
+13 -1
View File
@@ -98,8 +98,20 @@ PY
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option" rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
done done
rg -Fq '"mpvpaper", "-f", "-o",' "$service" \ rg -Fq '"mpvpaper", "-o",' "$service" \
|| fail 'the mpvpaper invocation is no longer a fixed argv' || fail 'the mpvpaper invocation is no longer a fixed argv'
# -f is banned outright: it forks mpvpaper into the background, which reads to
# the supervisor as an instant death — the respawn loop it caused flashed the
# desktop black once a second until the flag was removed.
if rg -Fq '"mpvpaper", "-f"' "$service"; then
fail 'mpvpaper is launched with -f again; the supervisor needs it foreground'
fi
# Restore is the service's own, reactive and once per session — a one-shot
# timer raced the async preference load and the availability probe at cold
# start and silently lost.
for needle in 'function tryRestore' 'restoreConsumed' 'videoWallpaperPath'; do
rg -Fq "$needle" "$service" || fail "startup restore lost its $needle"
done
rg -Fq 'command -v mpvpaper' "$service" \ rg -Fq 'command -v mpvpaper' "$service" \
|| fail 'the shell never checks whether mpvpaper is installed' || fail 'the shell never checks whether mpvpaper is installed'
rg -Fq 'mpvpaper' "$packages" \ rg -Fq 'mpvpaper' "$packages" \