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",
|
label: "Video wallpaper folder",
|
||||||
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
|
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",
|
key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
|
||||||
label: "Pause video wallpaper on battery",
|
label: "Pause video wallpaper on battery",
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ def check_video_wallpaper(config: DoctorConfig) -> Check:
|
|||||||
video = ""
|
video = ""
|
||||||
try:
|
try:
|
||||||
stored = json.loads(settings_path.read_text())
|
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):
|
if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE):
|
||||||
video = candidate
|
video = candidate
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
|||||||
@@ -145,7 +145,14 @@ load_preferences() {
|
|||||||
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
||||||
|| wallpaper_mode=single
|
|| wallpaper_mode=single
|
||||||
wallpaper_warning=""
|
wallpaper_warning=""
|
||||||
|
# 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 '')"
|
resolve_wallpaper_path "$(read_string wallpaperPath '')"
|
||||||
|
fi
|
||||||
wallpaper_path="$resolved_wallpaper"
|
wallpaper_path="$resolved_wallpaper"
|
||||||
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
|
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ Singleton {
|
|||||||
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
|
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
|
||||||
|
|
||||||
readonly property var outputs: Quickshell.screens.map(screen => screen.name)
|
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 {
|
function videoDir(): string {
|
||||||
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
|
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;
|
// 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
|
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is
|
||||||
// named explicitly so a silent software-decode fallback cannot hide.
|
// 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 {
|
function mpvpaperCommand(output: string): var {
|
||||||
return ["mpvpaper", "-f", "-o",
|
return ["mpvpaper", "-o",
|
||||||
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
|
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
|
||||||
+ "input-ipc-server=" + root.socketFor(output),
|
+ "input-ipc-server=" + root.socketFor(output),
|
||||||
"-p", "-a", "FULL",
|
"-p", "-a", "FULL",
|
||||||
@@ -132,11 +138,17 @@ Singleton {
|
|||||||
function stop(): void {
|
function stop(): void {
|
||||||
if (!root.active)
|
if (!root.active)
|
||||||
return;
|
return;
|
||||||
|
root.restoreConsumed = true;
|
||||||
root.path = "";
|
root.path = "";
|
||||||
root.manuallyPaused = false;
|
root.manuallyPaused = false;
|
||||||
playerRespawn.stop();
|
playerRespawn.stop();
|
||||||
for (const player of root.players)
|
spawnDelay.stop();
|
||||||
|
for (const player of root.players) {
|
||||||
|
player.retiring = true;
|
||||||
player.running = false;
|
player.running = false;
|
||||||
|
}
|
||||||
|
root.players = [];
|
||||||
|
reaper.running = true;
|
||||||
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
||||||
hyprpaperControl.running = true;
|
hyprpaperControl.running = true;
|
||||||
Qt.callLater(() => Wallpaper.refreshActive());
|
Qt.callLater(() => Wallpaper.refreshActive());
|
||||||
@@ -152,9 +164,35 @@ Singleton {
|
|||||||
onTriggered: root.spawnPlayers()
|
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 {
|
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;
|
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 = [];
|
const spawned = [];
|
||||||
for (const output of root.outputs) {
|
for (const output of root.outputs) {
|
||||||
const player = playerComponent.createObject(root, { output: output });
|
const player = playerComponent.createObject(root, { output: output });
|
||||||
@@ -165,6 +203,7 @@ Singleton {
|
|||||||
root.players = spawned;
|
root.players = spawned;
|
||||||
pauseSync.restart();
|
pauseSync.restart();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: playerComponent
|
id: playerComponent
|
||||||
@@ -172,17 +211,20 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: player
|
id: player
|
||||||
property string output: ""
|
property string output: ""
|
||||||
|
property bool retiring: false
|
||||||
onExited: {
|
onExited: {
|
||||||
// A dead player while a video is meant to be active is a
|
// A dead player while a video is meant to be active is a
|
||||||
// crash (mpvpaper has a known hotplug segfault): respawn the
|
// crash (mpvpaper has a known hotplug segfault): respawn the
|
||||||
// whole set after a beat rather than reasoning per-output.
|
// 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();
|
playerRespawn.restart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onOutputsChanged: if (root.active) playerRespawn.restart()
|
onOutputSignatureChanged: if (root.active) playerRespawn.restart()
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: hyprpaperControl
|
id: hyprpaperControl
|
||||||
@@ -240,5 +282,32 @@ Singleton {
|
|||||||
root.manuallyPaused = !root.manuallyPaused;
|
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: {
|
onTriggered: {
|
||||||
if (!root.startupRestoreEnabled)
|
if (!root.startupRestoreEnabled)
|
||||||
return;
|
return;
|
||||||
// A stored video wallpaper restores through mpvpaper; handing its
|
// A stored video wallpaper restores through mpvpaper — the video
|
||||||
// path to the hyprpaper transaction would only fail validation.
|
// service owns that reactively (VideoWallpaper.tryRestore), so
|
||||||
if (DesktopPreferences.get("wallpaperMode") === "single"
|
// this only avoids fighting it with a still policy.
|
||||||
&& VideoWallpaper.isVideo(root.configured)) {
|
if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) {
|
||||||
VideoWallpaper.start(root.configured);
|
VideoWallpaper.tryRestore();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.applyCurrentPolicy(false);
|
root.applyCurrentPolicy(false);
|
||||||
@@ -398,10 +398,11 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
DesktopPreferences.set("wallpaperMode", "single");
|
DesktopPreferences.set("videoWallpaperPath", path);
|
||||||
DesktopPreferences.set("wallpaperPath", path);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (DesktopPreferences.get("videoWallpaperPath") !== "")
|
||||||
|
DesktopPreferences.set("videoWallpaperPath", "");
|
||||||
const effectivePath = path === "" ? root.shippedPath : path;
|
const effectivePath = path === "" ? root.shippedPath : path;
|
||||||
const allowed = root.candidates([]);
|
const allowed = root.candidates([]);
|
||||||
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||||
@@ -454,6 +455,7 @@ Singleton {
|
|||||||
policy.slideshowPath = collection[0] || policy.globalPath;
|
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||||
}
|
}
|
||||||
if (VideoWallpaper.active) {
|
if (VideoWallpaper.active) {
|
||||||
|
DesktopPreferences.set("videoWallpaperPath", "");
|
||||||
VideoWallpaper.stop();
|
VideoWallpaper.stop();
|
||||||
root.pendingStillPolicy = policy;
|
root.pendingStillPolicy = policy;
|
||||||
stillAfterVideo.restart();
|
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 {
|
IpcHandler {
|
||||||
target: "settings"
|
target: "settings"
|
||||||
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
||||||
|
|||||||
@@ -98,8 +98,20 @@ PY
|
|||||||
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
||||||
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
||||||
done
|
done
|
||||||
rg -Fq '"mpvpaper", "-f", "-o",' "$service" \
|
rg -Fq '"mpvpaper", "-o",' "$service" \
|
||||||
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
||||||
|
# -f is banned outright: it forks mpvpaper into the background, which reads to
|
||||||
|
# the supervisor as an instant death — the respawn loop it caused flashed the
|
||||||
|
# desktop black once a second until the flag was removed.
|
||||||
|
if rg -Fq '"mpvpaper", "-f"' "$service"; then
|
||||||
|
fail 'mpvpaper is launched with -f again; the supervisor needs it foreground'
|
||||||
|
fi
|
||||||
|
# Restore is the service's own, reactive and once per session — a one-shot
|
||||||
|
# timer raced the async preference load and the availability probe at cold
|
||||||
|
# start and silently lost.
|
||||||
|
for needle in 'function tryRestore' 'restoreConsumed' 'videoWallpaperPath'; do
|
||||||
|
rg -Fq "$needle" "$service" || fail "startup restore lost its $needle"
|
||||||
|
done
|
||||||
rg -Fq 'command -v mpvpaper' "$service" \
|
rg -Fq 'command -v mpvpaper' "$service" \
|
||||||
|| fail 'the shell never checks whether mpvpaper is installed'
|
|| fail 'the shell never checks whether mpvpaper is installed'
|
||||||
rg -Fq 'mpvpaper' "$packages" \
|
rg -Fq 'mpvpaper' "$packages" \
|
||||||
|
|||||||
Reference in New Issue
Block a user