diff --git a/mpvpaper/README.md b/mpvpaper/README.md index b05538c..b112a03 100644 --- a/mpvpaper/README.md +++ b/mpvpaper/README.md @@ -36,6 +36,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. + ## Settings | Setting | Type | Default | Description | @@ -45,12 +55,15 @@ Assignments persist across restarts. Supported files: `mp4`, `webm`, `mkv`, `mov | `hardware_decode` | `bool` | `true` | Uses `mpv` hardware decoding. | | `auto_pause` | `bool` | `true` | Pauses playback while a fullscreen window covers the wallpaper. | | `mpv_options` | `string` | *(empty)* | Additional space-separated `mpv` options. | +| `run_as_systemd` | `bool` | `false` | Runs instances inside systemd transient scopes (`systemd-run`) for resource control. | +| `extract_last_frame` | `bool` | `true` | Extracts a static frame to use as a wallpaper when video playback is stopped or paused. | +| `cpu_quota` | `number` | `0` | Systemd `CPUQuota=` limit in percentage. Requires `run_as_systemd`. | +| `allowed_cpus` | `string` | *(empty)* | Systemd `AllowedCPUs=` limits (e.g., `0-3`). Requires `run_as_systemd`. | +| `memory_max` | `string` | *(empty)* | Systemd `MemoryMax=` limits (e.g., `500M`). Requires `run_as_systemd`. | +| `cpu_weight` | `number` | `0` | Systemd `CPUWeight=` priority. Requires `run_as_systemd`. | +| `nice` | `number` | `0` | Process `nice` priority level. Requires `run_as_systemd`. | | `glyph` | `glyph` | `movie` | Bar widget icon. | -## Notes +## 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..580195f 100644 --- a/mpvpaper/mpvpaper_service.luau +++ b/mpvpaper/mpvpaper_service.luau @@ -1,84 +1,70 @@ --!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 -local function mpvOptions() - local parts = { "loop-file=inf", "panscan=1.0" } - if cfg("mute") then - table.insert(parts, "no-audio") +-- 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 - 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 - return table.concat(parts, " ") + return "mpvpaper-" .. connector:gsub("[^%w%-]", "_") .. ".scope" end -local function mpvpaperFlags() - if cfg("auto_pause") then - return "--auto-pause" - end - return "" +-- 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 --- 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 - return - end - for _, line in ipairs(pending) do - noctalia.runAsync("printf '%s\\n' " .. shellQuote(line) .. " > " .. shellQuote(FIFO)) +-- 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 - pending = {} end -local function fifo(line) - table.insert(pending, line) - flush() -end - -local function startSupervisor() - if supervisorStarted then - return - 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") - end -end +-- ============================================================================== +-- ── STATE PERSISTENCE ────────────────────────────────────────────────────── +-- ============================================================================== local function persist() noctalia.runAsync("mkdir -p " .. shellQuote(STATE_DIR)) @@ -89,114 +75,352 @@ 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 + 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 --- ── Commands ────────────────────────────────────────────────────────────── +-- ============================================================================== +-- ── COMMAND BUILDERS ─────────────────────────────────────────────────────── +-- ============================================================================== -local function setVideo(connector, path) - if not available or type(connector) ~= "string" or connector == "" or type(path) ~= "string" or path == "" then +-- 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 + 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 + return table.concat(parts, " ") +end + +local function mpvpaperFlags() + if cfg("auto_pause") then return "--auto-pause" end + return "" +end + +-- 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 - assignments[connector] = path + + 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, true) + end) + else + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) + end + end) + end) +end + +-- ============================================================================== +-- ── 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 + local pattern = pkillPattern(connector) + 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 + 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 -- " .. pattern + end + + isPaused[connector] = false + + if callback then + noctalia.runAsync(killCmd, callback) + else + noctalia.runAsync(killCmd) + end +end + +-- 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) + noctalia.setWallpaperEnabled(name, true) + 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 &") .. " ; sleep 0.8", function() + forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, false) end) + end) + end) +end + +-- ============================================================================== +-- ── 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 + -- Send SIGSTOP to freeze the process in place, preserving the last frame on screen. + noctalia.runAsync("pkill -STOP -f -- " .. pkillPattern(connector)) + end +end + +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 + -- Send SIGCONT to resume the frozen process. + noctalia.runAsync("pkill -CONT -f -- " .. pkillPattern(connector)) + end +end + +local function toggleMpvpaper(connector) + if isPaused[connector] then + thawMpvpaper(connector) + else + freezeMpvpaper(connector) + end +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) + + 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) + 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 + 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 + 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 f87961b..0fefe20 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,70 @@ local function assignmentFor(connector) return nil end -local function selectedConnector() +-- 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 + for k, v in pairs(assignments) do + if v ~= nil then return true end + end + return false + end + 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 nil + for k, v in pairs(paused) do + if v == true then return true end + end + return false end - return selectedConnectorName + 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) 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() + local action = isPausedForSelection() and "thaw" or "freeze" + sendCommand(action, selectedConnectorName, nil) + render() +end + -- ── Thumbnails (generated by mpv, which mpvpaper already requires) ────────── local function cachePath(path) @@ -117,7 +150,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 +256,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 +271,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 +354,17 @@ 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() + 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 }, { ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), @@ -328,8 +379,10 @@ render = function() selectedIndex = selectedIndex, onChange = "onOutputChange", flexGrow = 1, + enabled = selectEnabled, }), - 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 }, { @@ -373,6 +426,10 @@ function onStop() stopSelected() end +function onToggle() + toggleSelected() +end + function onPrevPage() if page > 1 then page -= 1 @@ -396,6 +453,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" }