From 4d7a19430005e84a68ab6e3b0550f74c7e3d733e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 23 Aug 2026 23:57:04 -0400 Subject: [PATCH] Make video wallpapers survive first contact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quickshell/config/PreferenceSchema.qml | 9 ++ config/dot/quickshell/scripts/panama-doctor | 2 +- config/dot/quickshell/scripts/panama-lock | 9 +- .../quickshell/services/VideoWallpaper.qml | 97 ++++++++++++++++--- config/dot/quickshell/services/Wallpaper.qml | 16 +-- config/dot/quickshell/shell.qml | 25 +++++ tests/quickshell/video-wallpaper-contract | 14 ++- 7 files changed, 148 insertions(+), 24 deletions(-) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c8d69df..784c20b 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -980,6 +980,15 @@ Singleton { label: "Video wallpaper folder", 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", label: "Pause video wallpaper on battery", diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 8b68ca2..b818587 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -590,7 +590,7 @@ def check_video_wallpaper(config: DoctorConfig) -> Check: video = "" try: 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): video = candidate except (OSError, ValueError): diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 31f2df0..6f3eec5 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -145,7 +145,14 @@ load_preferences() { [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ || wallpaper_mode=single 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_assignments="$(read_object wallpaperPerMonitor)" diff --git a/config/dot/quickshell/services/VideoWallpaper.qml b/config/dot/quickshell/services/VideoWallpaper.qml index 600af59..5e6800c 100644 --- a/config/dot/quickshell/services/VideoWallpaper.qml +++ b/config/dot/quickshell/services/VideoWallpaper.qml @@ -50,6 +50,9 @@ Singleton { readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png" 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 { 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; // the real pausing happens over the IPC socket below. `hwdec=vaapi` is // 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 { - return ["mpvpaper", "-f", "-o", + return ["mpvpaper", "-o", "hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 " + "input-ipc-server=" + root.socketFor(output), "-p", "-a", "FULL", @@ -132,11 +138,17 @@ Singleton { function stop(): void { if (!root.active) return; + root.restoreConsumed = true; root.path = ""; root.manuallyPaused = false; playerRespawn.stop(); - for (const player of root.players) + spawnDelay.stop(); + for (const player of root.players) { + player.retiring = true; player.running = false; + } + root.players = []; + reaper.running = true; hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"]; hyprpaperControl.running = true; Qt.callLater(() => Wallpaper.refreshActive()); @@ -152,18 +164,45 @@ Singleton { 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 { - 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; - 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(); + root.players = []; + 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 { @@ -172,17 +211,20 @@ Singleton { Process { id: player property string output: "" + property bool retiring: false onExited: { // A dead player while a video is meant to be active is a // crash (mpvpaper has a known hotplug segfault): respawn the // 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(); } } } - onOutputsChanged: if (root.active) playerRespawn.restart() + onOutputSignatureChanged: if (root.active) playerRespawn.restart() Process { id: hyprpaperControl @@ -240,5 +282,32 @@ Singleton { 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(); + } } diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index b1552e9..f8c53da 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -128,11 +128,11 @@ Singleton { onTriggered: { if (!root.startupRestoreEnabled) return; - // A stored video wallpaper restores through mpvpaper; handing its - // path to the hyprpaper transaction would only fail validation. - if (DesktopPreferences.get("wallpaperMode") === "single" - && VideoWallpaper.isVideo(root.configured)) { - VideoWallpaper.start(root.configured); + // A stored video wallpaper restores through mpvpaper — the video + // service owns that reactively (VideoWallpaper.tryRestore), so + // this only avoids fighting it with a still policy. + if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) { + VideoWallpaper.tryRestore(); return; } root.applyCurrentPolicy(false); @@ -398,10 +398,11 @@ Singleton { return false; } root.lastError = ""; - DesktopPreferences.set("wallpaperMode", "single"); - DesktopPreferences.set("wallpaperPath", path); + DesktopPreferences.set("videoWallpaperPath", path); return true; } + if (DesktopPreferences.get("videoWallpaperPath") !== "") + DesktopPreferences.set("videoWallpaperPath", ""); const effectivePath = path === "" ? root.shippedPath : path; const allowed = root.candidates([]); if (!WallpaperPolicy.validPath(effectivePath, allowed)) { @@ -454,6 +455,7 @@ Singleton { policy.slideshowPath = collection[0] || policy.globalPath; } if (VideoWallpaper.active) { + DesktopPreferences.set("videoWallpaperPath", ""); VideoWallpaper.stop(); root.pendingStillPolicy = policy; stillAfterVideo.restart(); diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index e483817..1936fe7 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -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 { target: "settings" function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); } diff --git a/tests/quickshell/video-wallpaper-contract b/tests/quickshell/video-wallpaper-contract index 744f13b..ab7baf6 100755 --- a/tests/quickshell/video-wallpaper-contract +++ b/tests/quickshell/video-wallpaper-contract @@ -98,8 +98,20 @@ PY 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" done -rg -Fq '"mpvpaper", "-f", "-o",' "$service" \ +rg -Fq '"mpvpaper", "-o",' "$service" \ || 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" \ || fail 'the shell never checks whether mpvpaper is installed' rg -Fq 'mpvpaper' "$packages" \