Files
Panama/config/dot/quickshell/services/VideoWallpaper.qml
T
Gabriel Brown f8f5b25510 Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 04:28:20 -04:00

322 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),
// 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 — a user's stop() is not to be overridden.
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();
}
}