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" \