diff --git a/mpvpaper/README.md b/mpvpaper/README.md index cee2484..6e23865 100644 --- a/mpvpaper/README.md +++ b/mpvpaper/README.md @@ -8,6 +8,10 @@ Noctalia does not decode video itself. This plugin runs `mpvpaper`, which draws outputs you assign a video to — so the video shows through, with the bar and dock still drawn above it. +In addition to single-file wallpapers, the plugin can run **directory slideshows**: +when you set a slideshow interval from the picker, mpvpaper loops over all supported +videos in the selected file’s directory, starting at the one you clicked. + ## Plugin | Field | Value | @@ -20,6 +24,11 @@ drawn above it. Install `mpvpaper` and `mpv` on `PATH`. `mpvpaper` renders the wallpaper surface, and `mpv` renders picker thumbnails. +For slideshow state tracking (so the picker highlights the correct tile and static +wallpaper frames follow the current slideshow file), the service talks to mpv over +JSON IPC using the `socat` command; if `socat` is missing, slideshows still play, but +tile highlighting and static-frame extraction may not reflect the current file. + The plugin works on `wlr-layer-shell` compositors (Niri, Hyprland, Sway, Mango). ## Usage @@ -34,18 +43,25 @@ The plugin works on `wlr-layer-shell` compositors (Niri, Hyprland, Sway, Mango). 3. Choose a target output (or **All outputs**), then click a video to apply it. Use **Stop** to restore Noctalia's own wallpaper on that output. -Assignments persist across restarts. Supported files: `mp4`, `webm`, `mkv`, `mov`, `gif`. +4. To enable a **slideshow**, click the **Slideshow** button in the picker’s + header to expand the interval slider. Drag the slider to choose an interval + (in minutes) and release to commit; the service converts this to seconds and + passes `--slideshow ` to mpvpaper, building a playlist from all + supported videos in that directory, starting at the file you selected. + +Assignments (including the last slideshow interval) 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 pause [connector]` - Pauses playback via cgroups freezer (or SIGSTOP when systemd is disabled). - `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 ` - Stops the wallpaper on the specified monitor and extracts a frame as a static wallpaper (when enabled). - `noctalia msg plugin noctalia/mpvpaper:service all clear-all` - Stops all active video wallpapers. - +- `noctalia msg plugin noctalia/mpvpaper:service all slideshow ` - Changes the slideshow interval to the value in minutes ## Settings | Setting | Type | Default | Description | @@ -53,8 +69,8 @@ You can control the video wallpaper externally via Noctalia's IPC mechanism. Rep | `video_directory` | `folder` | *(empty)* | Folder scanned for wallpaper videos; defaults to `~/Videos` when empty. | | `mute` | `bool` | `true` | Starts video wallpapers muted. | | `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. | +| `auto_pause` | `select` | `"full"` | Automatically pauses or stops playback while a window covers the wallpaper (`off`, `maximized`, or `fullscreen`). | +| `mpv_options` | `string` | *(empty)* | Additional space-separated `mpv` options (forwarded via `-o`), e.g. `panscan=1.0 video-zoom=0.1`. | | `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`. | @@ -66,4 +82,13 @@ You can control the video wallpaper externally via Noctalia's IPC mechanism. Rep ## How it works -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, the service's `onExit` hook terminates every running `mpvpaper` instance — including frozen or paused ones — so no orphan processes remain. +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 you set a slideshow interval, the service builds an `.m3u` playlist from all +supported videos in the selected file’s directory and starts mpvpaper with that +playlist and `--slideshow` enabled; the service then polls mpv over JSON IPC to +track which file is currently shown, updating assignments and static wallpaper +frames so the picker highlights the right tile and stop operations extract the +correct frame. + +When the plugin is disabled or Noctalia exits, the service's `onExit` hook terminates every running `mpvpaper` instance — including frozen or paused ones — so no orphan processes remain. \ No newline at end of file diff --git a/mpvpaper/mpvpaper_service.luau b/mpvpaper/mpvpaper_service.luau index 9591851..f1b8f1f 100644 --- a/mpvpaper/mpvpaper_service.luau +++ b/mpvpaper/mpvpaper_service.luau @@ -3,6 +3,11 @@ -- This service handles active video wallpaper assignments and lifecycle. -- Note: Noctalia plugin scripts run in an isolated sandbox without `require`. -- Therefore, all logic (IPC, state management) must reside here. +-- In addition to single-video wallpapers, this service also drives per-output +-- directory slideshows: when a slideshow interval is set, it builds a playlist +-- from all videos in the chosen directory and tracks which file mpv is +-- currently showing via JSON IPC, updating both state and static wallpaper +-- frames to match. noctalia.setUpdateInterval(1000) @@ -14,16 +19,27 @@ local CACHE_DIR = (noctalia.getenv("XDG_CACHE_HOME") or ((noctalia.getenv("HOME" 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 = {} -- Map of connector -> video path ("*" for all outputs) +local VIDEO_EXTENSIONS = { mp4 = true, webm = true, mkv = true, mov = true, gif = true } +local assignments = {} -- Map of connector -> video path ("*" for all outputs) local launchedAsSystemd = {} -- Map of connector -> boolean (true if launched as systemd) -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? +local lastPresent = {} -- Map of connector -> true (monitors seen last tick) +local isPaused = {} -- Map of connector -> true (paused monitors) +local currentPath = {} -- Map of connector -> file mpv is actually showing right now (slideshow only) +local available = false -- Is mpvpaper binary installed? + +-- Slideshow interval in seconds, set exclusively from the picker's slider (no +-- manifest setting backs this anymore). 0 means disabled. Persisted to disk so +-- it survives a service restart. +local slideshowOverride = 0 -- ============================================================================== -- ── HELPERS ──────────────────────────────────────────────────────────────── -- ============================================================================== +local function ts() + return os.date("%H:%M:%S") +end + local function cfg(key) return noctalia.getConfig(key) end @@ -36,8 +52,8 @@ 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" + if connector == "*" then + return "mpvpaper.scope" end return "mpvpaper-" .. connector:gsub("[^%w%-]", "_") .. ".scope" end @@ -55,14 +71,28 @@ end -- 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) + for _, output in ipairs(noctalia.outputs()) do + fn(output.name) end else fn(connector) end end +-- Effective slideshow interval, driven solely by the picker's slider. +-- 0 (or anything non-positive) disables the slideshow. +local function slideshowInterval() + local v = slideshowOverride + if type(v) ~= "number" or v <= 0 then return nil end + return math.floor(v) +end + +-- Path to the mpv JSON IPC socket for a given connector. Used to poll which +-- file mpv is currently showing while a slideshow playlist is active. +local function ipcSocketPath(connector) + return STATE_DIR .. "/ipc-" .. connector:gsub("[^%w%-]", "_") .. ".sock" +end + -- ============================================================================== -- ── STATE PERSISTENCE ────────────────────────────────────────────────────── -- ============================================================================== @@ -90,6 +120,21 @@ local function loadAssignments() end end +-- Persists the slider-driven slideshow interval so it survives restarts. +local function persistSlideshowOverride() + noctalia.writeFile(STATE_DIR .. "/slideshow_override.json", noctalia.json.encode({ interval = slideshowOverride }) or "") +end + +local function loadSlideshowOverride() + local content = noctalia.readFile(STATE_DIR .. "/slideshow_override.json") + if type(content) == "string" and content ~= "" then + local decoded = noctalia.json.decode(content) + if type(decoded) == "table" and type(decoded.interval) == "number" then + slideshowOverride = decoded.interval + 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) @@ -102,19 +147,32 @@ end -- ============================================================================== -- Builds the -o options string forwarded to the internal mpv process. -local function mpvOptions() +local function mpvOptions(connector) 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")) - + table.insert(parts, "input-ipc-server=" .. ipcSocketPath(connector)) + local extra = cfg("mpv_options") if type(extra) == "string" and extra ~= "" then table.insert(parts, extra) end return table.concat(parts, " ") end +-- Builds mpvpaper flags from config, including auto-pause modes and slideshow. +-- When a slideshow interval is present, we append `--slideshow ` so +-- mpvpaper cycles through the playlist built for that connector. local function mpvpaperFlags() - if cfg("auto_pause") then return "--auto-pause" end - return "" + local options = "" + local mode = cfg("auto_pause") + if mode == "max" then options = "--auto-pause --auto-mode MAX" end + if mode == "full" then options = "--auto-pause --auto-mode FULL" end + + local interval = slideshowInterval() + if interval then + options = options .. (options ~= "" and " " or "") .. "--slideshow " .. tostring(interval) + end + + return options end -- Collects systemd-specific cgroup properties (e.g., CPUQuota). @@ -122,16 +180,16 @@ 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 @@ -150,7 +208,7 @@ local function extractStaticWallpaper(connector, videoPath) 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) @@ -164,6 +222,120 @@ local function extractStaticWallpaper(connector, videoPath) end) end +local function cachePath(path) + return CACHE_DIR .. "/" .. path:gsub("[^%w]", "_") .. ".jpg" +end + +local function updateStaticWallpaper(connector, videoPath) + local outPath = cachePath(videoPath) + + if noctalia.fileExists(outPath) then + forEachOutput(connector, function(name) + noctalia.setWallpaper(name, outPath) + end) + return + end + + if not cfg("extract_last_frame") then return end + + noctalia.runAsync("mkdir -p " .. shellQuote(CACHE_DIR), function() + local cmd = "ffmpeg -y -ss 1 -i " .. shellQuote(videoPath) .. " -frames:v 1 -q:v 2 " .. shellQuote(outPath) + noctalia.runAsync(cmd, function(result) + if type(result) == "table" and result.exit_code == 0 and noctalia.fileExists(outPath) then + forEachOutput(connector, function(name) + noctalia.setWallpaper(name, outPath) + end) + else + noctalia.log("mpvpaper: static-frame extraction failed for " .. videoPath .. ": " .. tostring(result and result.stderr)) + end + end) + end) +end + +-- ============================================================================== +-- ── Slideshow ────────────────────────────────────────────────────────────── +-- ========================================================================== + +-- Builds a deterministic slideshow playlist for a connector. It gathers all +-- supported video files from the directory of the selected path, sorts them, +-- and rotates the list so the chosen file is first. mpvpaper then loops this +-- playlist when `--slideshow` is enabled. +local function buildSlideshowPlaylist(connector, selectedPath) + local dir = selectedPath:match("^(.*)/[^/]+$") or "." + local entries = noctalia.listDir(dir) or {} + local files = {} + for _, name in ipairs(entries) do + local ext = name:match("%.([%a%d]+)$") + if ext and VIDEO_EXTENSIONS[ext:lower()] then + table.insert(files, dir .. "/" .. name) + end + end + table.sort(files) + + local startIdx = 1 + for i, f in ipairs(files) do + if f == selectedPath then startIdx = i break end + end + local ordered = {} + for i = 0, #files - 1 do + table.insert(ordered, files[((startIdx - 1 + i) % #files) + 1]) + end + + local playlistPath = CACHE_DIR .. "/playlist_" .. connector:gsub("[^%w%-]", "_") .. ".m3u" + noctalia.writeFile(playlistPath, table.concat(ordered, "\n")) + return playlistPath +end + +-- Resolves the actual target path for mpvpaper: either the raw file (no +-- slideshow) or the playlist built above when a slideshow interval is active. +local function resolveTarget(connector, path) + if slideshowInterval() then + return buildSlideshowPlaylist(connector, path) + end + return path +end + +-- Asks mpv's JSON IPC socket which file it is currently showing. +-- Requires the `socat` binary; silently no-ops if the socket isn't up yet. +local function queryCurrentFile(connector, callback) + local sock = ipcSocketPath(connector) + if not noctalia.fileExists(sock) then + callback(nil) + return + end + local cmd = "echo '{\"command\":[\"get_property\",\"path\"]}' | socat - " .. + shellQuote("UNIX-CONNECT:" .. sock) + noctalia.runAsync(cmd, function(result) + if type(result) ~= "table" or type(result.stdout) ~= "string" then + noctalia.log("mpvpaper: ipc query failed for " .. connector .. ": " .. tostring(result)) + callback(nil) + return + end + local path = result.stdout:match('"data":"(.-)"') + callback(path) + end) +end + +-- Polls mpv for the currently displayed file, and if it changed since the +-- last tick, syncs `assignments` (so the picker highlights the right tile and +-- extractStaticWallpaper grabs the correct frame on stop) and refreshes the +-- static wallpaper texture to match. This is what keeps the slideshow-aware +-- state in sync with mpv's internal playlist position. +local function refreshCurrentFile(connector) + queryCurrentFile(connector, function(current) + -- print(ts() .. "mpvpaper: poll tick, connector=" .. connector .. " current=" .. tostring(current) .. " tracked=" .. tostring(currentPath[connector])) + if type(current) ~= "string" or current == "" then return end + if currentPath[connector] == current then return end + + print(ts() .. "mpvpaper: DETECTED CHANGE connector=" .. connector .. " " .. tostring(currentPath[connector]) .. " -> " .. current) + currentPath[connector] = current + assignments[connector] = current + persist() + publish() + updateStaticWallpaper(connector, current) + end) +end + -- ============================================================================== -- ── PROCESS MANAGEMENT ───────────────────────────────────────────────────── -- ============================================================================== @@ -189,8 +361,9 @@ local function killMpvpaper(connector, callback, signal) else killCmd = "pkill -" .. sig .. " -f -- " .. pattern end - + isPaused[connector] = false + currentPath[connector] = nil if callback then noctalia.runAsync(killCmd, callback) @@ -200,12 +373,17 @@ local function killMpvpaper(connector, callback, signal) end -- Spawns a new mpvpaper instance for the given connector. +-- When a slideshow is active, we start mpvpaper against the generated +-- playlist; when it is disabled, we just point it at the single file. local function startMpvpaper(connector, path) + print(ts() .. "mpvpaper: startMpvpaper called connector=" .. connector .. " path=" .. tostring(path)) -- Temporarily set the cached thumbnail as the background to prevent black screens during startup. local thumb = CACHE_DIR .. "/" .. path:gsub("[^%w]", "_") .. ".jpg" + local target = resolveTarget(connector, path) + if noctalia.fileExists(thumb) then - forEachOutput(connector, function(name) - noctalia.setWallpaper(name, thumb) + forEachOutput(connector, function(name) + noctalia.setWallpaper(name, thumb) noctalia.setWallpaperEnabled(name, true) end) end @@ -216,13 +394,13 @@ local function startMpvpaper(connector, path) local flags = mpvpaperFlags() local cmd = "mpvpaper " if flags ~= "" then cmd = cmd .. flags .. " " end - cmd = cmd .. "-o " .. shellQuote(mpvOptions()) .. " " .. shellQuote(connector) .. " " .. shellQuote(path) -- mpvpaper -o [mpv-options] [connector] [video-path] - + cmd = cmd .. "-o " .. shellQuote(mpvOptions(connector)) .. " " .. shellQuote(connector) .. " " .. shellQuote(target) -- mpvpaper -o [mpv-options] [connector] [video-path] + local nice = cfg("nice") if type(nice) == "number" and nice ~= 0 then cmd = "nice -n " .. tostring(nice) .. " " .. cmd end - + if launchedAsSystemd[connector] then local unitName = unitNameFor(connector) local propsCmd = "" @@ -279,28 +457,32 @@ end -- ============================================================================== -- Clears an assigned video and restores the host wallpaper. +-- When a slideshow was in effect, the static wallpaper frame comes from the +-- currently displayed slideshow file (tracked via refreshCurrentFile). local function clearVideo(connector) if type(connector) ~= "string" or connector == "" then return end - + local path = assignments[connector] assignments[connector] = nil persist() - + killMpvpaper(connector, function() - if path then - extractStaticWallpaper(connector, path) + if path then + extractStaticWallpaper(connector, path) else forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) end end) - + publish() end -- Assigns a video to a specific monitor or globally ("*"). +-- If a slideshow interval is active, this actually starts a playlist beginning +-- at `path`; otherwise it just starts a single-file wallpaper. 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 @@ -312,9 +494,9 @@ local function setVideo(connector, path) assignments[connector] = path persist() - + startMpvpaper(connector, path) - + publish() end @@ -330,19 +512,28 @@ local function presentOutputs() end -- Applies saved video assignments upon plugin startup. +-- When a slideshow interval is present, this will reapply playlists as needed. local function applyAll() for connector, path in pairs(assignments) do startMpvpaper(connector, path) end end -function update() end +-- Polls mpv over IPC for the currently displayed file while a slideshow is +-- active, keeping `assignments` and the static wallpaper texture in sync. +function update() + if not available then return end + if not slideshowInterval() then return end + for connector in pairs(assignments) do + refreshCurrentFile(connector) + end +end -- Triggers when a monitor connects or disconnects. function onOutputsChanged() 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 @@ -361,7 +552,7 @@ function onOutputsChanged() end end end - + lastPresent = present end @@ -383,8 +574,8 @@ function onIpc(event, payload) if event == "clear-all" then for connector, path in pairs(assignments) do killMpvpaper(connector, function() - if path then - extractStaticWallpaper(connector, path) + if path then + extractStaticWallpaper(connector, path) else forEachOutput(connector, function(name) noctalia.setWallpaperEnabled(name, true) end) end @@ -420,6 +611,17 @@ function onIpc(event, payload) for conn in pairs(assignments) do toggleMpvpaper(conn) end end publish() + + elseif event == "slideshow" then + local minutes = tonumber(payload) + if minutes == nil then + noctalia.log("mpvpaper: invalid slideshow IPC payload: " .. tostring(payload)) + return + end + slideshowOverride = math.max(0, math.floor(minutes)) * 60 + persistSlideshowOverride() + noctalia.state.set("slideshowOverride", slideshowOverride) + applyAll() end end @@ -434,12 +636,24 @@ noctalia.state.watch("command", function(cmd) elseif cmd.action == "toggle" then onIpc("toggle", cmd.connector) end end) +-- Listen to the picker's slideshow interval slider. This is now the ONLY +-- source of truth for the interval — there is no manifest setting fallback. +-- The slider sends seconds via `slideshowCommand`, which we store and then +-- reapply all assignments so every output picks up the new interval. +noctalia.state.watch("slideshowCommand", function(cmd) + slideshowOverride = cmd.interval + persistSlideshowOverride() + noctalia.state.set("slideshowOverride", slideshowOverride) + applyAll() +end) + -- ============================================================================== -- ── BOOT ─────────────────────────────────────────────────────────────────── -- ============================================================================== noctalia.runAsync("mkdir -p '" .. STATE_DIR:gsub("'", "'\\''") .. "'") loadAssignments() +loadSlideshowOverride() available = noctalia.commandExists("mpvpaper") if available then @@ -450,8 +664,13 @@ else end publish() +noctalia.state.set("slideshowOverride", slideshowOverride) -- Called when any configuration setting is changed. +-- Because auto-pause and resource limits affect how mpvpaper is spawned, +-- we simply reapply all assignments so the running instances pick up the +-- new configuration (including slideshow interval changes coming from the +-- slider). function onConfigChanged(key, value) applyAll() -end +end \ No newline at end of file diff --git a/mpvpaper/panel.luau b/mpvpaper/panel.luau index 02db069..e866f96 100644 --- a/mpvpaper/panel.luau +++ b/mpvpaper/panel.luau @@ -3,6 +3,13 @@ -- Browse the configured video directory as a grid, choose a target output, and apply a -- video. Selection is delivered to the headless service through the shared "command" -- state channel; the service launches mpvpaper and drops the host wallpaper there. +-- The slideshow interval slider below is panel-local only (no config/state readback, +-- aside from a one-time read on open): it displays minutes, and commits seconds to the +-- service via "slideshowCommand". Its toggle button lives in the output/pause/stop row; +-- the slider itself expands into its own row below (Noctalia's ui.* vocabulary has no +-- floating dropdown/popover primitive, so this is an inline expand/collapse instead). +-- The panel never reads any manifest setting for the slideshow: the slider is the single +-- source of truth, and the service persists the interval so it survives restarts. local PANEL_WIDTH = 920 local PANEL_PADDING = 28 @@ -14,6 +21,10 @@ local TILE_RADIUS = 12 local VIDEO_EXTENSIONS = { mp4 = true, webm = true, mkv = true, mov = true, gif = true } local CACHE_DIR = (noctalia.getenv("XDG_CACHE_HOME") or ((noctalia.getenv("HOME") or "") .. "/.cache")) .. "/noctalia/mpvpaper" +local SLIDESHOW_MIN_MINUTES = 0 +local SLIDESHOW_MAX_MINUTES = 120 +local SECONDS_PER_MINUTE = 60 + local videos = {} -- { { path=, name=, ext= }, ... } local page = 1 local outputsList = {} @@ -21,6 +32,9 @@ local selectedConnectorName = "" -- empty = all outputs local available = true local errorMessage = "" local cmdNonce = 0 +local slideshowMinutes = 0 -- authoritative slideshow value, in minutes, panel-local only +local slideshowDraftValue = nil -- local UI value while dragging the slider; nil = not dragging +local slideshowExpanded = false -- whether the slider row is open local thumbPaths = {} -- path -> relative cache path (ready) local thumbPending = {} -- path -> true while generating @@ -67,13 +81,13 @@ local function assignmentFor(connector) return nil end --- Check if the selected output has an active video +-- Check if the selected output has an active video (including wildcard "*"). 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 + for _, v in pairs(assignments) do if v ~= nil then return true end end return false @@ -81,13 +95,13 @@ local function hasActiveVideo() return assignments[selectedConnectorName] ~= nil or assignments["*"] ~= nil end --- Check if the selected output is currently paused +-- 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 - for k, v in pairs(paused) do + for _, v in pairs(paused) do if v == true then return true end end return false @@ -103,13 +117,16 @@ local function isWildcardOnly() return assignmentFor(selectedConnectorName) == nil and assignmentFor("*") ~= nil end - - +-- Sends a command to the service. Each payload includes a nonce so the service +-- can distinguish consecutive actions even if the payload values repeat. local function sendCommand(action, connector, path) cmdNonce += 1 noctalia.state.set("command", { action = action, connector = connector, path = path, nonce = cmdNonce }) end +-- Applies a video (or slideshow playlist) to either all outputs ("*") or the +-- currently selected connector. The service decides whether to treat the path +-- as a single file or a playlist based on its slideshow interval. local function applyVideo(path) local target = selectedConnectorName == "" and "*" or selectedConnectorName sendCommand("set", target, path) @@ -131,6 +148,20 @@ local function toggleSelected() render() end +-- ── Slideshow interval ─────────────────────────────────────────────────── +-- Panel-local only, aside from a one-time read of the service's last-known +-- value in onOpen. Committing sends seconds to the service (mpvpaper +-- --slideshow expects seconds, the slider itself displays minutes). +-- The slider is deliberately kept simple: it always writes seconds into +-- `slideshowCommand.interval`, and the service persists and applies the new +-- interval to all existing assignments. + +local function setSlideshowInterval(minutes) + cmdNonce += 1 + local seconds = minutes * SECONDS_PER_MINUTE + noctalia.state.set("slideshowCommand", { interval = seconds, nonce = cmdNonce }) +end + -- ── Thumbnails (generated by mpv, which mpvpaper already requires) ────────── local function cachePath(path) @@ -338,6 +369,55 @@ local function videoGrid() return ui.column({ gap = 12, align = "stretch" }, rows) end +local function slideshowValueLabel(minutes) + if minutes <= 0 then return tr("slideshow_off") end + return tostring(minutes) .. "m" +end + +-- Compact toggle button meant to sit alongside the output/pause/stop controls. +-- It simply opens/closes the slider row; the slider itself is responsible for +-- committing values to the service. +local function slideshowToggleButton() + local current = slideshowDraftValue or slideshowMinutes + return ui.button({ + text = tr("slideshow") .. ": " .. slideshowValueLabel(current), + glyph = slideshowExpanded and "chevron-up" or "chevron-down", + variant = "outline", + onClick = function() + slideshowExpanded = not slideshowExpanded + render() + end, + }) +end + +-- Full-width slider row, only included in the layout while expanded. +-- Dragging updates a draft value immediately in the label; releasing the drag +-- commits the chosen minutes to the service and collapses any optimistic state +-- back into the authoritative `slideshowMinutes`. +local function slideshowSliderRow() + local current = slideshowDraftValue or slideshowMinutes + return ui.row({ gap = 8, align = "center" }, { + ui.slider({ + min = SLIDESHOW_MIN_MINUTES, + max = SLIDESHOW_MAX_MINUTES, + step = 1, + value = current, + flexGrow = 1, + onChange = function(value) + slideshowDraftValue = tonumber(value) or slideshowDraftValue or 0 + render() + end, + onDragEnd = function(_) + local minutes = slideshowDraftValue or slideshowMinutes + slideshowDraftValue = nil + slideshowMinutes = minutes + setSlideshowInterval(minutes) + render() + end, + }), + }) +end + render = function() refreshAvailability() refreshOutputs() @@ -359,11 +439,11 @@ render = function() 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" }, { + local mainChildren = { ui.row({ align = "center", justify = "space_between", gap = 8 }, { ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), ui.button({ glyph = "close", onClick = "onClose" }), @@ -381,23 +461,39 @@ render = function() }), 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 }), + slideshowToggleButton(), }), + } - ui.scroll({ flexGrow = 1, gap = 12 }, { - videoGrid(), - }), + if slideshowExpanded then + table.insert(mainChildren, slideshowSliderRow()) + end - ui.row({ align = "center", justify = "space_between", gap = 8 }, { - ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onPrevPage", enabled = page > 1 }), - ui.label({ text = `{page} / {totalPages()}`, color = "on_surface_variant" }), - ui.button({ glyph = "chevron-right", variant = "ghost", onClick = "onNextPage", enabled = page < totalPages() }), - }), + table.insert(mainChildren, ui.scroll({ flexGrow = 1, gap = 12 }, { + videoGrid(), })) + + table.insert(mainChildren, ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onPrevPage", enabled = page > 1 }), + ui.label({ text = `{page} / {totalPages()}`, color = "on_surface_variant" }), + ui.button({ glyph = "chevron-right", variant = "ghost", onClick = "onNextPage", enabled = page < totalPages() }), + })) + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, mainChildren)) end -- ── Callbacks ────────────────────────────────────────────────────────────── function onOpen(_context) + -- Seed the slider from the service's last-known interval (seconds). This + -- ensures the panel shows the right value after restarts. + local override = noctalia.state.get("slideshowOverride") + if type(override) == "number" then + slideshowMinutes = math.floor(override / SECONDS_PER_MINUTE) + else + slideshowMinutes = 0 + end + refreshOutputs() loadVideos() render() @@ -447,6 +543,8 @@ function onClose() end -- Re-render when the service publishes new assignments (tile highlights). +-- This keeps the UI in sync when the slideshow advances and the service +-- updates the active file for a connector. noctalia.state.watch("assignments", function(_) render() end) @@ -454,4 +552,4 @@ end) -- Re-render when the paused state changes (button text). noctalia.state.watch("paused", function(_) render() -end) +end) \ No newline at end of file diff --git a/mpvpaper/plugin.toml b/mpvpaper/plugin.toml index 347d360..7387d6b 100644 --- a/mpvpaper/plugin.toml +++ b/mpvpaper/plugin.toml @@ -10,7 +10,7 @@ id = "noctalia/mpvpaper" name = "Video Wallpaper" -version = "1.0.7" +version = "1.0.8" plugin_api = 9 author = "noctalia" license = "MIT" @@ -41,10 +41,15 @@ default = true [[setting]] key = "auto_pause" -type = "bool" +type = "select" label_key = "settings.auto_pause.label" description_key = "settings.auto_pause.description" -default = true +default = "full" +options = [ + { value = "off", label_key = "settings.auto_pause.options.off" }, + { value = "max", label_key = "settings.auto_pause.options.max" }, + { value = "full", label_key = "settings.auto_pause.options.full" }, +] [[setting]] key = "mpv_options" @@ -134,4 +139,4 @@ entry = "widget.luau" key = "glyph" type = "glyph" label_key = "settings.glyph.label" - default = "movie" + default = "movie" \ No newline at end of file diff --git a/mpvpaper/translations/en.json b/mpvpaper/translations/en.json index 9390c81..0e4ea3b 100644 --- a/mpvpaper/translations/en.json +++ b/mpvpaper/translations/en.json @@ -5,13 +5,20 @@ "output": "Output", "pause": "Pause", "resume": "Resume", + "slideshow": "Slideshow", + "slideshow_off": "Off", "settings": { "allowed_cpus": { "label": "Allowed CPUs" }, "auto_pause": { - "description": "Pause playback while a fullscreen window covers the wallpaper (mpvpaper --auto-pause)", - "label": "Pause When Covered" + "description": "Pause or stop playback while a window covers the wallpaper", + "label": "Pause When Covered", + "options": { + "off": "Off", + "full": "Fullscreen", + "max": "Maximized" + } }, "cpu_quota": { "label": "CPU Quota"