From d60264e8a25bb6a88636649a1c2c98185f927062 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Thu, 2 Jul 2026 00:18:45 +0100 Subject: [PATCH 1/3] refactor(mpvpaper): Rewriting multiple features --- mpvpaper/README.md | 16 +- mpvpaper/mpvpaper_service.luau | 424 +++++++++++++++++++++++++-------- mpvpaper/panel.luau | 66 +++-- mpvpaper/plugin.toml | 58 +++++ mpvpaper/translations/en.json | 13 +- 5 files changed, 452 insertions(+), 125 deletions(-) diff --git a/mpvpaper/README.md b/mpvpaper/README.md index 873e47e..dacd1bd 100644 --- a/mpvpaper/README.md +++ b/mpvpaper/README.md @@ -24,10 +24,16 @@ The plugin works on `wlr-layer-shell` compositors (Niri, Hyprland, Sway, Mango). Assignments persist across restarts. Supported files: `mp4`, `webm`, `mkv`, `mov`, `gif`. +## IPC Commands + +You can control the video wallpaper externally via Noctalia's IPC mechanism. Replace `[connector]` with your display name (e.g. `DP-1`), or omit it to target all monitors: + +- `noctalia msg plugin noctalia/mpvpaper:service all pause [connector]` - Pauses playback via cgroups freezer. +- `noctalia msg plugin noctalia/mpvpaper:service all resume [connector]` - Resumes playback. +- `noctalia msg plugin noctalia/mpvpaper:service all toggle [connector]` - Toggles playback between paused and resumed state. +- `noctalia msg plugin noctalia/mpvpaper:service all clear ` - Stops the wallpaper on the specified monitor and extracts a frame as a static wallpaper. +- `noctalia msg plugin noctalia/mpvpaper:service all clear-all` - Stops all active video wallpapers. + ## How it works -A headless service supervises one long-lived helper process (`supervisor.sh`) that owns -every `mpvpaper` instance, one per output. The picker panel and bar widget are thin -clients that drive the service through the plugin's shared state. When the plugin is -disabled or Noctalia exits, the supervisor and all `mpvpaper` instances are torn down -together. +A headless service natively supervises `mpvpaper` instances (one per output), either launching them directly or wrapping them in systemd transient scopes (`systemd-run`) for strict CPU and memory resource limits. The picker panel and bar widget are thin clients that drive the service through the plugin's shared state. When the plugin is disabled or Noctalia exits, all `mpvpaper` instances are gracefully torn down together. diff --git a/mpvpaper/mpvpaper_service.luau b/mpvpaper/mpvpaper_service.luau index 3937f98..66f1825 100644 --- a/mpvpaper/mpvpaper_service.luau +++ b/mpvpaper/mpvpaper_service.luau @@ -1,202 +1,418 @@ --!nonstrict -- Video wallpaper — headless service. --- Owns the mpvpaper supervisor (one long-lived helper process driven through a FIFO) --- and the per-output video assignments. The picker panel drives it through the shared --- "command" state channel; the service publishes "assignments"/"available" back so the --- panel and widget can mirror the current state. +-- This service handles active video wallpaper assignments and lifecycle. +-- Note: Noctalia plugin scripts run in an isolated sandbox without `require`. +-- Therefore, all logic (supervisor, IPC, state management) must reside here. noctalia.setUpdateInterval(1000) -local RUNTIME = noctalia.getenv("XDG_RUNTIME_DIR") or "/tmp" -local FIFO = RUNTIME .. "/noctalia-mpvpaper.fifo" +-- ============================================================================== +-- ── PATHS AND STATE ──────────────────────────────────────────────────────── +-- ============================================================================== + +local CACHE_DIR = (noctalia.getenv("XDG_CACHE_HOME") or ((noctalia.getenv("HOME") or "") .. "/.cache")) .. "/noctalia/mpvpaper" local STATE_DIR = (noctalia.getenv("XDG_STATE_HOME") or ((noctalia.getenv("HOME") or "") .. "/.local/state")) .. "/noctalia/mpvpaper" local STATE_FILE = STATE_DIR .. "/assignments.json" -local assignments = {} -- connector -> video path -local lastPresent = {} -- connector -> true, outputs seen on the last reconcile -local pending = {} -- FIFO command lines awaiting a ready supervisor -local available = false -local supervisorStarted = false +local assignments = {} -- Map of connector -> video path ("*" for all outputs) +local lastPresent = {} -- Map of connector -> true (monitors seen last tick) +local isPaused = {} -- Map of connector -> true (paused monitors) +local available = false -- Is mpvpaper binary installed? --- ── Helpers ────────────────────────────────────────────────────────────── +-- ============================================================================== +-- ── HELPERS ──────────────────────────────────────────────────────────────── +-- ============================================================================== local function cfg(key) return noctalia.getConfig(key) end +-- Safely quotes strings for shell execution to prevent injection. local function shellQuote(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end +-- Returns the systemd unit name for a given connector. +-- E.g., "*" -> "mpvpaper.scope", "DP-1" -> "mpvpaper-DP-1.scope". +local function unitNameFor(connector) + if connector == "*" then + return "mpvpaper.scope" + end + return "mpvpaper-" .. connector:gsub("[^%w%-]", "_") .. ".scope" +end + +-- Executes a function for every affected physical output. +-- Avoids massive code duplication for wildcard ("*") assignments. +local function forEachOutput(connector, fn) + if connector == "*" then + for _, output in ipairs(noctalia.outputs()) do + fn(output.name) + end + else + fn(connector) + end +end + +-- ============================================================================== +-- ── STATE PERSISTENCE ────────────────────────────────────────────────────── +-- ============================================================================== + +local function persist() + noctalia.runAsync("mkdir -p " .. shellQuote(STATE_DIR)) + noctalia.writeFile(STATE_FILE, noctalia.json.encode(assignments) or "") +end + +local function loadAssignments() + local content = noctalia.readFile(STATE_FILE) + if type(content) == "string" and content ~= "" then + local decoded = noctalia.json.decode(content) + if type(decoded) == "table" then assignments = decoded end + end +end + +-- Publishes the internal state so the panel can read it (e.g., to render paused UI). +local function publish() + noctalia.state.set("assignments", assignments) + noctalia.state.set("available", available) + noctalia.state.set("paused", isPaused) +end + +-- ============================================================================== +-- ── COMMAND BUILDERS ─────────────────────────────────────────────────────── +-- ============================================================================== + +-- Builds the -o options string forwarded to the internal mpv process. local function mpvOptions() local parts = { "loop-file=inf", "panscan=1.0" } - if cfg("mute") then - table.insert(parts, "no-audio") - end + if cfg("mute") then table.insert(parts, "no-audio") end table.insert(parts, "hwdec=" .. (cfg("hardware_decode") and "auto" or "no")) + local extra = cfg("mpv_options") - if type(extra) == "string" and extra ~= "" then - table.insert(parts, extra) - end + if type(extra) == "string" and extra ~= "" then table.insert(parts, extra) end return table.concat(parts, " ") end local function mpvpaperFlags() - if cfg("auto_pause") then - return "--auto-pause" - end + if cfg("auto_pause") then return "--auto-pause" end return "" end --- Write any queued commands once the supervisor has created the FIFO. Small line --- writes to a pipe are atomic, so concurrent runAsync writes never interleave. -local function flush() - if not noctalia.fileExists(FIFO) then +-- Collects systemd-specific cgroup properties (e.g., CPUQuota). +local function systemdProperties() + local props = {} + local cpu_quota = cfg("cpu_quota") + if type(cpu_quota) == "number" and cpu_quota > 0 then table.insert(props, "CPUQuota=" .. tostring(cpu_quota) .. "%") end + + local allowed_cpus = cfg("allowed_cpus") + if type(allowed_cpus) == "string" and allowed_cpus ~= "" then table.insert(props, "AllowedCPUs=" .. allowed_cpus) end + + local memory_max = cfg("memory_max") + if type(memory_max) == "string" and memory_max ~= "" then table.insert(props, "MemoryMax=" .. memory_max) end + + local cpu_weight = cfg("cpu_weight") + if type(cpu_weight) == "number" and cpu_weight > 0 then table.insert(props, "CPUWeight=" .. tostring(cpu_weight)) end + + return props +end + +-- ============================================================================== +-- ── THUMBNAILS & FRAME EXTRACTION ────────────────────────────────────────── +-- ============================================================================== + +-- When systemd is disabled, we cannot freeze the process. We fallback to killing +-- the video and extracting a static frame to use as the Noctalia wallpaper. +local function extractStaticWallpaper(connector, videoPath) + if not cfg("extract_last_frame") then + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) return end - for _, line in ipairs(pending) do - noctalia.runAsync("printf '%s\\n' " .. shellQuote(line) .. " > " .. shellQuote(FIFO)) - end - pending = {} -end -local function fifo(line) - table.insert(pending, line) - flush() + noctalia.runAsync("mkdir -p " .. shellQuote(CACHE_DIR), function() + local outPath = CACHE_DIR .. "/" .. connector:gsub("[^%w%-]", "_") .. "_static.jpg" + local cmd = "ffmpeg -y -ss 1 -i " .. shellQuote(videoPath) .. " -frames:v 1 -q:v 2 " .. shellQuote(outPath) + + noctalia.runAsync(cmd, function(success) + if success and noctalia.fileExists(outPath) then + forEachOutput(connector, function(name) + noctalia.setWallpaper(name, outPath) + noctalia.setWallpaperEnabled(name, false) + end) + else + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) + end + end) + end) end -local function startSupervisor() - if supervisorStarted then - return +-- ============================================================================== +-- ── PROCESS MANAGEMENT ───────────────────────────────────────────────────── +-- ============================================================================== + +-- Kills the mpvpaper process. +-- Note on pkill: The sandboxed noctalia.runAsync API does not return PIDs. Thus, +-- without systemd, matching by command line via pkill is the only reliable way to terminate it. +local function killMpvpaper(connector, callback) + local killCmd + if cfg("run_as_systemd") then + local unitName = unitNameFor(connector) + if isPaused[connector] then + -- A frozen cgroup ignores SIGTERM. We must thaw it right before stopping. + killCmd = "systemctl --user thaw " .. shellQuote(unitName) .. " ; systemctl --user stop " .. shellQuote(unitName) + else + killCmd = "systemctl --user stop " .. shellQuote(unitName) + end + else + killCmd = "pkill -f " .. shellQuote("mpvpaper .*" .. connector) end - local script = (noctalia.pluginDir() or "") .. "/supervisor.sh" - local cmd = string.format( - "exec sh %s %s %s %s", - shellQuote(script), shellQuote(FIFO), shellQuote(mpvpaperFlags()), shellQuote(mpvOptions()) - ) - supervisorStarted = noctalia.runStream(cmd, function(_) end) - if not supervisorStarted then - noctalia.notifyError("Video Wallpaper", "Could not start the mpvpaper supervisor") + + isPaused[connector] = false + + if callback then + noctalia.runAsync(killCmd, callback) + else + noctalia.runAsync(killCmd) end end -local function persist() - noctalia.runAsync("mkdir -p " .. shellQuote(STATE_DIR)) - noctalia.writeFile(STATE_FILE, noctalia.json.encode(assignments) or "") +-- Spawns a new mpvpaper instance for the given connector. +local function startMpvpaper(connector, path) + -- Temporarily set the cached thumbnail as the background to prevent black screens during startup. + local thumb = CACHE_DIR .. "/" .. path:gsub("[^%w]", "_") .. ".jpg" + if noctalia.fileExists(thumb) then + forEachOutput(connector, function(name) noctalia.setWallpaper(name, thumb) end) + end + + killMpvpaper(connector, function() + local flags = mpvpaperFlags() + local cmd = "mpvpaper " + if flags ~= "" then cmd = cmd .. flags .. " " end + cmd = cmd .. "-p " .. shellQuote(connector) .. " " .. shellQuote(path) .. " -o " .. shellQuote(mpvOptions()) + + local nice = cfg("nice") + if type(nice) == "number" and nice ~= 0 then + cmd = "nice -n " .. tostring(nice) .. " " .. cmd + end + + if cfg("run_as_systemd") then + local unitName = unitNameFor(connector) + local propsCmd = "" + for _, prop in ipairs(systemdProperties()) do + propsCmd = propsCmd .. "--property=" .. shellQuote(prop) .. " " + end + cmd = "systemd-run --user --scope " .. propsCmd .. "--unit=" .. shellQuote(unitName) .. " " .. cmd + end + + noctalia.runAsync("sh -c " .. shellQuote(cmd .. " >/dev/null 2>&1 &")) + end) end -local function loadAssignments() - local content = noctalia.readFile(STATE_FILE) - if type(content) == "string" and content ~= "" then - local decoded = noctalia.json.decode(content) - if type(decoded) == "table" then - assignments = decoded +-- ============================================================================== +-- ── PAUSE / RESUME / TOGGLE ──────────────────────────────────────────────── +-- ============================================================================== + +local function freezeMpvpaper(connector) + isPaused[connector] = true + if cfg("run_as_systemd") then + local unitName = unitNameFor(connector) + noctalia.runAsync("systemctl --user freeze " .. shellQuote(unitName)) + else + local path = assignments[connector] + if path then + killMpvpaper(connector, function() + extractStaticWallpaper(connector, path) + end) end end end -local function publish() - noctalia.state.set("assignments", assignments) - noctalia.state.set("available", available) +local function thawMpvpaper(connector) + isPaused[connector] = false + if cfg("run_as_systemd") then + local unitName = unitNameFor(connector) + noctalia.runAsync("systemctl --user thaw " .. shellQuote(unitName)) + else + local path = assignments[connector] + if path then + startMpvpaper(connector, path) + end + end end --- ── Commands ────────────────────────────────────────────────────────────── - -local function setVideo(connector, path) - if not available or type(connector) ~= "string" or connector == "" or type(path) ~= "string" or path == "" then - return +local function toggleMpvpaper(connector) + if isPaused[connector] then + thawMpvpaper(connector) + else + freezeMpvpaper(connector) end - assignments[connector] = path +end + +-- ============================================================================== +-- ── CORE ASSIGNMENT LOGIC ────────────────────────────────────────────────── +-- ============================================================================== + +-- Clears an assigned video and restores the host wallpaper. +local function clearVideo(connector) + if type(connector) ~= "string" or connector == "" then return end + + local path = assignments[connector] + assignments[connector] = nil persist() - noctalia.setWallpaperEnabled(connector, false) - fifo("set " .. connector .. " " .. path) + + killMpvpaper(connector, function() + if path then + extractStaticWallpaper(connector, path) + else + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) + end + end) + publish() end -local function clearVideo(connector) - if type(connector) ~= "string" or connector == "" then - return +-- Assigns a video to a specific monitor or globally ("*"). +local function setVideo(connector, path) + if not available or type(connector) ~= "string" or connector == "" or type(path) ~= "string" or path == "" then return end + + -- Resolve overlaps: clear specific assignments when applying globally, and vice versa. + if connector == "*" then + for conn in pairs(assignments) do + if conn ~= "*" then clearVideo(conn) end + end + else + if assignments["*"] then clearVideo("*") end end - assignments[connector] = nil + + assignments[connector] = path persist() - noctalia.setWallpaperEnabled(connector, true) - fifo("clear " .. connector) + + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) + startMpvpaper(connector, path) + publish() end --- ── Lifecycle ────────────────────────────────────────────────────────────── +-- ============================================================================== +-- ── LIFECYCLE ────────────────────────────────────────────────────────────── +-- ============================================================================== +-- Returns a quick dictionary of currently attached physical monitors. local function presentOutputs() local present = {} - for _, output in ipairs(noctalia.outputs()) do - present[output.name] = true - end + for _, output in ipairs(noctalia.outputs()) do present[output.name] = true end return present end --- Apply every persisted assignment. mpvpaper simply exits for a not-yet-connected --- output; onOutputsChanged re-applies when it appears. +-- Applies saved video assignments upon plugin startup. local function applyAll() for connector, path in pairs(assignments) do - noctalia.setWallpaperEnabled(connector, false) - fifo("set " .. connector .. " " .. path) + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) + startMpvpaper(connector, path) end end -function update() - flush() -end +function update() end --- Relaunch mpvpaper for an assigned output only when it (re)appears, so a routine --- geometry change does not restart playback. +-- Triggers when a monitor connects or disconnects. function onOutputsChanged() - if not available then - return - end + if not available then return end local present = presentOutputs() + + local newMonitor = false + for conn in pairs(present) do + if not lastPresent[conn] then newMonitor = true end + end + + -- Automatically apply wildcard wallpaper to newly connected monitors. + if newMonitor and assignments["*"] then + forEachOutput("*", function(name) noctalia.setWallpaperEnabled(name, false) end) + startMpvpaper("*", assignments["*"]) + end + + -- Relaunch specific assignments for monitors that just connected. for connector, path in pairs(assignments) do - if present[connector] and not lastPresent[connector] then - noctalia.setWallpaperEnabled(connector, false) - fifo("set " .. connector .. " " .. path) + if connector ~= "*" then + if present[connector] and not lastPresent[connector] then + noctalia.setWallpaperEnabled(connector, false) + startMpvpaper(connector, path) + end end end + lastPresent = present end +-- ============================================================================== +-- ── IPC (COMMUNICATION WITH PANEL) ───────────────────────────────────────── +-- ============================================================================== + function onIpc(event, payload) if event == "clear-all" then - for connector in pairs(assignments) do - noctalia.setWallpaperEnabled(connector, true) + for connector, path in pairs(assignments) do + killMpvpaper(connector, function() + if path then + extractStaticWallpaper(connector, path) + else + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) + end + end) end assignments = {} persist() - fifo("clear-all") + publish() + + elseif event == "clear" then + clearVideo(payload) + + elseif event == "pause" or event == "freeze" then + if type(payload) == "string" and payload ~= "" then + freezeMpvpaper(payload) + else + for conn in pairs(assignments) do freezeMpvpaper(conn) end + end + publish() + + elseif event == "resume" or event == "thaw" then + if type(payload) == "string" and payload ~= "" then + thawMpvpaper(payload) + else + for conn in pairs(assignments) do thawMpvpaper(conn) end + end + publish() + + elseif event == "toggle" then + if type(payload) == "string" and payload ~= "" then + toggleMpvpaper(payload) + else + for conn in pairs(assignments) do toggleMpvpaper(conn) end + end publish() end end --- The picker panel publishes { action, connector, path } to drive the service. +-- Listen to the panel UI commands. noctalia.state.watch("command", function(cmd) - if type(cmd) ~= "table" then - return - end - if cmd.action == "set" then - setVideo(cmd.connector, cmd.path) - elseif cmd.action == "clear" then - clearVideo(cmd.connector) - end + if type(cmd) ~= "table" then return end + if cmd.action == "set" then setVideo(cmd.connector, cmd.path) + elseif cmd.action == "clear" then clearVideo(cmd.connector) + elseif cmd.action == "clear-all" then onIpc("clear-all", nil) + elseif cmd.action == "freeze" then onIpc("freeze", cmd.connector) + elseif cmd.action == "thaw" then onIpc("thaw", cmd.connector) + elseif cmd.action == "toggle" then onIpc("toggle", cmd.connector) end end) --- ── Boot ────────────────────────────────────────────────────────────────── +-- ============================================================================== +-- ── BOOT ─────────────────────────────────────────────────────────────────── +-- ============================================================================== -noctalia.runAsync("mkdir -p " .. shellQuote(STATE_DIR)) +noctalia.runAsync("mkdir -p '" .. STATE_DIR:gsub("'", "'\\''") .. "'") loadAssignments() + available = noctalia.commandExists("mpvpaper") if available then lastPresent = presentOutputs() - startSupervisor() applyAll() else noctalia.notifyError("Video Wallpaper", "mpvpaper is not installed") end + publish() diff --git a/mpvpaper/panel.luau b/mpvpaper/panel.luau index dc3737e..a65382f 100644 --- a/mpvpaper/panel.luau +++ b/mpvpaper/panel.luau @@ -34,6 +34,9 @@ local function tr(key) return noctalia.tr(key) end +--- Shell-safe single-quoting. +--- Note: duplicated here because Noctalia sandboxed plugins don't have `require` +--- to share code between scripts (like panel.luau and mpvpaper_service.luau). local function shellQuote(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end @@ -65,40 +68,51 @@ local function assignmentFor(connector) return nil end -local function selectedConnector() +-- Check if the selected output has an active video +local function hasActiveVideo() if selectedConnectorName == "" then - return nil + return assignmentFor("*") ~= nil end - return selectedConnectorName + return assignmentFor(selectedConnectorName) ~= nil or assignmentFor("*") ~= nil end +-- Check if the selected output is currently paused +local function isPausedForSelection() + local paused = noctalia.state.get("paused") + if type(paused) ~= "table" then return false end + if selectedConnectorName == "" then + return paused["*"] == true + end + return paused[selectedConnectorName] == true or paused["*"] == true +end + + + local function sendCommand(action, connector, path) cmdNonce += 1 noctalia.state.set("command", { action = action, connector = connector, path = path, nonce = cmdNonce }) end local function applyVideo(path) - if selectedConnectorName == "" then - for _, output in ipairs(outputsList) do - sendCommand("set", output.name, path) - end - else - sendCommand("set", selectedConnectorName, path) - end + local target = selectedConnectorName == "" and "*" or selectedConnectorName + sendCommand("set", target, path) render() end local function stopSelected() if selectedConnectorName == "" then - for _, output in ipairs(outputsList) do - sendCommand("clear", output.name, nil) - end + sendCommand("clear-all", nil, nil) else sendCommand("clear", selectedConnectorName, nil) end render() end +local function toggleSelected() + sendCommand("toggle", selectedConnectorName, nil) + render() +end + -- ── Thumbnails (generated by mpv, which mpvpaper already requires) ────────── local function cachePath(path) @@ -117,7 +131,7 @@ local function pumpThumbQueue() -- file.jpg is not available on every mpv build); move it to the cache name. local cmd = "mkdir -p " .. shellQuote(tmpdir) .. " && mpv " .. shellQuote(path) .. - " --no-config --no-terminal --really-quiet --frames=1 --start=10% --no-audio --vf=scale=480:-2" .. + " --no-config --no-terminal --really-quiet --frames=1 --start=10% --no-audio" .. " --vo=image --vo-image-format=jpg --vo-image-outdir=" .. shellQuote(tmpdir) .. " && mv " .. shellQuote(tmpdir .. "/00000001.jpg") .. " " .. shellQuote(dest) .. "; rm -rf " .. shellQuote(tmpdir) @@ -223,6 +237,13 @@ local function tileSize() end local function isAssignedToSelection(path) + -- If this video is currently assigned as the wildcard ("*") wallpaper, + -- it should show as selected regardless of which output the user is looking at. + if assignmentFor("*") == path then + return true + end + + -- Otherwise, check if it's assigned to the currently selected output. if selectedConnectorName == "" then for _, output in ipairs(outputsList) do if assignmentFor(output.name) == path then @@ -231,7 +252,7 @@ local function isAssignedToSelection(path) end return false end - return assignmentFor(selectedConnector()) == path + return assignmentFor(selectedConnectorName) == path end local function videoTile(video, slotIndex) @@ -314,6 +335,11 @@ render = function() return end + local paused = isPausedForSelection() + local pauseBtnText = paused and tr("resume") or tr("pause") + local pauseBtnGlyph = paused and "player-play" or "player-pause" + local hasVideo = hasActiveVideo() + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { ui.row({ align = "center", justify = "space_between", gap = 8 }, { ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }), @@ -329,6 +355,7 @@ render = function() onChange = "onOutputChange", flexGrow = 1, }), + ui.button({ text = pauseBtnText, glyph = pauseBtnGlyph, variant = "outline", onClick = "onToggle", enabled = hasVideo }), ui.button({ text = tr("stop"), glyph = "player-stop", variant = "outline", onClick = "onStop" }), }), @@ -373,6 +400,10 @@ function onStop() stopSelected() end +function onToggle() + toggleSelected() +end + function onPrevPage() if page > 1 then page -= 1 @@ -396,6 +427,11 @@ noctalia.state.watch("assignments", function(_) render() end) +-- Re-render when the paused state changes (button text). +noctalia.state.watch("paused", function(_) + render() +end) + local function onPickAt(slot) local path = pickSlots[slot] if path ~= nil then diff --git a/mpvpaper/plugin.toml b/mpvpaper/plugin.toml index c1d0fc5..0ea504b 100644 --- a/mpvpaper/plugin.toml +++ b/mpvpaper/plugin.toml @@ -53,6 +53,64 @@ label_key = "settings.mpv_options.label" description_key = "settings.mpv_options.description" default = "" +[[setting]] +key = "run_as_systemd" +type = "bool" +label_key = "settings.run_as_systemd.label" +description_key = "settings.run_as_systemd.description" +default = false + +[[setting]] +key = "extract_last_frame" +type = "bool" +label_key = "settings.extract_last_frame.label" +description_key = "settings.extract_last_frame.description" +default = true + +[[setting]] +key = "cpu_quota" +type = "number" +label_key = "settings.cpu_quota.label" +default = 0 +min = 0 +max = 1600 +step = 10 +visible_when = { key = "run_as_systemd", values = ["true"] } + +[[setting]] +key = "allowed_cpus" +type = "string" +label_key = "settings.allowed_cpus.label" +default = "" +visible_when = { key = "run_as_systemd", values = ["true"] } + +[[setting]] +key = "memory_max" +type = "string" +label_key = "settings.memory_max.label" +default = "" +visible_when = { key = "run_as_systemd", values = ["true"] } + +[[setting]] +key = "cpu_weight" +type = "number" +label_key = "settings.cpu_weight.label" +default = 0 +min = 0 +max = 10000 +step = 10 +visible_when = { key = "run_as_systemd", values = ["true"] } + +[[setting]] +key = "nice" +type = "number" +label_key = "settings.nice.label" +default = 0 +min = -20 +max = 19 +step = 1 +visible_when = { key = "run_as_systemd", values = ["true"] } + # Headless background service: owns the mpvpaper supervisor and per-output assignments. [[service]] id = "service" diff --git a/mpvpaper/translations/en.json b/mpvpaper/translations/en.json index 7c9afe6..88363b4 100644 --- a/mpvpaper/translations/en.json +++ b/mpvpaper/translations/en.json @@ -3,6 +3,8 @@ "output": "Output", "all_outputs": "All outputs", "stop": "Stop", + "pause": "Pause", + "resume": "Resume", "empty": "No videos found in the configured directory", "not_installed": "mpvpaper is not installed", "settings.video_directory.label": "Video directory", @@ -14,5 +16,14 @@ "settings.auto_pause.description": "Pause playback while a fullscreen window covers the wallpaper (mpvpaper --auto-pause).", "settings.mpv_options.label": "Extra mpv options", "settings.mpv_options.description": "Additional space-separated mpv options, e.g. panscan=1.0 video-zoom=0.1", - "settings.glyph.label": "Glyph" + "settings.glyph.label": "Glyph", + "settings.run_as_systemd.label": "Run as systemd service", + "settings.run_as_systemd.description": "Run the wallpaper in a systemd transient scope to enforce strict resource limits.", + "settings.extract_last_frame.label": "Static fallback frame", + "settings.extract_last_frame.description": "Automatically extract a frame from the video to use as the static wallpaper when playback is stopped.", + "settings.cpu_quota.label": "CPU Quota", + "settings.allowed_cpus.label": "Allowed CPUs", + "settings.memory_max.label": "Max Memory", + "settings.cpu_weight.label": "CPU Weight", + "settings.nice.label": "Nice value" } From 5f2daea5fd737c04e57f2279af19d233e5fbd0fb Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sat, 4 Jul 2026 14:35:30 +0100 Subject: [PATCH 2/3] fix: implement SIGSTOP and SIGCONT signaling for mpvpaper process management to preserve wallpaper state --- mpvpaper/mpvpaper_service.luau | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/mpvpaper/mpvpaper_service.luau b/mpvpaper/mpvpaper_service.luau index 66f1825..93bbb73 100644 --- a/mpvpaper/mpvpaper_service.luau +++ b/mpvpaper/mpvpaper_service.luau @@ -153,6 +153,7 @@ end -- without systemd, matching by command line via pkill is the only reliable way to terminate it. local function killMpvpaper(connector, callback) local killCmd + local pattern = shellQuote("mpvpaper .*" .. connector) if cfg("run_as_systemd") then local unitName = unitNameFor(connector) if isPaused[connector] then @@ -161,8 +162,11 @@ local function killMpvpaper(connector, callback) else killCmd = "systemctl --user stop " .. shellQuote(unitName) end + elseif isPaused[connector] then + -- A SIGSTOP'd process ignores SIGTERM. We must resume it right before killing. + killCmd = "pkill -CONT -f " .. pattern .. " ; pkill -f " .. pattern else - killCmd = "pkill -f " .. shellQuote("mpvpaper .*" .. connector) + killCmd = "pkill -f " .. pattern end isPaused[connector] = false @@ -216,12 +220,8 @@ local function freezeMpvpaper(connector) local unitName = unitNameFor(connector) noctalia.runAsync("systemctl --user freeze " .. shellQuote(unitName)) else - local path = assignments[connector] - if path then - killMpvpaper(connector, function() - extractStaticWallpaper(connector, path) - end) - end + -- Send SIGSTOP to freeze the process in place, preserving the last frame on screen. + noctalia.runAsync("pkill -STOP -f " .. shellQuote("mpvpaper .*" .. connector)) end end @@ -231,10 +231,8 @@ local function thawMpvpaper(connector) local unitName = unitNameFor(connector) noctalia.runAsync("systemctl --user thaw " .. shellQuote(unitName)) else - local path = assignments[connector] - if path then - startMpvpaper(connector, path) - end + -- Send SIGCONT to resume the frozen process. + noctalia.runAsync("pkill -CONT -f " .. shellQuote("mpvpaper .*" .. connector)) end end From 525559fb1ccf1523bb3b7aceeafd7554da548601 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sat, 4 Jul 2026 16:41:42 +0100 Subject: [PATCH 3/3] refactor: improve mpvpaper process management with precise pkill patterns and refined UI state handling for wildcard assignments. --- mpvpaper/mpvpaper_service.luau | 34 +++++++++++++++++++----------- mpvpaper/panel.luau | 38 ++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/mpvpaper/mpvpaper_service.luau b/mpvpaper/mpvpaper_service.luau index 93bbb73..580195f 100644 --- a/mpvpaper/mpvpaper_service.luau +++ b/mpvpaper/mpvpaper_service.luau @@ -41,6 +41,15 @@ local function unitNameFor(connector) return "mpvpaper-" .. connector:gsub("[^%w%-]", "_") .. ".scope" end +-- Builds a precise pkill -f pattern that matches only the exact connector. +-- Targets the '-p ' argument followed by a space, preventing partial +-- matches (e.g., DP-1 accidentally matching DP-10 or DP-11). +local function pkillPattern(connector) + -- Escape POSIX ERE metacharacters in the connector name (e.g., '*' for wildcard). + local escaped = connector:gsub("([%.%*%+%?%(%)%[%]%^%$|\\{}])", "\\%1") + return shellQuote("-p " .. escaped .. " ") +end + -- Executes a function for every affected physical output. -- Avoids massive code duplication for wildcard ("*") assignments. local function forEachOutput(connector, fn) @@ -135,7 +144,7 @@ local function extractStaticWallpaper(connector, videoPath) if success and noctalia.fileExists(outPath) then forEachOutput(connector, function(name) noctalia.setWallpaper(name, outPath) - noctalia.setWallpaperEnabled(name, false) + noctalia.setWallpaperEnabled(name, true) end) else forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) @@ -153,7 +162,7 @@ end -- without systemd, matching by command line via pkill is the only reliable way to terminate it. local function killMpvpaper(connector, callback) local killCmd - local pattern = shellQuote("mpvpaper .*" .. connector) + local pattern = pkillPattern(connector) if cfg("run_as_systemd") then local unitName = unitNameFor(connector) if isPaused[connector] then @@ -164,9 +173,9 @@ local function killMpvpaper(connector, callback) end elseif isPaused[connector] then -- A SIGSTOP'd process ignores SIGTERM. We must resume it right before killing. - killCmd = "pkill -CONT -f " .. pattern .. " ; pkill -f " .. pattern + killCmd = "pkill -CONT -f -- " .. pattern .. " ; pkill -f -- " .. pattern else - killCmd = "pkill -f " .. pattern + killCmd = "pkill -f -- " .. pattern end isPaused[connector] = false @@ -183,7 +192,10 @@ local function startMpvpaper(connector, path) -- Temporarily set the cached thumbnail as the background to prevent black screens during startup. local thumb = CACHE_DIR .. "/" .. path:gsub("[^%w]", "_") .. ".jpg" if noctalia.fileExists(thumb) then - forEachOutput(connector, function(name) noctalia.setWallpaper(name, thumb) end) + forEachOutput(connector, function(name) + noctalia.setWallpaper(name, thumb) + noctalia.setWallpaperEnabled(name, true) + end) end killMpvpaper(connector, function() @@ -206,7 +218,9 @@ local function startMpvpaper(connector, path) cmd = "systemd-run --user --scope " .. propsCmd .. "--unit=" .. shellQuote(unitName) .. " " .. cmd end - noctalia.runAsync("sh -c " .. shellQuote(cmd .. " >/dev/null 2>&1 &")) + noctalia.runAsync("sh -c " .. shellQuote(cmd .. " >/dev/null 2>&1 &") .. " ; sleep 0.8", function() + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) + end) end) end @@ -221,7 +235,7 @@ local function freezeMpvpaper(connector) noctalia.runAsync("systemctl --user freeze " .. shellQuote(unitName)) else -- Send SIGSTOP to freeze the process in place, preserving the last frame on screen. - noctalia.runAsync("pkill -STOP -f " .. shellQuote("mpvpaper .*" .. connector)) + noctalia.runAsync("pkill -STOP -f -- " .. pkillPattern(connector)) end end @@ -232,7 +246,7 @@ local function thawMpvpaper(connector) noctalia.runAsync("systemctl --user thaw " .. shellQuote(unitName)) else -- Send SIGCONT to resume the frozen process. - noctalia.runAsync("pkill -CONT -f " .. shellQuote("mpvpaper .*" .. connector)) + noctalia.runAsync("pkill -CONT -f -- " .. pkillPattern(connector)) end end @@ -283,7 +297,6 @@ local function setVideo(connector, path) assignments[connector] = path persist() - forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) startMpvpaper(connector, path) publish() @@ -303,7 +316,6 @@ end -- Applies saved video assignments upon plugin startup. local function applyAll() for connector, path in pairs(assignments) do - forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) startMpvpaper(connector, path) end end @@ -322,7 +334,6 @@ function onOutputsChanged() -- Automatically apply wildcard wallpaper to newly connected monitors. if newMonitor and assignments["*"] then - forEachOutput("*", function(name) noctalia.setWallpaperEnabled(name, false) end) startMpvpaper("*", assignments["*"]) end @@ -330,7 +341,6 @@ function onOutputsChanged() for connector, path in pairs(assignments) do if connector ~= "*" then if present[connector] and not lastPresent[connector] then - noctalia.setWallpaperEnabled(connector, false) startMpvpaper(connector, path) end end diff --git a/mpvpaper/panel.luau b/mpvpaper/panel.luau index 5fb6361..0fefe20 100644 --- a/mpvpaper/panel.luau +++ b/mpvpaper/panel.luau @@ -70,22 +70,40 @@ end -- Check if the selected output has an active video local function hasActiveVideo() + local assignments = noctalia.state.get("assignments") + if type(assignments) ~= "table" then return false end + if selectedConnectorName == "" then - return assignmentFor("*") ~= nil + for k, v in pairs(assignments) do + if v ~= nil then return true end + end + return false end - return assignmentFor(selectedConnectorName) ~= nil or assignmentFor("*") ~= nil + return assignments[selectedConnectorName] ~= nil or assignments["*"] ~= nil end -- Check if the selected output is currently paused local function isPausedForSelection() local paused = noctalia.state.get("paused") if type(paused) ~= "table" then return false end + if selectedConnectorName == "" then - return paused["*"] == true + for k, v in pairs(paused) do + if v == true then return true end + end + return false end return paused[selectedConnectorName] == true or paused["*"] == true end +-- Returns true when the selected individual connector has no per-connector +-- assignment but inherits a video from the wildcard ("*") assignment. +-- In this case, pause/stop must be done via "All Outputs" instead. +local function isWildcardOnly() + if selectedConnectorName == "" then return false end + return assignmentFor(selectedConnectorName) == nil and assignmentFor("*") ~= nil +end + local function sendCommand(action, connector, path) @@ -109,7 +127,8 @@ local function stopSelected() end local function toggleSelected() - sendCommand("toggle", selectedConnectorName, nil) + local action = isPausedForSelection() and "thaw" or "freeze" + sendCommand(action, selectedConnectorName, nil) render() end @@ -339,6 +358,12 @@ render = function() local pauseBtnText = paused and tr("resume") or tr("pause") local pauseBtnGlyph = paused and "player-play" or "player-pause" local hasVideo = hasActiveVideo() + local wildcardOnly = isWildcardOnly() + local pauseEnabled = hasVideo and not wildcardOnly + local stopEnabled = hasVideo and not wildcardOnly + + -- Lock the output selector if "All Outputs" (*) is currently playing a video. + local selectEnabled = assignmentFor("*") == nil panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { ui.row({ align = "center", justify = "space_between", gap = 8 }, { @@ -354,9 +379,10 @@ render = function() selectedIndex = selectedIndex, onChange = "onOutputChange", flexGrow = 1, + enabled = selectEnabled, }), - ui.button({ text = pauseBtnText, glyph = pauseBtnGlyph, variant = "outline", onClick = "onToggle", enabled = hasVideo }), - ui.button({ text = tr("stop"), glyph = "player-stop", variant = "outline", onClick = "onStop" }), + ui.button({ text = pauseBtnText, glyph = pauseBtnGlyph, variant = "outline", onClick = "onToggle", enabled = pauseEnabled }), + ui.button({ text = tr("stop"), glyph = "player-stop", variant = "outline", onClick = "onStop", enabled = stopEnabled }), }), ui.scroll({ flexGrow = 1, gap = 12 }, {