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; Wallpaper.qml // reapplies the still policy 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) 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. function mpvpaperCommand(output: string): var { return ["mpvpaper", "-f", "-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; 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.path = ""; root.manuallyPaused = false; playerRespawn.stop(); for (const player of root.players) player.running = false; hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"]; hyprpaperControl.running = true; Qt.callLater(() => 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() } function spawnPlayers(): void { for (const player of root.players) 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(); } Component { id: playerComponent Process { id: player property string output: "" 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) playerRespawn.restart(); } } } onOutputsChanged: 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), 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; } Component.onCompleted: root.rescan() }