pragma Singleton // Video wallpapers, played by mpvpaper — one supervised process per output, // hardware-decoded, muted, looped. Panama owns the pause policy, because the // compositor cannot be trusted with it: Hyprland rebuilds the full-screen blur // chain on every wallpaper frame while any blur-enabled layer exists, and an // occluded wallpaper keeps compositing unless solitary mode holds — which a // single toast breaks. So the wallpaper pauses itself whenever a game runs, // whenever the machine is on battery (if the preference says so), and whenever // the bar pill is clicked, over mpv's JSON IPC socket. // // hyprpaper and mpvpaper both claim the background layer and stacking within a // layer is creation order — a race. So while a video is active hyprpaper's // service is stopped, and stopping the video starts it again; this service then // reapplies the still policy through Wallpaper.qml once it returns. // // mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak // workaround. Known upstream sharp edges — a hotplug segfault and a // mitigated-not-fixed leak — are why the processes are supervised and simply // respawned rather than trusted. import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-video-wallpaper" property bool available: false // mpvpaper on PATH, checked once property bool scanned: false property var candidates: [] // newest-60 videos in videoWallpaperDir property string path: "" // the active video, "" when inactive readonly property bool active: root.path !== "" property string lastError: "" // Pause reasons, each independent; the video plays only when none holds. property bool manuallyPaused: false readonly property bool gamePaused: FocusModes.gameRunning readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true && Battery.available && !Battery.acOnline readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== "" ? Quickshell.env("XDG_STATE_HOME") + "/panama" : Quickshell.env("HOME") + "/.local/state/panama" // The lock screen cannot play video; panama-lock uses this still instead. 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"); if (stored.startsWith("/")) return stored; if (stored.startsWith("~/")) return Quickshell.env("HOME") + stored.slice(1); return Quickshell.env("HOME") + "/" + stored; } function isVideo(candidate: string): bool { return /\.(mp4|mkv|webm)$/i.test(String(candidate)); } function socketFor(output: string): string { return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock"; } // ── Discovery ─────────────────────────────────────────────────────────── function rescan(): void { if (!scanProc.running) { scanProc.command = [root.helperPath, "scan", root.videoDir()]; scanProc.running = true; } } Process { id: scanProc stdout: StdioCollector { onStreamFinished: { root.candidates = this.text.split("\n").filter(line => line !== ""); root.scanned = true; } } } Process { id: probeProc command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"] running: true stdout: StdioCollector { onStreamFinished: root.available = this.text.trim() === "yes" } } // ── Playback lifecycle ────────────────────────────────────────────────── // 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", "-o", "hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 " + "input-ipc-server=" + root.socketFor(output), "-p", "-a", "FULL", output, root.path]; } function start(video: string): bool { if (!root.available) { root.lastError = "mpvpaper is not installed — run panama update to pick it up."; return false; } if (!root.isVideo(video)) return false; root.lastError = ""; root.path = video; root.manuallyPaused = false; // A new video starts its own patience; the previous file's crashes are // not evidence about this one. root.crashStreak = 0; hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"]; hyprpaperControl.running = true; frameProc.command = ["sh", "-c", "mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath + "' frame \"$1\" \"$2\"", "frame", video, root.framePath]; frameProc.running = true; playerRespawn.restart(); return true; } function stop(): void { if (!root.active) return; root.restoreConsumed = true; root.path = ""; root.manuallyPaused = false; root.crashStreak = 0; root.teardownPlayers(); // The stored path is the restore path, the picker's "current" ring and // what Wallpaper.setSingle reads back. Leaving it set after a deliberate // stop meant the next login started playing the video again. DesktopPreferences.set("videoWallpaperPath", ""); stillHandback.restart(); } // Everything both stop() and the crash bail-out have to do: no player left // running, no timer armed to start another, and hyprpaper handed back the // layer it owns. function teardownPlayers(): void { playerRespawn.stop(); 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; } // hyprpaper's service needs a beat to be back on the bus before it can be // asked to draw anything — the same settle Wallpaper.setSingle uses for its // own video→still handoff. When that handoff is the reason we are stopping, // it owns the policy (it holds the new one, which is not yet persisted) and // this stays out of the way. Timer { id: stillHandback interval: 300 onTriggered: { if (root.active || Wallpaper.pendingStillPolicy !== null) return; if (!Wallpaper.applyCurrentPolicy(false)) Wallpaper.refreshActive(); } } // Players are created per current output set each (re)spawn, so hotplug // lands on the same path as a crash: kill what runs, spawn fresh. property var players: [] Timer { id: playerRespawn interval: 400 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 { // 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; } root.players = []; reaper.running = true; } // pkill returns when the signal is delivered, not when the process is gone, // and mpvpaper takes a moment to tear its GL context down. So the reaper // waits for the corpses rather than the shell guessing at how long that // takes: TERM, then poll pgrep in 50ms steps, escalating to KILL halfway // through and giving up after two seconds so a wedged process cannot hold // the wallpaper hostage. Process { id: reaper command: ["sh", "-c", ` pkill -x mpvpaper 2>/dev/null || true step=0 while [ "$step" -lt 40 ]; do pgrep -x mpvpaper >/dev/null 2>&1 || exit 0 [ "$step" -eq 20 ] && pkill -9 -x mpvpaper 2>/dev/null sleep 0.05 step=$((step + 1)) done `] onExited: spawnDelay.restart() } // The reaper already waited for the old players to die, so this is only the // hand-back to the event loop, not a guess at how long a kill takes. Timer { id: spawnDelay interval: 50 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; root.playersStartedAt = Date.now(); root.crashCounted = false; pauseSync.restart(); } } // A crash loop has to end somewhere. mpvpaper dying within three seconds of // being spawned is not the hotplug segfault the supervisor exists for — it // is a file it cannot decode or a VAAPI stack that is not there — and // respawning forever flashes the desktop black once a second for as long as // the session lasts, with no error anywhere to explain it. property real playersStartedAt: 0 property int crashStreak: 0 // One count per spawned set: on two monitors a single crash fires two // onExited, and counting each would trip the limit half a cycle early. property bool crashCounted: false readonly property int crashStreakLimit: 3 readonly property int crashWindowMs: 3000 function giveUp(): void { root.path = ""; root.manuallyPaused = false; root.crashStreak = 0; // Nothing may start it again this session, not even a preference write // landing in tryRestore's lap. root.restoreConsumed = true; root.teardownPlayers(); root.lastError = "Video wallpaper kept crashing — check the file and VAAPI decode"; stillHandback.restart(); } Component { id: playerComponent Process { id: player property string output: "" property bool retiring: false onExited: { // The object is spent either way: a Process cannot be restarted // and every respawn builds a fresh one per output, so holding // this one leaked a Process per crash cycle. root.players = root.players.filter(candidate => candidate !== player); Qt.callLater(() => player.destroy()); // 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) return; // 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.crashCounted) { root.crashCounted = true; root.crashStreak = Date.now() - root.playersStartedAt < root.crashWindowMs ? root.crashStreak + 1 : 0; } if (root.crashStreak >= root.crashStreakLimit) { root.giveUp(); return; } if (!playerRespawn.running) playerRespawn.restart(); } } } onOutputSignatureChanged: if (root.active) playerRespawn.restart() Process { id: hyprpaperControl } Process { id: frameProc } // ── Pause, over mpv's JSON IPC ────────────────────────────────────────── onPausedChanged: pauseSync.restart() // The sockets appear a moment after mpvpaper starts; a short retry beats // ordering ceremony. Each sync writes the current desired state to every // output's socket and disconnects. Timer { id: pauseSync interval: 600 onTriggered: root.pushPauseState() } function pushPauseState(): void { if (!root.active) return; for (const output of root.outputs) { const socket = pauseSocketComponent.createObject(root, { socketPath: root.socketFor(output), // Built as a string, not an object literal: a QML-side // `command: [...]` reads to declared-assets-contract's scanner // as a Process launch. payload: JSON.stringify({ "command": ["set_property", "pause", root.paused] }) + "\n" }); socket.connected = true; } } Component { id: pauseSocketComponent Socket { id: pauseSocket property string payload: "" path: socketPath property string socketPath: "" onConnectionStateChanged: { if (connected) { write(payload); flush(); Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); }); } } onError: pauseSocket.destroy() } } function togglePause(): void { root.manuallyPaused = !root.manuallyPaused; } // ── 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, and consumed by stop() and giveUp() as well as by the // restore itself: a preference write is all it takes to re-enter tryRestore, // so neither a video the user turned off nor one that crashed out may come // back under them. stop() also clears the stored path, so there is nothing // left to restore at the next login either. property bool restoreConsumed: false // Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance // must never start playing the user's real wallpaper. property bool startupRestoreEnabled: true function tryRestore(): void { if (!root.startupRestoreEnabled) return; 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(); } }