Make video wallpapers survive first contact
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
This commit is contained in:
@@ -980,6 +980,15 @@ Singleton {
|
||||
label: "Video wallpaper folder",
|
||||
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
|
||||
},
|
||||
{
|
||||
// Its own key, not wallpaperPath: the still pipeline persists its
|
||||
// policy transactionally and once clobbered a stored video path.
|
||||
// Two owners, two keys.
|
||||
key: "videoWallpaperPath", type: "string", def: "", group: "wallpaper", internal: true,
|
||||
pattern: "^(|/[^,\n]+)$",
|
||||
label: "Video wallpaper",
|
||||
detail: "The video playing as the desktop background"
|
||||
},
|
||||
{
|
||||
key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
|
||||
label: "Pause video wallpaper on battery",
|
||||
|
||||
@@ -590,7 +590,7 @@ def check_video_wallpaper(config: DoctorConfig) -> Check:
|
||||
video = ""
|
||||
try:
|
||||
stored = json.loads(settings_path.read_text())
|
||||
candidate = str(stored.get("wallpaperPath", ""))
|
||||
candidate = str(stored.get("videoWallpaperPath", ""))
|
||||
if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE):
|
||||
video = candidate
|
||||
except (OSError, ValueError):
|
||||
|
||||
@@ -145,7 +145,14 @@ load_preferences() {
|
||||
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
||||
|| wallpaper_mode=single
|
||||
wallpaper_warning=""
|
||||
resolve_wallpaper_path "$(read_string wallpaperPath '')"
|
||||
# A video wallpaper lives under its own key; when one is set it wins,
|
||||
# standing in as its cached still frame via resolve_wallpaper_path.
|
||||
video_wallpaper="$(read_string videoWallpaperPath '')"
|
||||
if [[ -n "$video_wallpaper" ]]; then
|
||||
resolve_wallpaper_path "$video_wallpaper"
|
||||
else
|
||||
resolve_wallpaper_path "$(read_string wallpaperPath '')"
|
||||
fi
|
||||
wallpaper_path="$resolved_wallpaper"
|
||||
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ Singleton {
|
||||
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");
|
||||
@@ -101,8 +104,11 @@ Singleton {
|
||||
// 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", "-f", "-o",
|
||||
return ["mpvpaper", "-o",
|
||||
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
|
||||
+ "input-ipc-server=" + root.socketFor(output),
|
||||
"-p", "-a", "FULL",
|
||||
@@ -132,11 +138,17 @@ Singleton {
|
||||
function stop(): void {
|
||||
if (!root.active)
|
||||
return;
|
||||
root.restoreConsumed = true;
|
||||
root.path = "";
|
||||
root.manuallyPaused = false;
|
||||
playerRespawn.stop();
|
||||
for (const player of root.players)
|
||||
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());
|
||||
@@ -152,18 +164,45 @@ Singleton {
|
||||
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 {
|
||||
for (const player of root.players)
|
||||
// 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;
|
||||
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();
|
||||
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 {
|
||||
@@ -172,17 +211,20 @@ Singleton {
|
||||
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.
|
||||
if (root.active && !playerRespawn.running)
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOutputsChanged: if (root.active) playerRespawn.restart()
|
||||
onOutputSignatureChanged: if (root.active) playerRespawn.restart()
|
||||
|
||||
Process {
|
||||
id: hyprpaperControl
|
||||
@@ -240,5 +282,32 @@ Singleton {
|
||||
root.manuallyPaused = !root.manuallyPaused;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.rescan()
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,11 @@ Singleton {
|
||||
onTriggered: {
|
||||
if (!root.startupRestoreEnabled)
|
||||
return;
|
||||
// A stored video wallpaper restores through mpvpaper; handing its
|
||||
// path to the hyprpaper transaction would only fail validation.
|
||||
if (DesktopPreferences.get("wallpaperMode") === "single"
|
||||
&& VideoWallpaper.isVideo(root.configured)) {
|
||||
VideoWallpaper.start(root.configured);
|
||||
// A stored video wallpaper restores through mpvpaper — the video
|
||||
// service owns that reactively (VideoWallpaper.tryRestore), so
|
||||
// this only avoids fighting it with a still policy.
|
||||
if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) {
|
||||
VideoWallpaper.tryRestore();
|
||||
return;
|
||||
}
|
||||
root.applyCurrentPolicy(false);
|
||||
@@ -398,10 +398,11 @@ Singleton {
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperMode", "single");
|
||||
DesktopPreferences.set("wallpaperPath", path);
|
||||
DesktopPreferences.set("videoWallpaperPath", path);
|
||||
return true;
|
||||
}
|
||||
if (DesktopPreferences.get("videoWallpaperPath") !== "")
|
||||
DesktopPreferences.set("videoWallpaperPath", "");
|
||||
const effectivePath = path === "" ? root.shippedPath : path;
|
||||
const allowed = root.candidates([]);
|
||||
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||
@@ -454,6 +455,7 @@ Singleton {
|
||||
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||
}
|
||||
if (VideoWallpaper.active) {
|
||||
DesktopPreferences.set("videoWallpaperPath", "");
|
||||
VideoWallpaper.stop();
|
||||
root.pendingStillPolicy = policy;
|
||||
stillAfterVideo.restart();
|
||||
|
||||
@@ -477,6 +477,31 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
// Video wallpapers: what the bar pill and Settings do, reachable from a
|
||||
// terminal and from test harnesses. start() routes through Wallpaper so a
|
||||
// video rides wallpaperPath exactly like a click in the picker.
|
||||
IpcHandler {
|
||||
target: "video-wallpaper"
|
||||
function start(path: string): void { Wallpaper.setSingle(path); }
|
||||
function stop(): void { VideoWallpaper.stop(); }
|
||||
function pause(): void { VideoWallpaper.manuallyPaused = true; }
|
||||
function resume(): void { VideoWallpaper.manuallyPaused = false; }
|
||||
function rescan(): void { VideoWallpaper.rescan(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
available: VideoWallpaper.available,
|
||||
active: VideoWallpaper.active,
|
||||
path: VideoWallpaper.path,
|
||||
paused: VideoWallpaper.paused,
|
||||
manuallyPaused: VideoWallpaper.manuallyPaused,
|
||||
gamePaused: VideoWallpaper.gamePaused,
|
||||
batteryPaused: VideoWallpaper.batteryPaused,
|
||||
candidates: VideoWallpaper.candidates.length,
|
||||
lastError: VideoWallpaper.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "settings"
|
||||
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
||||
|
||||
Reference in New Issue
Block a user