420 lines
17 KiB
QML
420 lines
17 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; 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();
|
|
}
|
|
}
|