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
314 lines
12 KiB
QML
314 lines
12 KiB
QML
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)
|
|
// 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;
|
|
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;
|
|
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;
|
|
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()
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 {
|
|
id: playerComponent
|
|
|
|
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.
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
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 — 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();
|
|
}
|
|
}
|