From 132c9fe00b468f43bb1811768e3183e407534779 Mon Sep 17 00:00:00 2001 From: RAMA <143774106+Nomadcxx@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:03:53 +1000 Subject: [PATCH] Add gSlapper video wallpaper plugin (#152) * feat: add gSlapper wallpaper plugin * docs: clarify gSlapper installation and testing * docs: state Noctalia v5 requirement * docs: use generated plugin thumbnail * fix: keep gSlapper videos playing by default --- gslapper/README.md | 118 +++ gslapper/panel.luau | 1002 +++++++++++++++++++ gslapper/plugin.toml | 111 +++ gslapper/service.luau | 1699 +++++++++++++++++++++++++++++++++ gslapper/thumbnail.webp | Bin 0 -> 41686 bytes gslapper/translations/en.json | 102 ++ gslapper/widget.luau | 18 + 7 files changed, 3050 insertions(+) create mode 100644 gslapper/README.md create mode 100644 gslapper/panel.luau create mode 100644 gslapper/plugin.toml create mode 100644 gslapper/service.luau create mode 100644 gslapper/thumbnail.webp create mode 100644 gslapper/translations/en.json create mode 100644 gslapper/widget.luau diff --git a/gslapper/README.md b/gslapper/README.md new file mode 100644 index 0000000..6eb9365 --- /dev/null +++ b/gslapper/README.md @@ -0,0 +1,118 @@ +# gSlapper Wallpaper + +Choose images and video wallpapers from one picker. Apply one file to every +detected output or select a different file for each connector. + +> Requires Noctalia v5 and plugin API 19. Noctalia v4 uses a different QML +> plugin format and will not list or load this source. + +Video playback uses [gSlapper](https://github.com/Nomadcxx/gSlapper) instead of +mpvpaper. gSlapper uses GStreamer rather than libmpv; its README documents lower +CPU, memory, and GPU use than mpvpaper. Tests cover Niri, Hyprland, and Sway. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nomadcxx/gslapper` | +| Entries | Bar widget: `wallpaper`; panel: `picker`; service: `service` | + +## Requirements + +- `find` indexes the wallpaper roots +- [gSlapper](https://github.com/Nomadcxx/gSlapper) 1.5.2 or newer provides the + `gslapper` command and renders video wallpapers +- `gst-launch-1.0` creates image and video previews +- `pkill` cleans up a plugin-owned process if its socket stops responding +- `socat` sends gSlapper IPC commands + +Arch Linux provides `gst-launch-1.0` in `gstreamer`. Debian and Ubuntu provide +it in `gstreamer1.0-tools`. Preview generation can fail without blocking image +selection or video playback. + +## Install + +Add the canonical repository as a custom plugin source: + +1. Open **Settings → Plugins → Sources**. +2. Choose **Add custom repository**. +3. Enter `https://github.com/Nomadcxx/noctalia-gslapper`. +4. Open **Settings → Plugins → Install** and select **gSlapper Wallpaper**. + +Or install it from a shell: + +```sh +noctalia msg plugins source add gslapper git https://github.com/Nomadcxx/noctalia-gslapper +noctalia msg plugins enable nomadcxx/gslapper +``` + +Choose this source or the Noctalia community source. If you add both, Noctalia +uses the copy from the last user source. Remove the other source with: + +```sh +noctalia msg plugins source remove +``` + +## Usage + +In Noctalia Settings, open your existing bar, choose a widget section, select +Add Widget, then add **gSlapper Wallpaper**. You can remove Noctalia's +Wallpaper widget if you want one wallpaper button. + +Left-click the gSlapper widget to open the picker. Select **All outputs** or a +connector such as `eDP-1` or `DP-1`, then choose an image or video. The All +view can pause assigned videos or restore every output. + +Toggle the picker from a shell: + +```sh +noctalia msg panel-toggle nomadcxx/gslapper:picker +``` + +The service indexes your image and video roots when Noctalia starts. It creates +224 by 126 JPEG previews in the plugin data directory. The picker shows 24 +media files per page in a four-column grid and lists child folders in a +separate selector. + +## Settings + +| Setting | Default | Description | +| --- | --- | --- | +| Video directory | `~/Videos/Wallpapers` | Root for video wallpapers. Noctalia's wallpaper directory supplies images. | +| Video scale | `fill` | Uses gSlapper's fill, stretch, original, or panscan scaling mode. | +| When hidden | `none` | Keeps playing unless you select Auto Pause or Auto Stop. | +| Loop videos | On | Restarts video playback at the end. | +| FPS cap | `30` | Caps video wallpaper playback at 30, 60, or 100 FPS. | +| Fade between videos | Off | Enables gSlapper's fade transition. | +| Fade duration | `0.5` seconds | Sets the transition duration when you enable fading. | +| Additional GStreamer options | Empty | Appends options to gSlapper's GStreamer option list. | +| Widget glyph | `wallpaper-selector` | Changes the icon shown in the bar. | + +The service restarts active video wallpapers after you change a playback +setting. + +## Self-check + +Run the built-in model and trust-boundary checks with: + +```sh +noctalia msg plugin nomadcxx/gslapper:service all self-test +``` + +## Notes + +- The plugin starts one `gslapper` process and one Unix socket under + `$XDG_RUNTIME_DIR/noctalia-gslapper/` for each output with a video. +- The plugin stores assignments, its self-check report, its media index, and + cached preview JPEGs in Noctalia's plugin data directory. It makes no network + requests. +- The picker skips filenames that do not use UTF-8. +- Static images use Noctalia's wallpaper renderer. For video assignments, the + plugin disables Noctalia's wallpaper surface on that output while gSlapper + owns it, then restores the native surface when you select **Restore**. +- Use **Restore all** before disabling or reloading the plugin. This stops its + processes, removes its sockets, and re-enables Noctalia's wallpaper surfaces. +- The current plugin API does not report static wallpaper changes made outside + this plugin, so its saved image assignment can become stale. +- Assignments follow connector names, not monitor serial numbers. Review them + after GPU, dock, or cabling changes that rename outputs. diff --git a/gslapper/panel.luau b/gslapper/panel.luau new file mode 100644 index 0000000..dfd2ef4 --- /dev/null +++ b/gslapper/panel.luau @@ -0,0 +1,1002 @@ +--!nonstrict + +local IMAGE_EXTENSIONS = { jpg = true, jpeg = true, png = true, webp = true, jxl = true, bmp = true } +local VIDEO_EXTENSIONS = { mp4 = true, mkv = true, webm = true, mov = true, avi = true, m4v = true, gif = true } +local GRID_BATCH_SIZE = 12 +local PAGE_SIZE = 24 + +local filter = "all" +local search = "" +local page = 1 +local currentRoot = nil +local currentDirectory = nil +local entries = {} +local scanError = nil +local library = noctalia.state.get("library") or {} +local libraryStatus = noctalia.state.get("library_status") or { phase = "scanning" } +local scanning = libraryStatus.phase == "scanning" +local assignments = noctalia.state.get("assignments") or {} +local runtime = noctalia.state.get("runtime") or {} +local capabilities = noctalia.state.get("capabilities") or { video = false, checking = true } +local outputs = noctalia.state.get("outputs") or {} +local commandResult = noctalia.state.get("command_result") +local selectedOutput = "*" +local nonceCounter = 0 +local outputOptions = {} +local folderEntries = {} +local gridRows = nil +local gridBuilding = false +local gridGeneration = 0 +local gridProgress = 0 +local indexBuilding = false +local indexGeneration = 0 +local indexProgress = 0 +local loadedLibraryVersion = nil +local viewMedia = {} +local viewDirectories = {} +local viewBuilding = false +local viewDirty = true +local viewGeneration = 0 +local viewProgress = 0 +local loadingPulse = 0 +local loadingElapsed = 0 +local render + +local function tr(key, substitutions) + return noctalia.tr(key, substitutions) +end + +local function trimTrailingSlash(path) + if type(path) ~= "string" then + return nil + end + path = path:gsub("/+$", "") + return path == "" and "/" or path +end + +local function classifyName(name) + if type(name) ~= "string" then + return nil + end + local extension = name:match("%.([^./]+)$") + extension = extension and extension:lower() or nil + if extension == "gif" then + return "video" + elseif extension and IMAGE_EXTENSIONS[extension] then + return "image" + elseif extension and VIDEO_EXTENSIONS[extension] then + return "video" + end + return nil +end + +local function safeChild(root, directory, name) + root = trimTrailingSlash(root) + directory = trimTrailingSlash(directory) + if not root or not directory or type(name) ~= "string" or name == "" or name == "." or name == ".." + or name:find("/", 1, true) or name:find("[\n\r%z]") then + return nil + end + if directory ~= root and directory:sub(1, #root + 1) ~= root .. "/" then + return nil + end + return (directory == "/" and "" or directory) .. "/" .. name +end + +local function sortEntries(values) + table.sort(values, function(left, right) + if left.kind == "directory" and right.kind ~= "directory" then + return true + elseif left.kind ~= "directory" and right.kind == "directory" then + return false + end + local leftName = left.name:lower() + local rightName = right.name:lower() + return leftName == rightName and left.name < right.name or leftName < rightName + end) +end + +local function assignmentMatchCount(values, connectors, path) + local count = 0 + for _, connector in ipairs(connectors or {}) do + if values[connector] and values[connector].path == path then + count = count + 1 + end + end + return count +end + +local function mixedCounts(values, connectors) + local counts = { video = 0, image = 0, unassigned = 0 } + for _, connector in ipairs(connectors or {}) do + local assignment = values[connector] + if assignment and assignment.kind == "video" then + counts.video = counts.video + 1 + elseif assignment and assignment.kind == "image" then + counts.image = counts.image + 1 + else + counts.unassigned = counts.unassigned + 1 + end + end + return counts +end + +local function videoPlaybackAction(values, liveState, connectors) + local videoCount = 0 + for _, connector in ipairs(connectors or {}) do + if values[connector] and values[connector].kind == "video" then + videoCount = videoCount + 1 + if not liveState[connector] or liveState[connector].status ~= "paused" then + return "pause" + end + end + end + return videoCount > 0 and "resume" or "pause" +end + +local function fnv1a32(value) + -- ponytail: this mirrors the service cache key; use a wider digest if a real collision appears. + local hash = 2166136261 + for index = 1, #value do + hash = bit32.bxor(hash, value:byte(index)) + local low = (hash % 65536) * 16777619 + local high = (math.floor(hash / 65536) * 16777619) % 65536 + hash = (low + high * 65536) % 4294967296 + end + return string.format("%08x", hash) +end + +local function thumbnailPath(path, size, mtime) + local directory = noctalia.pluginDataDir() + if not directory then + return nil + end + return directory .. "/thumb-" .. fnv1a32("v3:" .. path .. ":" .. tostring(size) .. ":" .. tostring(mtime)) .. ".jpg" +end + +local function invalidateGrid() + gridGeneration = gridGeneration + 1 + gridRows = nil + gridBuilding = false + gridProgress = 0 +end + +local function invalidateView() + viewGeneration = viewGeneration + 1 + viewMedia = {} + viewDirectories = {} + viewBuilding = false + viewDirty = true + viewProgress = 0 + invalidateGrid() +end + +local function nextNulField(raw, offset) + local boundary = raw:find("\0", offset, true) + if not boundary then + return nil, offset + end + return raw:sub(offset, boundary - 1), boundary + 1 +end + +local function acceptsMedia(rootKind, kind, name) + if rootKind == "mixed" or rootKind == "video" then + return rootKind == "mixed" or kind == "video" + end + return kind == "image" or name:lower():match("%.gif$") ~= nil +end + +local function finishIndexBuild(generation, value) + if generation ~= indexGeneration then + return + end + library = value + loadedLibraryVersion = value.version + indexBuilding = false + indexProgress = 1 + invalidateView() + local started = noctalia.runAsync(":", render, 2000) + if not started then + render() + end +end + +local function loadLibrary(value) + if type(value) ~= "table" then + return false + end + if value.version == loadedLibraryVersion and not indexBuilding then + return true + end + if type(value.entries) == "table" then + library = value + entries = value.entries + sortEntries(entries) + loadedLibraryVersion = value.version + indexBuilding = false + invalidateView() + return true + end + if type(value.sources) ~= "table" then + return false + end + + indexGeneration = indexGeneration + 1 + local generation = indexGeneration + local tasks = {} + local totalBytes = 0 + local consumedBytes = 0 + entries = {} + indexBuilding = true + indexProgress = 0 + for _, source in ipairs(value.sources) do + local raw = noctalia.readFile(source.path) + if type(raw) == "string" then + totalBytes = totalBytes + #raw + table.insert(tasks, { + raw = raw, + offset = 1, + root = source.root, + rootKind = source.rootKind, + }) + else + scanError = "Wallpaper index is unavailable" + end + end + + local processBatch + processBatch = function() + if generation ~= indexGeneration then + return + end + local remaining = GRID_BATCH_SIZE + while remaining > 0 and #tasks > 0 do + local task = tasks[1] + local previousOffset = task.offset + local fileType + fileType, task.offset = nextNulField(task.raw, task.offset) + if not fileType then + table.remove(tasks, 1) + else + local size + local mtime + local path + size, task.offset = nextNulField(task.raw, task.offset) + mtime, task.offset = nextNulField(task.raw, task.offset) + path, task.offset = nextNulField(task.raw, task.offset) + consumedBytes = consumedBytes + task.offset - previousOffset + remaining = remaining - 1 + if path and utf8.len(path) ~= nil then + local name = path:match("([^/]+)$") + local parent = path:match("^(.*)/[^/]+$") + if fileType == "d" then + table.insert(entries, { + name = name, + path = path, + parent = parent, + kind = "directory", + root = task.root, + rootKind = task.rootKind, + }) + elseif fileType == "f" then + local kind = classifyName(name) + if kind and acceptsMedia(task.rootKind, kind, name) then + local numericSize = tonumber(size) or 0 + local numericMtime = tonumber(mtime) or 0 + table.insert(entries, { + name = name, + path = path, + parent = parent, + kind = kind, + root = task.root, + rootKind = task.rootKind, + thumbnail = thumbnailPath(path, numericSize, numericMtime), + }) + end + end + end + end + end + indexProgress = totalBytes > 0 and math.min(1, consumedBytes / totalBytes) or 1 + if #tasks == 0 then + finishIndexBuild(generation, value) + return + end + local started = noctalia.runAsync(":", processBatch, 2000) + if not started then + indexBuilding = false + scanError = "Noctalia command capacity is busy" + render() + end + end + + processBatch() + return true +end + +local function partitionEntries(values, needle) + local directories = {} + local media = {} + needle = tostring(needle or ""):lower() + for _, entry in ipairs(values or {}) do + local inDirectory = currentDirectory and entry.parent == currentDirectory or not currentDirectory and entry.parent == entry.root + if inDirectory and entry.kind == "directory" then + table.insert(directories, entry) + elseif inDirectory + and (filter == "all" or filter == "images" and entry.kind == "image" or filter == "videos" and entry.kind == "video") + and (needle == "" or entry.name:lower():find(needle, 1, true)) then + table.insert(media, entry) + end + end + return directories, media +end + +local function beginViewBuild() + viewGeneration = viewGeneration + 1 + local generation = viewGeneration + local index = 1 + local needle = search:lower() + viewMedia = {} + viewDirectories = {} + viewBuilding = true + viewDirty = false + viewProgress = 0 + + local processBatch + processBatch = function() + if generation ~= viewGeneration then + return + end + local last = math.min(#entries, index + GRID_BATCH_SIZE - 1) + while index <= last do + local entry = entries[index] + local inDirectory = currentDirectory and entry.parent == currentDirectory + or not currentDirectory and entry.parent == entry.root + if inDirectory and entry.kind == "directory" then + table.insert(viewDirectories, entry) + elseif inDirectory + and (filter == "all" or filter == "images" and entry.kind == "image" + or filter == "videos" and entry.kind == "video") + and (needle == "" or entry.name:lower():find(needle, 1, true)) then + table.insert(viewMedia, entry) + end + index = index + 1 + end + viewProgress = #entries == 0 and 1 or math.min(1, (index - 1) / #entries) + if index > #entries then + sortEntries(viewDirectories) + sortEntries(viewMedia) + viewBuilding = false + invalidateGrid() + render() + return + end + local started = noctalia.runAsync(":", processBatch, 2000) + if not started then + viewBuilding = false + scanError = "Noctalia command capacity is busy" + render() + end + end + + processBatch() +end + +local function paginate(values, requestedPage) + local pages = math.max(1, math.ceil(#values / PAGE_SIZE)) + local currentPage = math.max(1, math.min(requestedPage, pages)) + local first = (currentPage - 1) * PAGE_SIZE + 1 + local visible = {} + for index = first, math.min(#values, first + PAGE_SIZE - 1) do + table.insert(visible, values[index]) + end + return visible, currentPage, pages +end + +local function visibleEntries() + local visible, currentPage, pages = paginate(viewMedia, page) + page = currentPage + return visible, #viewMedia, pages, viewDirectories +end + +local function connectorNames() + local names = {} + for _, output in ipairs(outputs or {}) do + local name = type(output) == "table" and output.name or output + if type(name) == "string" and name ~= "" then + table.insert(names, name) + end + end + table.sort(names) + return names +end + +local function nextNonce() + nonceCounter = nonceCounter + 1 + return noctalia.nowMs() * 1000 + nonceCounter +end + +local function sendCommand(action, entry) + local command = { + nonce = nextNonce(), + action = action, + target = action == "restore-all" and "*" or selectedOutput, + } + if entry then + command.path = entry.path + command.preview_path = entry.thumbnail and noctalia.fileExists(entry.thumbnail) and entry.thumbnail or nil + end + noctalia.state.set("command", command) +end + +local function selectedConnectors() + return selectedOutput == "*" and connectorNames() or { selectedOutput } +end + +local function selectionBusy() + for _, connector in ipairs(selectedConnectors()) do + if runtime[connector] and runtime[connector].status == "starting" then + return true + end + end + return false +end + +local function navigateDirectory(entry) + if type(entry) ~= "table" or entry.kind ~= "directory" + or type(entry.root) ~= "string" or type(entry.path) ~= "string" + or (entry.path ~= entry.root and entry.path:sub(1, #entry.root + 1) ~= entry.root .. "/") then + scanError = tr("panel.navigation_blocked") + render() + return + end + currentRoot = { kind = entry.rootKind, path = entry.root } + currentDirectory = entry.path + search = "" + page = 1 + scanError = libraryStatus.error + invalidateView() + render() +end + +local function navigateUp() + if not currentDirectory or not currentRoot then + return + end + if currentDirectory == currentRoot.path then + currentDirectory = nil + currentRoot = nil + else + local parent = currentDirectory:match("^(.*)/[^/]+$") + if not parent or parent == currentRoot.path then + currentDirectory = nil + currentRoot = nil + else + currentDirectory = parent + end + end + search = "" + page = 1 + invalidateView() + render() +end + +local function tileFor(entry, connectors) + local matches = assignmentMatchCount(assignments, connectors, entry.path) + local selected = #connectors > 0 and matches == #connectors + local videoDisabled = entry.kind == "video" and not capabilities.video + local busy = selectionBusy() + local choose + if not videoDisabled and not busy then + choose = function() + sendCommand(entry.kind == "image" and "assign-image" or "assign-video", entry) + end + end + local preview + if entry.thumbnail and noctalia.fileExists(entry.thumbnail) then + preview = ui.image({ + path = entry.thumbnail, + height = 96, + fit = "cover", + radius = 8, + border = selected and "primary" or "outline/0.45", + borderWidth = selected and 2 or 1, + onClick = choose, + }) + else + preview = ui.button({ + height = 96, + glyph = entry.kind == "image" and "photo" or "player-play", + glyphSize = 36, + variant = "ghost", + enabled = choose ~= nil, + selected = selected, + onClick = choose, + }) + end + + local detail = entry.name + if entry.kind == "video" then + detail = tr("panel.video_badge") .. " · " .. detail + end + if selectedOutput == "*" and matches > 0 and matches < #connectors then + detail = detail .. " · " .. tr("panel.partial_badge", { count = matches, total = #connectors }) + end + + return ui.column({ + key = entry.path, + width = 210, + gap = 5, + opacity = videoDisabled and 0.55 or 1, + }, { + preview, + ui.label({ + text = detail, + maxLines = 1, + fontSize = 11, + color = entry.kind == "video" and "secondary" or "on_surface", + }), + }) +end + +local function beginGridBuild(values, connectors) + gridGeneration = gridGeneration + 1 + local generation = gridGeneration + gridRows = {} + gridBuilding = true + gridProgress = 0 + local index = 1 + local row = {} + + local processBatch + processBatch = function() + if generation ~= gridGeneration then + return + end + local last = math.min(#values, index + GRID_BATCH_SIZE - 1) + while index <= last do + table.insert(row, tileFor(values[index], connectors)) + if #row == 4 then + table.insert(gridRows, ui.row({ gap = 10, align = "start" }, row)) + row = {} + end + index = index + 1 + end + gridProgress = #values == 0 and 1 or math.min(1, (index - 1) / #values) + if index > #values then + if #row > 0 then + table.insert(gridRows, ui.row({ gap = 10, align = "start" }, row)) + end + gridBuilding = false + render() + return + end + local started = noctalia.runAsync(":", function() + processBatch() + end, 2000) + if not started then + gridBuilding = false + scanError = "Noctalia command capacity is busy" + render() + end + end + + processBatch() +end + +local function loadingState(label, progress) + local rows = {} + for rowIndex = 1, 2 do + local cards = {} + for cardIndex = 1, 4 do + local offset = ((rowIndex - 1) * 4 + cardIndex - 1) * 0.08 + local opacity = 0.22 + 0.28 * math.abs(math.sin((loadingPulse + offset) * math.pi)) + table.insert(cards, ui.column({ + width = 210, + height = 128, + radius = 10, + fill = "surface_variant", + opacity = opacity, + })) + end + table.insert(rows, ui.row({ gap = 10 }, cards)) + end + return ui.column({ flexGrow = 1, gap = 10, justify = "center" }, { + ui.row({ align = "center", gap = 8 }, { + ui.label({ text = label, color = "on_surface_variant", flexGrow = 1 }), + ui.label({ text = tostring(math.floor(progress * 100)) .. "%", color = "on_surface_variant", fontSize = 11 }), + }), + ui.progress({ height = 4, progress = progress, fill = "primary", track = "surface_variant", radius = 2 }), + ui.column({ gap = 10 }, rows), + }) +end + +local function folderStrip(directories) + if #directories == 0 then + folderEntries = {} + return nil + end + folderEntries = directories + local options = { tr("panel.folders") } + for _, entry in ipairs(directories) do + table.insert(options, entry.name) + end + return ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "folder", color = "on_surface_variant", size = 18 }), + ui.select({ + options = options, + selectedIndex = 0, + flexGrow = 1, + onChange = "onFolderChanged", + }), + }) +end + +local function activeStrip(connectors) + if selectedOutput == "*" then + local counts = mixedCounts(assignments, connectors) + local playbackAction = videoPlaybackAction(assignments, runtime, connectors) + return ui.row({ align = "center", gap = 8, padding = 9, radius = 9, fill = "surface_variant/0.40" }, { + ui.glyph({ name = "devices", color = "primary", size = 18 }), + ui.label({ + text = tr("panel.mixed_summary", { + outputs = #connectors, + videos = counts.video, + images = counts.image, + }), + flexGrow = 1, + maxLines = 1, + }), + ui.button({ + text = tr(playbackAction == "resume" and "panel.resume_videos" or "panel.pause_videos"), + variant = "ghost", + enabled = counts.video > 0 and not selectionBusy(), + onClick = function() sendCommand(playbackAction) end, + }), + ui.button({ + text = tr("panel.restore_all"), + variant = "ghost", + enabled = #connectors > 0 and not selectionBusy(), + onClick = function() sendCommand("restore-all") end, + }), + }) + end + + local assignment = assignments[selectedOutput] + local live = runtime[selectedOutput] + if not assignment then + return nil + end + local isVideo = assignment.kind == "video" + local paused = live and live.status == "paused" + return ui.row({ align = "center", gap = 8, padding = 9, radius = 9, fill = "surface_variant/0.40" }, { + ui.glyph({ name = isVideo and "movie" or "photo", color = isVideo and "secondary" or "primary", size = 18 }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = assignment.path:match("([^/]+)$") or assignment.path, fontWeight = "bold", maxLines = 1 }), + ui.label({ + text = live and tr("panel.status_" .. tostring(live.status)) or tr("panel.status_static"), + fontSize = 10, + color = live and live.error and "error" or "on_surface_variant", + }), + }), + ui.button({ + text = tr(paused and "panel.resume" or "panel.pause"), + variant = "ghost", + visible = isVideo, + enabled = live ~= nil and not selectionBusy(), + onClick = function() sendCommand(paused and "resume" or "pause") end, + }), + ui.button({ + text = tr("panel.restore"), + variant = "ghost", + enabled = not selectionBusy(), + onClick = function() sendCommand("restore") end, + }), + }) +end + +local function resultBanner() + if not commandResult or type(commandResult.summary) ~= "table" then + return nil + end + local summary = commandResult.summary + if (summary.pending or 0) > 0 then + return ui.label({ text = tr("panel.applying"), color = "on_surface_variant" }) + end + local errors = {} + for connector, result in pairs(commandResult.results or {}) do + if result.ok == false then + table.insert(errors, connector .. ": " .. tostring(result.error)) + end + end + table.sort(errors) + if #errors > 0 then + return ui.label({ text = table.concat(errors, " · "), color = "error", maxLines = 2 }) + end + return nil +end + +render = function() + if not scanning and not indexBuilding and viewDirty and not viewBuilding then + beginViewBuild() + end + local visible, total, pages, directories = visibleEntries() + local connectors = connectorNames() + if not indexBuilding and not viewBuilding and total > 0 and gridRows == nil and not gridBuilding then + beginGridBuild(visible, selectedConnectors()) + end + local loading = scanning or indexBuilding or viewBuilding or gridBuilding + panel.setNeedsFrameTick(loading) + outputOptions = { tr("panel.all_outputs") } + local selectedIndex = 0 + for index, connector in ipairs(connectors) do + table.insert(outputOptions, connector) + if selectedOutput == connector then + selectedIndex = index + end + end + if selectedOutput ~= "*" and selectedIndex == 0 then + selectedOutput = "*" + end + + local children = { + ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("panel.title"), fontSize = 18, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("panel.settings"), onClick = "onOpenSettings" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.close"), onClick = "onCloseClicked" }), + }), + ui.row({ align = "center", gap = 8 }, { + ui.input({ + key = "search-" .. filter .. "-" .. selectedOutput, + value = search, + placeholder = tr("panel.search"), + focus = true, + flexGrow = 1, + onChange = "onSearchChanged", + }), + ui.select({ + options = outputOptions, + selectedIndex = selectedIndex, + width = 170, + onChange = "onOutputChanged", + }), + }), + ui.row({ align = "center", gap = 6 }, { + ui.button({ text = tr("panel.filter_all"), selected = filter == "all", variant = "ghost", onClick = "onFilterAll" }), + ui.button({ text = tr("panel.filter_images"), selected = filter == "images", variant = "ghost", onClick = "onFilterImages" }), + ui.button({ text = tr("panel.filter_videos"), selected = filter == "videos", variant = "ghost", onClick = "onFilterVideos" }), + ui.spacer({ flexGrow = 1 }), + ui.button({ + glyph = "arrow-up", + text = tr("panel.up"), + variant = "ghost", + visible = currentDirectory ~= nil, + onClick = "onNavigateUp", + }), + }), + } + + local folders = folderStrip(directories) + if folders then table.insert(children, folders) end + + if capabilities.checking then + table.insert(children, ui.label({ text = tr("panel.checking_dependencies"), color = "on_surface_variant" })) + elseif not capabilities.video then + table.insert(children, ui.column({ + gap = 3, + padding = 9, + radius = 8, + fill = "error/0.10", + border = "error/0.45", + borderWidth = 1, + }, { + ui.label({ text = tr("panel.video_unavailable"), color = "error", fontWeight = "bold" }), + ui.label({ text = tostring(capabilities.error or ""), fontSize = 11, color = "on_surface_variant" }), + })) + end + + if scanError and total > 0 then + table.insert(children, ui.label({ + text = tr("panel.directory_unreadable") .. ": " .. scanError, + color = "error", + maxLines = 2, + })) + end + local active = activeStrip(connectors) + if active then table.insert(children, active) end + local result = resultBanner() + if result then table.insert(children, result) end + + if scanning then + local consumed = tonumber(libraryStatus.consumed_bytes) or 0 + local bytes = tonumber(libraryStatus.bytes) or 0 + table.insert(children, loadingState(tr("panel.scanning"), bytes > 0 and math.min(1, consumed / bytes) or 0)) + elseif scanError and total == 0 then + table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 8 }, { + ui.glyph({ name = "alert-circle", size = 32, color = "error" }), + ui.label({ text = tr("panel.directory_unreadable"), color = "error", fontWeight = "bold" }), + ui.label({ text = scanError, color = "on_surface_variant", maxLines = 2 }), + })) + elseif indexBuilding then + table.insert(children, loadingState(tr("panel.preparing_library"), indexProgress)) + elseif viewBuilding then + table.insert(children, loadingState(tr("panel.preparing_library"), viewProgress)) + elseif gridBuilding then + table.insert(children, loadingState(tr("panel.preparing_library"), gridProgress)) + elseif total == 0 then + table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 8 }, { + ui.glyph({ name = "photo-off", size = 36, color = "outline" }), + ui.label({ + text = search ~= "" and tr("panel.no_search_results") or tr("panel.directory_empty"), + color = "on_surface_variant", + }), + })) + else + table.insert(children, ui.scroll({ + key = "media:" .. filter .. ":" .. tostring(currentDirectory or "") .. ":" .. tostring(page) .. ":" .. search, + gap = 10, + flexGrow = 1, + align = "stretch", + }, gridRows or {})) + end + + if libraryStatus.phase == "thumbnails" and (libraryStatus.thumbnails_total or 0) > 0 then + local done = tonumber(libraryStatus.thumbnails_done) or 0 + local thumbnails = tonumber(libraryStatus.thumbnails_total) or 0 + table.insert(children, ui.column({ gap = 4 }, { + ui.label({ + text = tr("panel.generating_previews", { count = done, total = thumbnails }), + color = "on_surface_variant", + fontSize = 11, + }), + ui.progress({ + height = 3, + progress = thumbnails > 0 and math.min(1, done / thumbnails) or 0, + fill = "primary", + track = "surface_variant", + radius = 2, + }), + })) + end + + table.insert(children, ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("panel.media_count", { count = total }), color = "on_surface_variant", fontSize = 11, flexGrow = 1 }), + ui.button({ glyph = "chevron-left", variant = "ghost", enabled = page > 1, onClick = "onPreviousPage" }), + ui.label({ text = tr("panel.page", { page = page, pages = pages }), fontSize = 11 }), + ui.button({ glyph = "chevron-right", variant = "ghost", enabled = page < pages, onClick = "onNextPage" }), + })) + panel.render(ui.column({ padding = 16, gap = 10, flexGrow = 1 }, children)) +end + +function onOpen(_context) + outputs = noctalia.state.get("outputs") or noctalia.outputs() or {} + assignments = noctalia.state.get("assignments") or {} + runtime = noctalia.state.get("runtime") or {} + capabilities = noctalia.state.get("capabilities") or capabilities + libraryStatus = noctalia.state.get("library_status") or libraryStatus + scanError = libraryStatus.error + scanning = libraryStatus.phase == "scanning" + loadLibrary(noctalia.state.get("library") or library) + render() +end + +function onCloseClicked() + panel.close() +end + +function onOpenSettings() + noctalia.openSettings() +end + +function onSearchChanged(value) + search = tostring(value or "") + page = 1 + invalidateView() + render() +end + +local function setFilter(value) + filter = value + currentDirectory = nil + currentRoot = nil + page = 1 + invalidateView() + render() +end + +function onFilterAll() setFilter("all") end +function onFilterImages() setFilter("images") end +function onFilterVideos() setFilter("videos") end + +function onOutputChanged(index, _label) + selectedOutput = index == 0 and "*" or outputOptions[index + 1] or "*" + invalidateGrid() + render() +end + +function onNavigateUp() navigateUp() end + +function onPreviousPage() + page = math.max(1, page - 1) + invalidateGrid() + render() +end + +function onNextPage() + page = page + 1 + invalidateGrid() + render() +end + +function onFolderChanged(index, _label) + if index > 0 and folderEntries[index] then + navigateDirectory(folderEntries[index]) + end +end + +function onFrameTick(deltaMs) + if not scanning and not indexBuilding and not viewBuilding and not gridBuilding then + panel.setNeedsFrameTick(false) + return + end + loadingElapsed = loadingElapsed + (tonumber(deltaMs) or 0) + if loadingElapsed >= 100 then + loadingElapsed = loadingElapsed - 100 + loadingPulse = (loadingPulse + 0.08) % 1 + render() + end +end + +noctalia.state.watch("assignments", function(value) + assignments = type(value) == "table" and value or {} + invalidateGrid() + render() +end) +noctalia.state.watch("runtime", function(value) + runtime = type(value) == "table" and value or {} + render() +end) +noctalia.state.watch("capabilities", function(value) + capabilities = type(value) == "table" and value or capabilities + invalidateGrid() + render() +end) +noctalia.state.watch("outputs", function(value) + outputs = type(value) == "table" and value or {} + invalidateGrid() + render() +end) +noctalia.state.watch("command_result", function(value) + commandResult = value + invalidateGrid() + render() +end) +noctalia.state.watch("library", function(value) + if loadLibrary(value) then + scanError = libraryStatus.error + render() + end +end) +noctalia.state.watch("library_status", function(value) + local previousPhase = libraryStatus.phase + libraryStatus = type(value) == "table" and value or libraryStatus + scanError = libraryStatus.error + scanning = libraryStatus.phase == "scanning" + if libraryStatus.phase == "ready" and previousPhase ~= "ready" then + invalidateGrid() + end + render() +end) + +return { + classifyName = classifyName, + safeChild = safeChild, + sortEntries = sortEntries, + fnv1a32 = fnv1a32, + assignmentMatchCount = assignmentMatchCount, + mixedCounts = mixedCounts, + videoPlaybackAction = videoPlaybackAction, + partitionEntries = partitionEntries, + paginate = paginate, + pageSize = PAGE_SIZE, +} diff --git a/gslapper/plugin.toml b/gslapper/plugin.toml new file mode 100644 index 0000000..d456088 --- /dev/null +++ b/gslapper/plugin.toml @@ -0,0 +1,111 @@ +id = "nomadcxx/gslapper" +name = "gSlapper Wallpaper" +version = "0.1.1" +plugin_api = 19 +author = "Nomadcxx" +license = "MIT" +dependencies = ["find", "gslapper", "gst-launch-1.0", "pkill", "socat"] +tags = ["bar", "panel", "service", "video", "wallpaper"] +icon = "wallpaper-selector" +description = "Choose an image or video wallpaper for each output." + +[[setting]] +key = "video_directory" +type = "folder" +label_key = "settings.video_directory.label" +description_key = "settings.video_directory.description" +default = "~/Videos/Wallpapers" + +[[setting]] +key = "scale" +type = "select" +label_key = "settings.scale.label" +description_key = "settings.scale.description" +default = "fill" +options = [ + { value = "fill", label_key = "settings.scale.options.fill" }, + { value = "stretch", label_key = "settings.scale.options.stretch" }, + { value = "original", label_key = "settings.scale.options.original" }, + { value = "panscan", label_key = "settings.scale.options.panscan" }, +] + +[[setting]] +key = "hidden_behavior" +type = "select" +label_key = "settings.hidden_behavior.label" +description_key = "settings.hidden_behavior.description" +default = "none" +options = [ + { value = "none", label_key = "settings.hidden_behavior.options.none" }, + { value = "auto-pause", label_key = "settings.hidden_behavior.options.auto_pause" }, + { value = "auto-stop", label_key = "settings.hidden_behavior.options.auto_stop" }, +] + +[[setting]] +key = "loop" +type = "bool" +label_key = "settings.loop.label" +description_key = "settings.loop.description" +default = true + +[[setting]] +key = "fps_cap" +type = "select" +label_key = "settings.fps_cap.label" +description_key = "settings.fps_cap.description" +default = "30" +options = [ + { value = "30", label_key = "settings.fps_cap.options.fps_30" }, + { value = "60", label_key = "settings.fps_cap.options.fps_60" }, + { value = "100", label_key = "settings.fps_cap.options.fps_100" }, +] + +[[setting]] +key = "fade" +type = "bool" +label_key = "settings.fade.label" +description_key = "settings.fade.description" +default = false + +[[setting]] +key = "fade_duration" +type = "double" +label_key = "settings.fade_duration.label" +description_key = "settings.fade_duration.description" +default = 0.5 +min = 0.1 +max = 5.0 +step = 0.1 +visible_when = { key = "fade", values = ["true"] } + +[[setting]] +key = "gst_options" +type = "string" +label_key = "settings.gst_options.label" +description_key = "settings.gst_options.description" +default = "" +advanced = true + +[[setting]] +key = "glyph" +type = "glyph" +label_key = "settings.glyph.label" +description_key = "settings.glyph.description" +default = "wallpaper-selector" + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "picker" +entry = "panel.luau" +width = 980 +height = 1100 +placement = "attached" +position = "auto" +keyboard_focus = "exclusive" + +[[widget]] +id = "wallpaper" +entry = "widget.luau" diff --git a/gslapper/service.luau b/gslapper/service.luau new file mode 100644 index 0000000..d04ba4f --- /dev/null +++ b/gslapper/service.luau @@ -0,0 +1,1699 @@ +--!nonstrict + +local assignments = {} +local runtime = {} +local generations = {} +local pending = {} +local capabilities = { video = false, checking = true } +local previousConnectors = {} +local lastHandledNonce = 0 +local commandBatches = {} +local activeRequests = {} +local restartSettingsSignature = "" +local NOTIFICATION_TITLE = "gSlapper" +local IMAGE_EXTENSIONS = { jpg = true, jpeg = true, png = true, webp = true, jxl = true, bmp = true } +local VIDEO_EXTENSIONS = { mp4 = true, mkv = true, webm = true, mov = true, avi = true, m4v = true, gif = true } +local LIBRARY_BATCH_SIZE = 6 +local THUMBNAIL_LIMIT = 2 +local libraryGeneration = 0 +local libraryVersion = 0 +local libraryTasks = {} +local librarySources = {} +local libraryErrors = {} +local libraryProcessed = 0 +local libraryBytes = 0 +local libraryConsumedBytes = 0 +local libraryInvalidUtf8 = 0 +local thumbnailQueue = {} +local thumbnailActive = 0 +local thumbnailReady = 0 +local thumbnailDone = 0 +local thumbnailTotal = 0 +local thumbnailsAvailable = false +local libraryRootSignature = "" + +local REQUIRED_FLAGS = { + "--fork", + "--ipc-socket", + "--no-save-state", + "--auto-pause", + "--auto-stop", + "--fps-cap", + "--gst-options", + "--transition-type", + "--transition-duration", +} + +local VIDEO_CHANGE_RESTART_ERROR = "ERROR: cannot update path (use --auto-stop for video changes)" + +local function copyTable(value) + local result = {} + for key, item in pairs(value or {}) do + result[key] = item + end + return result +end + +local function copyList(values) + local result = {} + for index, value in ipairs(values or {}) do + result[index] = value + end + return result +end + +local function validateShellValue(value) + if type(value) ~= "string" or value:find("[\n\r%z]") then + return nil, "value contains a forbidden control character" + end + return value +end + +local function shellQuote(value) + local valid, message = validateShellValue(value) + if not valid then + return nil, message + end + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function trimTrailingSlash(path) + if type(path) ~= "string" then + return nil + end + path = path:gsub("/+$", "") + return path == "" and "/" or path +end + +local function mediaRoots() + local image = trimTrailingSlash(noctalia.wallpaperDirectory()) + local configured = noctalia.getConfig("video_directory") + local video = trimTrailingSlash(type(configured) == "string" and noctalia.expandPath(configured) or nil) + return image, video +end + +local function validMediaRoot(path) + return type(path) == "string" and path:sub(1, 1) == "/" and validateShellValue(path) +end + +local function classifyMedia(name) + if type(name) ~= "string" then + return nil + end + local extension = name:match("%.([^./]+)$") + extension = extension and extension:lower() or nil + if extension == "gif" then + return "video" + elseif extension and IMAGE_EXTENSIONS[extension] then + return "image" + elseif extension and VIDEO_EXTENSIONS[extension] then + return "video" + end + return nil +end + +local function fnv1a32(value) + -- ponytail: 32-bit FNV keeps cache names short; use a wider digest if a real collision appears. + local hash = 2166136261 + for index = 1, #value do + hash = bit32.bxor(hash, value:byte(index)) + local low = (hash % 65536) * 16777619 + local high = (math.floor(hash / 65536) * 16777619) % 65536 + hash = (low + high * 65536) % 4294967296 + end + return string.format("%08x", hash) +end + +local function thumbnailPath(path, size, mtime) + local directory = noctalia.pluginDataDir() + if not directory then + return nil + end + return directory .. "/thumb-" .. fnv1a32("v3:" .. path .. ":" .. tostring(size) .. ":" .. tostring(mtime)) .. ".jpg" +end + +local function publishLibraryStatus(phase, errorMessage) + noctalia.state.set("library_status", { + phase = phase, + generation = libraryGeneration, + version = libraryVersion, + processed = libraryProcessed, + bytes = libraryBytes, + consumed_bytes = libraryConsumedBytes, + skipped_invalid_utf8 = libraryInvalidUtf8, + thumbnails_ready = thumbnailReady, + thumbnails_done = thumbnailDone, + thumbnails_total = thumbnailTotal, + error = errorMessage, + }) +end + +local function nextNulField(raw, offset) + local boundary = raw:find("\0", offset, true) + if not boundary then + return nil, offset + end + return raw:sub(offset, boundary - 1), boundary + 1 +end + +local function isWithinRoot(path, root) + return path == root or path:sub(1, #root + 1) == root .. "/" +end + +local function acceptsMedia(rootKind, kind, name) + if rootKind == "mixed" or rootKind == "video" then + return rootKind == "mixed" or kind == "video" + end + return kind == "image" or name:lower():match("%.gif$") ~= nil +end + +local startThumbnailJobs +local function finishLibraryIndex(generation) + if generation ~= libraryGeneration then + return + end + libraryVersion = libraryVersion + 1 + local imageRoot, videoRoot = mediaRoots() + noctalia.state.set("library", { + version = libraryVersion, + roots = { image = imageRoot, video = videoRoot }, + sources = librarySources, + }) + thumbnailTotal = thumbnailReady + #thumbnailQueue + thumbnailDone = thumbnailReady + if thumbnailTotal == thumbnailReady then + publishLibraryStatus("ready", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil) + else + publishLibraryStatus("thumbnails", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil) + startThumbnailJobs(generation) + end +end + +local scheduleLibraryBatch +local function processLibraryBatch(generation) + if generation ~= libraryGeneration then + return + end + local remaining = LIBRARY_BATCH_SIZE + while remaining > 0 and #libraryTasks > 0 do + local task = libraryTasks[1] + local previousOffset = task.offset + local fileType + fileType, task.offset = nextNulField(task.raw, task.offset) + if not fileType then + table.remove(libraryTasks, 1) + else + local size + local mtime + local path + size, task.offset = nextNulField(task.raw, task.offset) + mtime, task.offset = nextNulField(task.raw, task.offset) + path, task.offset = nextNulField(task.raw, task.offset) + libraryConsumedBytes = libraryConsumedBytes + task.offset - previousOffset + remaining = remaining - 1 + libraryProcessed = libraryProcessed + 1 + if path and utf8.len(path) == nil then + libraryInvalidUtf8 = libraryInvalidUtf8 + 1 + elseif path and validateShellValue(path) and isWithinRoot(path, task.root) then + local name = path:match("([^/]+)$") + if fileType == "f" then + local kind = classifyMedia(name) + if kind and acceptsMedia(task.rootKind, kind, name) then + local numericSize = tonumber(size) or 0 + local numericMtime = tonumber(mtime) or 0 + local thumbnail = thumbnailPath(path, numericSize, numericMtime) + local entry = { path = path, thumbnail = thumbnail } + if thumbnail and noctalia.fileExists(thumbnail) then + thumbnailReady = thumbnailReady + 1 + elseif thumbnail and thumbnailsAvailable then + table.insert(thumbnailQueue, entry) + end + end + end + end + end + end + if #libraryTasks == 0 then + finishLibraryIndex(generation) + else + publishLibraryStatus("scanning") + scheduleLibraryBatch(generation) + end +end + +scheduleLibraryBatch = function(generation) + local started = noctalia.runAsync(":", function() + processLibraryBatch(generation) + end, 2000) + if not started and generation == libraryGeneration then + publishLibraryStatus("error", "Noctalia command capacity is busy") + end +end + +startThumbnailJobs = function(generation) + while generation == libraryGeneration and thumbnailActive < THUMBNAIL_LIMIT and #thumbnailQueue > 0 do + local entry = table.remove(thumbnailQueue, 1) + thumbnailActive = thumbnailActive + 1 + local temporary = entry.thumbnail .. ".tmp-" .. tostring(generation) + local command = "gst-launch-1.0 -q filesrc location=" .. shellQuote(entry.path) + .. " ! decodebin ! videoconvert ! videoscale" + .. " ! video/x-raw,width=224,height=126,pixel-aspect-ratio=1/1" + .. " ! jpegenc quality=80 snapshot=true ! filesink location=" .. shellQuote(temporary) + local started = noctalia.runAsync(command, function(result) + if generation ~= libraryGeneration then + noctalia.removeFile(temporary) + return + end + thumbnailActive = thumbnailActive - 1 + if result.exitCode == 0 and noctalia.fileExists(temporary) + and noctalia.renameFile(temporary, entry.thumbnail) then + thumbnailReady = thumbnailReady + 1 + else + noctalia.removeFile(temporary) + end + thumbnailDone = thumbnailDone + 1 + if #thumbnailQueue == 0 and thumbnailActive == 0 then + publishLibraryStatus("ready", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil) + elseif thumbnailDone % 8 == 0 then + publishLibraryStatus("thumbnails", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil) + end + startThumbnailJobs(generation) + end, 15000) + if not started then + thumbnailActive = thumbnailActive - 1 + thumbnailDone = thumbnailDone + 1 + if #thumbnailQueue == 0 and thumbnailActive == 0 then + publishLibraryStatus("ready", "could not start thumbnail generation") + end + end + end +end + +local function startLibraryIndex() + local imageRoot, videoRoot = mediaRoots() + local signature = tostring(imageRoot or "") .. "\0" .. tostring(videoRoot or "") + libraryRootSignature = signature + libraryGeneration = libraryGeneration + 1 + local generation = libraryGeneration + libraryTasks = {} + librarySources = {} + libraryErrors = {} + libraryProcessed = 0 + libraryBytes = 0 + libraryConsumedBytes = 0 + libraryInvalidUtf8 = 0 + thumbnailQueue = {} + thumbnailActive = 0 + thumbnailReady = 0 + thumbnailDone = 0 + thumbnailTotal = 0 + thumbnailsAvailable = noctalia.commandExists("gst-launch-1.0") + publishLibraryStatus("scanning") + + local roots = {} + if imageRoot and videoRoot == imageRoot then + if validMediaRoot(imageRoot) then + table.insert(roots, { root = imageRoot, rootKind = "mixed" }) + else + table.insert(libraryErrors, "Wallpaper directory must be an absolute path without control characters") + end + else + if imageRoot then + if validMediaRoot(imageRoot) then + table.insert(roots, { root = imageRoot, rootKind = "image" }) + else + table.insert(libraryErrors, "Wallpaper directory must be an absolute path without control characters") + end + end + if videoRoot then + if validMediaRoot(videoRoot) then + table.insert(roots, { root = videoRoot, rootKind = "video" }) + else + table.insert(libraryErrors, "Video directory must be an absolute path without control characters") + end + end + end + if #roots == 0 then + publishLibraryStatus("error", #libraryErrors > 0 and table.concat(libraryErrors, "; ") + or "No wallpaper directories are configured") + return + end + + local pendingRoots = #roots + for _, root in ipairs(roots) do + local quoted = shellQuote(root.root) + local command = "find -P " .. quoted .. " -mindepth 1 -printf '%y\\0%s\\0%T@\\0%p\\0'" + local started = noctalia.runAsync(command, function(result) + if generation ~= libraryGeneration then + return + end + pendingRoots = pendingRoots - 1 + if result.exitCode == 0 then + local raw = result.stdout or "" + local indexPath = noctalia.pluginDataDir() .. "/library-" .. root.rootKind .. ".index" + noctalia.writeFile(indexPath, raw) + libraryBytes = libraryBytes + #raw + table.insert(librarySources, { + root = root.root, + rootKind = root.rootKind, + path = indexPath, + }) + table.insert(libraryTasks, { + root = root.root, + rootKind = root.rootKind, + raw = raw, + offset = 1, + }) + else + table.insert(libraryErrors, result.stderr ~= "" and result.stderr or (root.root .. " is unreadable")) + end + if pendingRoots == 0 then + scheduleLibraryBatch(generation) + end + end, 30000) + if not started then + pendingRoots = pendingRoots - 1 + table.insert(libraryErrors, "could not scan " .. root.root) + end + end + if pendingRoots == 0 then + scheduleLibraryBatch(generation) + end +end + +local function runtimeDirectory() + local base = noctalia.getenv("XDG_RUNTIME_DIR") + if not validateShellValue(base) or base == "" or base:sub(1, 1) ~= "/" then + return nil, "XDG_RUNTIME_DIR is unavailable" + end + return base .. "/noctalia-gslapper" +end + +local function socketPathFor(connector, directory) + if type(connector) ~= "string" or connector == "" or type(directory) ~= "string" then + return nil + end + local safeConnector = connector:gsub("[^A-Za-z0-9_.-]", "_") + return directory .. "/" .. safeConnector .. ".sock" +end + +local function isOwnedSocket(path, directory) + if type(path) ~= "string" or type(directory) ~= "string" then + return false + end + local prefix = directory .. "/" + if path:sub(1, #prefix) ~= prefix then + return false + end + local filename = path:sub(#prefix + 1) + return filename ~= "" and not filename:find("/", 1, true) and filename:match("^[A-Za-z0-9_.-]+%.sock$") ~= nil +end + +local function processMatchNeedles(socketPath) + return { "gslapper", "--ipc-socket", socketPath } +end + +local function hiddenFlags(behavior) + if behavior == "auto-pause" then + return { "--auto-pause" } + elseif behavior == "auto-stop" then + return { "--auto-stop" } + end + return {} +end + +local function trim(value) + return (value:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function parseIpcResponse(command, raw) + raw = trim(raw or "") + if command == "query" then + local status, kind, path = raw:match("^STATUS: (%S+) (%S+) (.+)$") + if (status == "playing" or status == "paused") and (kind == "image" or kind == "video") then + return { ok = true, status = status, kind = kind, path = path, raw = raw } + end + elseif raw == "OK" or raw:sub(1, 4) == "OK: " then + return { ok = true, raw = raw } + elseif command == "change" and raw == VIDEO_CHANGE_RESTART_ERROR then + return { ok = false, restart = true, error = raw, raw = raw } + end + + return { ok = false, error = raw ~= "" and raw or "gSlapper returned no response", raw = raw } +end + +local COMMAND_ACTIONS = { + ["assign-image"] = true, + ["assign-video"] = true, + ["pause"] = true, + ["resume"] = true, + ["restore"] = true, + ["restore-all"] = true, + ["self-test"] = true, +} + +local function validateCommand(command) + if type(command) ~= "table" or type(command.nonce) ~= "number" or command.nonce % 1 ~= 0 then + return false, "command nonce must be an integer" + end + if not COMMAND_ACTIONS[command.action] then + return false, "unknown command action" + end + if type(command.target) ~= "string" or command.target == "" then + return false, "command target is required" + end + if command.action == "assign-image" or command.action == "assign-video" then + if type(command.path) ~= "string" or command.path:sub(1, 1) ~= "/" or command.path == "/" + or not validateShellValue(command.path) then + return false, "assignment path must be absolute" + end + end + return true +end + +local function summarizeResults(results) + local summary = { total = 0, successful = 0, failed = 0, pending = 0 } + for _, result in pairs(results or {}) do + summary.total = summary.total + 1 + if result.pending then + summary.pending = summary.pending + 1 + elseif result.ok then + summary.successful = summary.successful + 1 + else + summary.failed = summary.failed + 1 + end + end + return summary +end + +local function targetsFor(target, connectors) + if target ~= "*" then + return { target } + end + + local targets = copyList(connectors) + table.sort(targets) + return targets +end + +local function commandTargets(action, target, connectors, durable) + local targets = targetsFor(target, connectors) + if (action ~= "pause" and action ~= "resume") or target ~= "*" then + return targets + end + local videos = {} + for _, connector in ipairs(targets) do + if durable[connector] and durable[connector].kind == "video" then + table.insert(videos, connector) + end + end + return videos +end + +local function detectedConnectors() + local connectors = {} + for _, output in ipairs(noctalia.outputs()) do + if type(output.name) == "string" and output.name ~= "" then + table.insert(connectors, output.name) + end + end + table.sort(connectors) + return connectors +end + +local function connectorDetected(connector) + if type(connector) ~= "string" or connector == "" then + return false + end + for _, detected in ipairs(detectedConnectors()) do + if connector == detected then + return true + end + end + return false +end + +local function connectorBusy(connector) + return pending[connector] ~= nil or (runtime[connector] and runtime[connector].status == "starting") +end + +local function nextGeneration(connector, store) + store = store or generations + store[connector] = (store[connector] or 0) + 1 + return store[connector] +end + +local function isCurrent(connector, generation, store) + store = store or generations + return store[connector] == generation +end + +local function generationGuardedSet(store, generationStore, connector, generation, value) + if not isCurrent(connector, generation, generationStore) then + return false + end + store[connector] = value + return true +end + +local function connectorSet(connectors) + local result = {} + for _, connector in ipairs(connectors) do + result[connector] = true + end + return result +end + +local function ownedConnectors(durable, liveState) + local seen = {} + local connectors = {} + for connector in pairs(durable or {}) do + seen[connector] = true + table.insert(connectors, connector) + end + for connector in pairs(liveState or {}) do + if not seen[connector] then + table.insert(connectors, connector) + end + end + table.sort(connectors) + return connectors +end + +local function reconcileOutputs(previousConnectors, currentConnectors, durable, live) + local previous = connectorSet(previousConnectors) + local current = connectorSet(currentConnectors) + local stops = {} + local restores = {} + + for connector in pairs(previous) do + if not current[connector] then + live[connector] = nil + table.insert(stops, connector) + end + end + + for connector in pairs(current) do + local assignment = durable[connector] + if not previous[connector] and assignment and assignment.kind == "video" then + table.insert(restores, connector) + end + end + + table.sort(stops) + table.sort(restores) + return stops, restores +end + +local function applyAssignmentBatch(target, connectors, assignment, durable, apply) + local results = {} + for _, connector in ipairs(targetsFor(target, connectors)) do + local ok, message = apply(connector, assignment) + results[connector] = { ok = ok, error = ok and nil or message } + if ok then + durable[connector] = copyTable(assignment) + end + end + return results +end + +local function validAbsolutePath(path) + return type(path) == "string" and path:sub(1, 1) == "/" and path ~= "/" +end + +local function validateAssignment(connector, record) + if type(connector) ~= "string" or connector == "" or type(record) ~= "table" then + return nil + end + if record.kind ~= "image" and record.kind ~= "video" then + return nil + end + if not validAbsolutePath(record.path) then + return nil + end + if record.desired_playback ~= "playing" and record.desired_playback ~= "paused" then + return nil + end + if record.preview_path ~= nil and not validAbsolutePath(record.preview_path) then + return nil + end + + return { + kind = record.kind, + path = record.path, + preview_path = record.preview_path, + desired_playback = record.desired_playback, + } +end + +local function assignmentsPath() + local directory = noctalia.pluginDataDir() + if not directory then + return nil + end + return directory .. "/assignments.json" +end + +local function loadAssignments() + local path = assignmentsPath() + if not path then + return {} + end + + local contents = noctalia.readFile(path) + if not contents then + return {} + end + + local decoded, decodeError = noctalia.json.decode(contents) + if type(decoded) ~= "table" or type(decoded.assignments) ~= "table" then + noctalia.log("Ignoring invalid assignments file: " .. tostring(decodeError or "invalid shape")) + return {} + end + + local loaded = {} + for connector, record in pairs(decoded.assignments) do + local valid = validateAssignment(connector, record) + if valid then + loaded[connector] = valid + end + end + return loaded +end + +local function saveAssignments() + local path = assignmentsPath() + if not path then + return false, "plugin data directory is unavailable" + end + + -- ponytail: assignments are rewritten as one small JSON object; split storage + -- only if real configurations make write size measurable. + local encoded, encodeError = noctalia.json.encode({ version = 1, assignments = assignments }, true) + if not encoded then + return false, encodeError or "could not encode assignments" + end + + local temporaryPath = path .. ".tmp" + local written, writeError = noctalia.writeFile(temporaryPath, encoded) + if not written then + return false, writeError or "could not write assignments" + end + + local renamed, renameError = noctalia.renameFile(temporaryPath, path) + if not renamed then + noctalia.removeFile(temporaryPath) + return false, renameError or "could not replace assignments" + end + return true +end + +local function publishRuntime() + noctalia.state.set("runtime", runtime) +end + +local function publishAssignments() + noctalia.state.set("assignments", assignments) +end + +local function publishBatch(batch) + local record = { + nonce = batch.nonce, + action = batch.action, + results = batch.results, + summary = summarizeResults(batch.results), + } + noctalia.state.set("command_result", record) +end + +local function completeRequest(connector, ok, message) + local request = activeRequests[connector] + if not request then + return + end + activeRequests[connector] = nil + local batch = commandBatches[request.nonce] + if not batch then + return + end + batch.results[connector] = { ok = ok, error = ok and nil or message } + publishBatch(batch) + if summarizeResults(batch.results).pending == 0 then + commandBatches[request.nonce] = nil + end +end + +local function setPending(connector, operation) + pending[connector] = operation + noctalia.setUpdateInterval(100) +end + +local function clearPending(connector, operation) + if operation == nil or pending[connector] == operation then + pending[connector] = nil + end + if next(pending) == nil then + noctalia.setUpdateInterval(1000) + end +end + +local function validateMediaPath(path) + local valid, message = validateShellValue(path) + if not valid or path:sub(1, 1) ~= "/" or path == "/" then + return nil, message or "media path must be absolute" + end + local info, infoError = noctalia.fileInfo(path) + if not info or info.isDir then + return nil, infoError or "media path is not a file" + end + return path +end + +local function ipcCommand(socketPath, command, callback) + if not isOwnedSocket(socketPath, runtimeDirectory()) then + callback({ ok = false, error = "refusing IPC outside the plugin runtime directory" }) + return false + end + if not validateShellValue(command) then + callback({ ok = false, error = "IPC command contains a forbidden control character" }) + return false + end + + local quotedCommand = shellQuote(command) + local quotedSocket = shellQuote(socketPath) + local shellCommand = "printf '%s\\n' " .. quotedCommand .. " | socat -T 2 - UNIX-CONNECT:" .. quotedSocket + local started = noctalia.runAsync(shellCommand, function(result) + if result.exitCode ~= 0 then + callback({ + ok = false, + error = trim(result.stderr ~= "" and result.stderr or "gSlapper IPC failed"), + notConnectable = not result.timedOut, + raw = result.stdout, + }) + return + end + callback(parseIpcResponse(command:match("^(%S+)") or command, result.stdout)) + end, 2000) + if not started then + callback({ ok = false, retryable = true, error = "Noctalia command capacity is busy" }) + end + return started +end + +local function normalizedSettings() + local scale = noctalia.getConfig("scale") + if scale ~= "fill" and scale ~= "stretch" and scale ~= "original" and scale ~= "panscan" then + scale = "fill" + end + if scale == "panscan" then + scale = "panscan=1.0" + end + + local hidden = noctalia.getConfig("hidden_behavior") + if hidden ~= "none" and hidden ~= "auto-pause" and hidden ~= "auto-stop" then + hidden = "none" + end + + local fps = tostring(noctalia.getConfig("fps_cap") or "30") + if fps ~= "30" and fps ~= "60" and fps ~= "100" then + fps = "30" + end + + local duration = tonumber(noctalia.getConfig("fade_duration")) or 0.5 + duration = math.max(0.1, math.min(5.0, duration)) + + return { + scale = scale, + hidden = hidden, + loop = noctalia.getConfig("loop") ~= false, + fps = fps, + fade = noctalia.getConfig("fade") == true, + fadeDuration = duration, + gstOptions = tostring(noctalia.getConfig("gst_options") or ""), + } +end + +local function buildLaunchCommand(connector, path, socketPath) + local directory = runtimeDirectory() + if not directory or not isOwnedSocket(socketPath, directory) then + return nil, "invalid plugin socket path" + end + + local settings = normalizedSettings() + if not validateShellValue(settings.gstOptions) then + return nil, "GStreamer options contain a forbidden control character" + end + + local gst = settings.scale .. " no-audio" + if settings.loop then + gst = gst .. " loop" + end + if settings.gstOptions ~= "" then + gst = gst .. " " .. settings.gstOptions + end + + local arguments = { + "gslapper", + "--fork", + "--ipc-socket", + socketPath, + "--no-save-state", + } + for _, flag in ipairs(hiddenFlags(settings.hidden)) do + table.insert(arguments, flag) + end + table.insert(arguments, "--gst-options") + table.insert(arguments, gst) + table.insert(arguments, "--fps-cap") + table.insert(arguments, settings.fps) + table.insert(arguments, "--transition-type") + table.insert(arguments, settings.fade and "fade" or "none") + table.insert(arguments, "--transition-duration") + table.insert(arguments, tostring(settings.fadeDuration)) + table.insert(arguments, connector) + table.insert(arguments, path) + + local quoted = {} + for index, argument in ipairs(arguments) do + local value, message = shellQuote(argument) + if not value then + return nil, message + end + quoted[index] = value + end + return table.concat(quoted, " ") +end + +local function setConnectorError(connector, generation, message) + if not isCurrent(connector, generation) then + return + end + clearPending(connector) + runtime[connector] = { status = "error", error = message } + noctalia.setWallpaperEnabled(connector, true) + publishRuntime() + completeRequest(connector, false, message) +end + +local function setLiveError(connector, generation, message) + if not isCurrent(connector, generation) then + return + end + clearPending(connector) + local live = runtime[connector] or {} + runtime[connector] = { + status = "error", + error = message, + socket_path = live.socket_path, + } + publishRuntime() + completeRequest(connector, false, message) +end + +local stopOwned + +local function commitVideo(connector, generation, assignment, query) + if not isCurrent(connector, generation) then + return + end + if not query.ok or query.kind ~= "video" or query.path ~= assignment.path then + setConnectorError(connector, generation, query.error or "gSlapper reported unexpected media") + return + end + if assignment.desired_playback == "paused" and query.status ~= "paused" then + local live = runtime[connector] + ipcCommand(live.socket_path, "pause", function(paused) + if not isCurrent(connector, generation) then + return + end + if not paused.ok then + setConnectorError(connector, generation, paused.error) + return + end + ipcCommand(live.socket_path, "query", function(pausedQuery) + commitVideo(connector, generation, assignment, pausedQuery) + end) + end) + return + end + + local previous = assignments[connector] + assignments[connector] = copyTable(assignment) + local saved, saveError = saveAssignments() + if not saved then + assignments[connector] = previous + setConnectorError(connector, generation, saveError) + stopOwned(connector, generation) + return + end + + clearPending(connector) + noctalia.setWallpaperEnabled(connector, false) + runtime[connector] = { + status = query.status, + error = nil, + socket_path = runtime[connector] and runtime[connector].socket_path, + } + publishAssignments() + publishRuntime() + completeRequest(connector, true) +end + +local function awaitVideo(connector, generation, assignment, socketPath) + local operation = { + kind = "ready", + generation = generation, + assignment = assignment, + socketPath = socketPath, + deadline = noctalia.nowMs() + 5000, + inFlight = false, + } + setPending(connector, operation) +end + +local function launchVideo(connector, generation, assignment) + if not isCurrent(connector, generation) then + return + end + + local directory, directoryError = runtimeDirectory() + if not directory then + setConnectorError(connector, generation, directoryError) + return + end + local made, makeError = noctalia.mkdirAll(directory) + if not made then + setConnectorError(connector, generation, makeError or "could not create runtime directory") + return + end + + local socketPath = socketPathFor(connector, directory) + local command, commandError = buildLaunchCommand(connector, assignment.path, socketPath) + if not command then + setConnectorError(connector, generation, commandError) + return + end + + runtime[connector] = { status = "starting", socket_path = socketPath } + publishRuntime() + local started = noctalia.runAsync(command, function(result) + if not isCurrent(connector, generation) then + return + end + if result.exitCode ~= 0 then + setConnectorError(connector, generation, trim(result.stderr ~= "" and result.stderr or "gSlapper failed to start")) + return + end + awaitVideo(connector, generation, assignment, socketPath) + end, 5000) + if not started then + setConnectorError(connector, generation, "Noctalia command capacity is busy") + end +end + +local function regexEscape(value) + return (value:gsub("([^%w])", "%%%1")) +end + +local function processFallback(connector, generation, socketPath, after) + local directory = runtimeDirectory() + if not directory or not isOwnedSocket(socketPath, directory) then + setConnectorError(connector, generation, "refusing process fallback for an unowned socket") + return + end + local needles = processMatchNeedles(socketPath) + local started = noctalia.processMatches(function(matched) + if not isCurrent(connector, generation) then + return + end + if not matched then + if after then + after() + end + return + end + + local pattern = "[g]slapper.*--ipc-socket[ =]" .. regexEscape(socketPath) + local command = "pkill -TERM -f -- " .. shellQuote(pattern) + local killStarted = noctalia.runAsync(command, function() + if isCurrent(connector, generation) and after then + after() + end + end, 2000) + if not killStarted and after then + after() + end + end, needles[1], needles[2], needles[3]) + if not started and after then + after() + end +end + +stopOwned = function(connector, generation, after) + if not isCurrent(connector, generation) then + return + end + + local directory = runtimeDirectory() + local socketPath = directory and socketPathFor(connector, directory) + if not socketPath or not noctalia.fileExists(socketPath) then + if socketPath then + processFallback(connector, generation, socketPath, after) + elseif after then + after() + end + return + end + + ipcCommand(socketPath, "stop", function() + if not isCurrent(connector, generation) then + return + end + setPending(connector, { + kind = "stop", + generation = generation, + socketPath = socketPath, + deadline = noctalia.nowMs() + 2000, + after = after, + inFlight = false, + }) + end) +end + +local function changeVideo(connector, generation, assignment, socketPath) + ipcCommand(socketPath, "change " .. assignment.path, function(change) + if not isCurrent(connector, generation) then + return + end + if change.ok then + awaitVideo(connector, generation, assignment, socketPath) + elseif change.restart then + stopOwned(connector, generation, function() + launchVideo(connector, generation, assignment) + end) + else + setLiveError(connector, generation, change.error) + end + end) +end + +local function assignVideo(connector, path, previewPath, desiredPlayback) + if not connectorDetected(connector) then + return false, "output is not connected" + end + if connectorBusy(connector) then + return false, "output switch is already in progress" + end + local validPath, pathError = validateMediaPath(path) + if not validPath then + return false, pathError + end + if previewPath ~= nil and not validateMediaPath(previewPath) then + previewPath = nil + end + if not capabilities.video then + return false, capabilities.error or "video support is unavailable" + end + + local generation = nextGeneration(connector) + local assignment = { + kind = "video", + path = validPath, + preview_path = previewPath, + desired_playback = desiredPlayback == "paused" and "paused" or "playing", + } + if previewPath then + noctalia.setWallpaper(connector, previewPath) + end + + local directory = runtimeDirectory() + local socketPath = directory and socketPathFor(connector, directory) + if socketPath and noctalia.fileExists(socketPath) then + runtime[connector] = { status = "starting", socket_path = socketPath } + publishRuntime() + ipcCommand(socketPath, "query", function(query) + if not isCurrent(connector, generation) then + return + end + if query.ok and query.kind == "video" and query.path == assignment.path then + commitVideo(connector, generation, assignment, query) + elseif query.ok then + changeVideo(connector, generation, assignment, socketPath) + elseif query.notConnectable then + if isOwnedSocket(socketPath, directory) then + noctalia.removeFile(socketPath) + end + processFallback(connector, generation, socketPath, function() + launchVideo(connector, generation, assignment) + end) + else + setLiveError(connector, generation, query.error) + end + end) + else + if socketPath then + processFallback(connector, generation, socketPath, function() + launchVideo(connector, generation, assignment) + end) + else + launchVideo(connector, generation, assignment) + end + end + return true +end + +local function assignImage(connector, path) + if not connectorDetected(connector) then + return false, "output is not connected" + end + if connectorBusy(connector) then + return false, "output switch is already in progress" + end + local validPath, pathError = validateMediaPath(path) + if not validPath then + return false, pathError + end + + local generation = nextGeneration(connector) + local previous = assignments[connector] + noctalia.setWallpaper(connector, validPath) + assignments[connector] = { + kind = "image", + path = validPath, + desired_playback = "playing", + } + local saved, saveError = saveAssignments() + if not saved then + assignments[connector] = previous + return false, saveError + end + + noctalia.setWallpaperEnabled(connector, true) + runtime[connector] = { status = "static" } + publishAssignments() + publishRuntime() + stopOwned(connector, generation) + completeRequest(connector, true) + return true +end + +local function setPlayback(connector, desired) + local assignment = assignments[connector] + local live = runtime[connector] + if not assignment or assignment.kind ~= "video" or not live or not live.socket_path then + return false, "output has no active video" + end + if connectorBusy(connector) then + return false, "output switch is already in progress" + end + + local generation = nextGeneration(connector) + local previousDesired = assignment.desired_playback + live.status = "starting" + live.error = nil + publishRuntime() + ipcCommand(live.socket_path, desired == "paused" and "pause" or "resume", function(result) + if not isCurrent(connector, generation) or not result.ok then + if isCurrent(connector, generation) and not result.ok then + setLiveError(connector, generation, result.error) + end + return + end + ipcCommand(live.socket_path, "query", function(query) + if not isCurrent(connector, generation) then + return + end + if not query.ok or query.status ~= desired then + setLiveError(connector, generation, query.error or "playback state did not change") + return + end + assignment.desired_playback = desired + local saved, saveError = saveAssignments() + if not saved then + assignment.desired_playback = previousDesired + setLiveError(connector, generation, saveError) + return + end + runtime[connector].status = query.status + publishAssignments() + publishRuntime() + completeRequest(connector, true) + end) + end) + return true +end + +local function restoreConnector(connector) + if not connectorDetected(connector) then + return false, "output is not connected" + end + if connectorBusy(connector) then + return false, "output switch is already in progress" + end + + local generation = nextGeneration(connector) + local previous = assignments[connector] + assignments[connector] = nil + local saved, saveError = saveAssignments() + if not saved then + assignments[connector] = previous + return false, saveError + end + + noctalia.setWallpaperEnabled(connector, true) + runtime[connector] = { status = "static" } + publishAssignments() + publishRuntime() + stopOwned(connector, generation, function() + completeRequest(connector, true) + end) + return true +end + +local function restoreAssignment(connector) + local assignment = assignments[connector] + if not assignment then + return + end + if assignment.kind == "image" then + runtime[connector] = { status = "static" } + noctalia.setWallpaperEnabled(connector, true) + publishRuntime() + elseif capabilities.video then + assignVideo(connector, assignment.path, assignment.preview_path, assignment.desired_playback) + else + runtime[connector] = { status = "error", error = capabilities.error or "video support is unavailable" } + noctalia.setWallpaperEnabled(connector, true) + publishRuntime() + end +end + +local function restoreConnectedAssignments() + previousConnectors = detectedConnectors() + noctalia.state.set("outputs", previousConnectors) + for _, connector in ipairs(previousConnectors) do + restoreAssignment(connector) + end +end + +local function settingsSignature() + local settings = normalizedSettings() + return table.concat({ + settings.scale, + settings.hidden, + tostring(settings.loop), + settings.fps, + tostring(settings.fade), + tostring(settings.fadeDuration), + settings.gstOptions, + }, "\0") +end + +local function restartActiveVideos() + for _, connector in ipairs(detectedConnectors()) do + local assignment = assignments[connector] + local live = runtime[connector] + if assignment and assignment.kind == "video" and live and not connectorBusy(connector) then + local generation = nextGeneration(connector) + live.status = "starting" + live.error = nil + publishRuntime() + stopOwned(connector, generation, function() + launchVideo(connector, generation, copyTable(assignment)) + end) + end + end +end + +local function reconcileDetectedOutputs() + local current = detectedConnectors() + local stops, restores = reconcileOutputs(previousConnectors, current, assignments, runtime) + previousConnectors = current + noctalia.state.set("outputs", current) + + for _, connector in ipairs(stops) do + completeRequest(connector, false, "output disconnected") + local generation = nextGeneration(connector) + noctalia.setWallpaperEnabled(connector, true) + stopOwned(connector, generation) + end + for _, connector in ipairs(restores) do + restoreAssignment(connector) + end + publishRuntime() +end + +local capabilitiesReady +local function probeCapabilities() + if not noctalia.commandExists("gslapper") then + capabilities = { video = false, checking = false, error = "gSlapper is not installed" } + noctalia.state.set("capabilities", capabilities) + if capabilitiesReady then + capabilitiesReady() + end + return + end + if not noctalia.commandExists("socat") then + capabilities = { video = false, checking = false, error = "socat is not installed" } + noctalia.state.set("capabilities", capabilities) + if capabilitiesReady then + capabilitiesReady() + end + return + end + + noctalia.state.set("capabilities", capabilities) + local started = noctalia.runAsync("gslapper --help", function(result) + local missing = {} + local help = result.stdout .. result.stderr + for _, flag in ipairs(REQUIRED_FLAGS) do + if not help:find(flag, 1, true) then + table.insert(missing, flag) + end + end + if result.exitCode == 0 and #missing == 0 then + capabilities = { video = true, checking = false, tested_version = "1.5.2" } + else + capabilities = { + video = false, + checking = false, + error = "gSlapper is missing required IPC options: " .. table.concat(missing, ", "), + } + end + noctalia.state.set("capabilities", capabilities) + if capabilitiesReady then + capabilitiesReady() + end + end, 2000) + if not started then + capabilities = { video = false, checking = false, error = "could not run the gSlapper compatibility check" } + noctalia.state.set("capabilities", capabilities) + if capabilitiesReady then + capabilitiesReady() + end + end +end + +local function runModelChecks() + local connectors = { "eDP-1", "DP-1", "DP-3" } + + local durable = { + ["eDP-1"] = { kind = "image", path = "/wallpapers/still.jpg", desired_playback = "playing" }, + ["DP-1"] = { kind = "video", path = "/wallpapers/motion.mp4", desired_playback = "paused" }, + } + assert(durable["eDP-1"].path ~= durable["DP-1"].path, "outputs lost independent assignments") + + local targets = targetsFor("*", connectors) + assert(table.concat(targets, ",") == "DP-1,DP-3,eDP-1", "All target was not sorted") + + local testGenerations = {} + local testRuntime = {} + local stale = nextGeneration("DP-1", testGenerations) + local current = nextGeneration("DP-1", testGenerations) + assert(not generationGuardedSet(testRuntime, testGenerations, "DP-1", stale, "stale"), "stale generation mutated state") + assert(generationGuardedSet(testRuntime, testGenerations, "DP-1", current, "ready"), "current generation did not mutate state") + + local live = { ["DP-1"] = { status = "playing" } } + local stops = reconcileOutputs(connectors, { "eDP-1", "DP-3" }, durable, live) + assert(durable["DP-1"] ~= nil and live["DP-1"] == nil and stops[1] == "DP-1", "disconnect did not retain durable state") + + local _, restores = reconcileOutputs({ "eDP-1", "DP-3" }, connectors, durable, live) + assert(restores[1] == "DP-1", "reconnect did not schedule retained video") + + local batchDurable = {} + local results = applyAssignmentBatch("*", connectors, durable["DP-1"], batchDurable, function(connector) + if connector == "DP-1" then + return false, "fixture failure" + end + return true + end) + assert(not results["DP-1"].ok, "fixture failure was not reported") + assert(batchDurable["eDP-1"] ~= nil and batchDurable["DP-3"] ~= nil, "partial failure erased successful targets") + + assert(shellQuote("/tmp/a b's.mp4") == "'/tmp/a b'\\''s.mp4'", "apostrophe quoting failed") + assert(validateShellValue("line\nbreak") == nil and validateShellValue("nul\0byte") == nil, "control character accepted") + assert(validMediaRoot("/wallpapers") and not validMediaRoot("wallpapers") + and not validMediaRoot("/wall\npapers"), "media root validation failed") + + local testDirectory = "/run/user/1000/noctalia-gslapper" + local socketPath = socketPathFor("../DP/1", testDirectory) + assert(socketPath == testDirectory .. "/.._DP_1.sock", "connector escaped runtime directory") + assert(isOwnedSocket(socketPath, testDirectory) and not isOwnedSocket("/tmp/DP-1.sock", testDirectory), "socket ownership failed") + + local needles = processMatchNeedles(socketPath) + assert(needles[1] == "gslapper" and needles[3] == socketPath, "process match omitted exact socket") + assert(#hiddenFlags("none") == 0 and #hiddenFlags("auto-pause") == 1 and #hiddenFlags("auto-stop") == 1, "hidden flags conflict") + + local query = parseIpcResponse("query", "STATUS: paused video /tmp/a b.mp4\n") + assert(query.ok and query.status == "paused" and query.path == "/tmp/a b.mp4", "query response parsing failed") + assert(parseIpcResponse("pause", "OK\n").ok, "success response parsing failed") + assert(parseIpcResponse("change", VIDEO_CHANGE_RESTART_ERROR .. "\n").restart, "video-change recovery parsing failed") + + assert(validateCommand({ nonce = 1, action = "assign-video", target = "*", path = "/tmp/a.mp4" }), "valid command rejected") + assert(not validateCommand({ nonce = 1, action = "unknown", target = "*" }), "unknown command accepted") + assert(not validateCommand({ nonce = 1, action = "assign-video", target = "*", path = "relative.mp4" }), "relative path accepted") + + local summary = summarizeResults({ + ["DP-1"] = { ok = true }, + ["DP-3"] = { ok = false, error = "fixture" }, + ["eDP-1"] = { pending = true }, + }) + assert(summary.total == 3 and summary.successful == 1 and summary.failed == 1 and summary.pending == 1, "batch summary failed") + + local newOutputDurable = { ["DP-1"] = durable["DP-1"] } + local _, newOutputRestores = reconcileOutputs({ "DP-1" }, { "DP-1", "DP-3" }, newOutputDurable, {}) + assert(#newOutputRestores == 0, "unassigned new output received a video") + + local allThenOne = {} + applyAssignmentBatch("*", connectors, durable["DP-1"], allThenOne, function() + return true + end) + allThenOne["DP-1"] = copyTable(durable["eDP-1"]) + assert(allThenOne["DP-3"].kind == "video" and allThenOne["eDP-1"].kind == "video", "individual override changed other outputs") + + local pauseTargets = commandTargets("pause", "*", connectors, { + ["DP-1"] = { kind = "video" }, + ["DP-3"] = { kind = "image" }, + ["eDP-1"] = { kind = "video" }, + }) + assert(table.concat(pauseTargets, ",") == "DP-1,eDP-1", "All pause targeted non-video outputs") + assert(table.concat(ownedConnectors({ ["DP-1"] = {} }, { ["DP-3"] = {} }), ",") == "DP-1,DP-3", "teardown omitted durable output") +end + +local function publishSelfTest(report) + local directory = noctalia.pluginDataDir() + if directory then + local encoded = noctalia.json.encode(report, true) + if encoded then + noctalia.writeFile(directory .. "/selftest.json", encoded) + end + end + noctalia.state.set("self_test", report) +end + +local function runSelfTest(quiet) + local ok, message = pcall(runModelChecks) + local report = { + passed = ok, + checks = 22, + error = not ok and tostring(message) or nil, + } + + if not quiet then + publishSelfTest(report) + if ok then + noctalia.notify(NOTIFICATION_TITLE, "Self-test passed.") + else + noctalia.notifyError(NOTIFICATION_TITLE, report.error) + error(report.error) + end + end + return report +end + +local function handleCommand(command) + local valid, validationError = validateCommand(command) + if not valid or command.nonce <= lastHandledNonce then + return false, validationError or "command nonce was already handled" + end + lastHandledNonce = command.nonce + + if command.action == "self-test" then + local report = runSelfTest(true) + noctalia.state.set("command_result", { + nonce = command.nonce, + action = command.action, + results = { self_test = { ok = report.passed, error = report.error } }, + summary = { total = 1, successful = report.passed and 1 or 0, failed = report.passed and 0 or 1, pending = 0 }, + }) + publishSelfTest(report) + return report.passed, report.error + end + + local connectors = detectedConnectors() + local target = command.action == "restore-all" and "*" or command.target + local targets = commandTargets(command.action, target, connectors, assignments) + local batch = { nonce = command.nonce, action = command.action, results = {} } + commandBatches[command.nonce] = batch + + for _, connector in ipairs(targets) do + if activeRequests[connector] or connectorBusy(connector) then + batch.results[connector] = { ok = false, error = "output switch is already in progress" } + else + batch.results[connector] = { pending = true } + activeRequests[connector] = { nonce = command.nonce, action = command.action } + + local ok, message + if command.action == "assign-image" then + ok, message = assignImage(connector, command.path) + elseif command.action == "assign-video" then + ok, message = assignVideo(connector, command.path, command.preview_path) + elseif command.action == "pause" then + ok, message = setPlayback(connector, "paused") + elseif command.action == "resume" then + ok, message = setPlayback(connector, "playing") + else + ok, message = restoreConnector(connector) + end + + if not ok then + activeRequests[connector] = nil + batch.results[connector] = { ok = false, error = message } + end + end + end + + publishBatch(batch) + if summarizeResults(batch.results).pending == 0 then + commandBatches[command.nonce] = nil + end + return true +end + +function onIpc(event, _payload) + if event == "self-test" then + runSelfTest(false) + end +end + +function onOutputsChanged() + reconcileDetectedOutputs() +end + +function onConfigChanged() + local currentSignature = settingsSignature() + if restartSettingsSignature ~= "" and currentSignature ~= restartSettingsSignature then + restartActiveVideos() + end + restartSettingsSignature = currentSignature + local imageRoot, videoRoot = mediaRoots() + local rootsSignature = tostring(imageRoot or "") .. "\0" .. tostring(videoRoot or "") + if noctalia.pluginDataDir() and rootsSignature ~= libraryRootSignature then + startLibraryIndex() + end +end + +function onExit() + local directory = runtimeDirectory() + if not directory then + return + end + for _, connector in ipairs(ownedConnectors(assignments, runtime)) do + local live = runtime[connector] or {} + noctalia.setWallpaperEnabled(connector, true) + local socketPath = live.socket_path or socketPathFor(connector, directory) + if isOwnedSocket(socketPath, directory) and noctalia.fileExists(socketPath) then + local command = "printf '%s\\n' 'stop' | socat -T 2 - UNIX-CONNECT:" .. shellQuote(socketPath) + noctalia.runAsync(command) + end + end +end + +function update() + local now = noctalia.nowMs() + for connector, operation in pairs(pending) do + if not isCurrent(connector, operation.generation) then + clearPending(connector, operation) + elseif operation.kind == "ready" and not operation.inFlight then + operation.inFlight = true + ipcCommand(operation.socketPath, "query", function(query) + if pending[connector] ~= operation or not isCurrent(connector, operation.generation) then + return + end + operation.inFlight = false + if query.ok and query.kind == "video" and query.path == operation.assignment.path then + commitVideo(connector, operation.generation, operation.assignment, query) + elseif noctalia.nowMs() >= operation.deadline then + setConnectorError(connector, operation.generation, query.error or "gSlapper readiness timed out") + stopOwned(connector, operation.generation) + end + end) + elseif operation.kind == "stop" then + if not noctalia.fileExists(operation.socketPath) then + clearPending(connector, operation) + if operation.after then + operation.after() + end + elseif now >= operation.deadline and not operation.inFlight then + operation.inFlight = true + ipcCommand(operation.socketPath, "query", function(query) + if pending[connector] ~= operation or not isCurrent(connector, operation.generation) then + return + end + clearPending(connector, operation) + local directory = runtimeDirectory() + if query.notConnectable and directory and isOwnedSocket(operation.socketPath, directory) then + noctalia.removeFile(operation.socketPath) + end + processFallback(connector, operation.generation, operation.socketPath, operation.after) + end) + end + end + end +end + +noctalia.setUpdateInterval(1000) +assignments = loadAssignments() +publishAssignments() +capabilitiesReady = restoreConnectedAssignments +if type(noctalia.commandExists) == "function" then + restartSettingsSignature = settingsSignature() + noctalia.state.watch("command", handleCommand) + probeCapabilities() + if noctalia.pluginDataDir() then + startLibraryIndex() + end +end + +return { + assignments = assignments, + runtime = runtime, + detectedConnectors = detectedConnectors, + targetsFor = targetsFor, + commandTargets = commandTargets, + nextGeneration = nextGeneration, + isCurrent = isCurrent, + reconcileOutputs = reconcileOutputs, + ownedConnectors = ownedConnectors, + validateAssignment = validateAssignment, + saveAssignments = saveAssignments, + validateShellValue = validateShellValue, + shellQuote = shellQuote, + socketPathFor = socketPathFor, + isOwnedSocket = isOwnedSocket, + processMatchNeedles = processMatchNeedles, + hiddenFlags = hiddenFlags, + parseIpcResponse = parseIpcResponse, + validateCommand = validateCommand, + summarizeResults = summarizeResults, + buildLaunchCommand = buildLaunchCommand, + assignVideo = assignVideo, + assignImage = assignImage, + setPlayback = setPlayback, + handleCommand = handleCommand, + runSelfTest = runSelfTest, + classifyMedia = classifyMedia, + fnv1a32 = fnv1a32, + validMediaRoot = validMediaRoot, + requiredFlags = REQUIRED_FLAGS, +} diff --git a/gslapper/thumbnail.webp b/gslapper/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..3857b2b1ac2c79d83c747f9ab0415c0bdd647af9 GIT binary patch literal 41686 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 g{@-+3Y0oRga-myO?ML(NsQ>@~ literal 0 HcmV?d00001 diff --git a/gslapper/translations/en.json b/gslapper/translations/en.json new file mode 100644 index 0000000..b8434a0 --- /dev/null +++ b/gslapper/translations/en.json @@ -0,0 +1,102 @@ +{ + "panel": { + "title": "Wallpaper", + "media_count": "{count} items", + "thumbnail_unavailable": "Video preview unavailable", + "settings": "Settings", + "close": "Close", + "search": "Search wallpapers", + "all_outputs": "All outputs", + "filter_all": "All", + "filter_images": "Images", + "filter_videos": "Videos", + "folders": "Folders", + "up": "Up", + "checking_dependencies": "Checking video support…", + "scanning": "Indexing wallpaper library…", + "preparing_library": "Preparing wallpaper library…", + "generating_previews": "Generating previews · {count} / {total}", + "video_unavailable": "Video wallpapers are unavailable", + "video_badge": "VIDEO", + "partial_badge": "{count} / {total}", + "mixed_summary": "{outputs} outputs · {videos} video · {images} image", + "pause_videos": "Pause videos", + "resume_videos": "Resume videos", + "restore_all": "Restore all", + "pause": "Pause", + "resume": "Resume", + "restore": "Restore", + "status_static": "Static", + "status_starting": "Starting", + "status_playing": "Playing", + "status_paused": "Paused", + "status_error": "Error", + "applying": "Applying wallpaper…", + "navigation_blocked": "The selected directory is outside its wallpaper root.", + "directory_unreadable": "Directory unavailable", + "directory_empty": "No supported wallpapers in this directory", + "no_search_results": "No wallpapers match your search", + "page": "{page} / {pages}" + }, + "widget": { + "tooltip": "Choose wallpaper" + }, + "service": { + "self_test_passed": "Self-test passed.", + "self_test_failed": "Self-test failed." + }, + "settings": { + "video_directory": { + "label": "Video directory", + "description": "Directory containing video wallpapers." + }, + "scale": { + "label": "Video scale", + "description": "How videos fit the output.", + "options": { + "fill": "Fill", + "stretch": "Stretch", + "original": "Original", + "panscan": "Panscan" + } + }, + "hidden_behavior": { + "label": "When hidden", + "description": "Playback control when the wallpaper is hidden.", + "options": { + "none": "None", + "auto_pause": "Auto Pause", + "auto_stop": "Auto Stop" + } + }, + "loop": { + "label": "Loop videos", + "description": "Restart a video when it reaches the end." + }, + "fps_cap": { + "label": "FPS cap", + "description": "Maximum video wallpaper frame rate.", + "options": { + "fps_30": "30", + "fps_60": "60", + "fps_100": "100" + } + }, + "fade": { + "label": "Fade between videos", + "description": "Use a fade when changing media." + }, + "fade_duration": { + "label": "Fade duration", + "description": "Fade duration in seconds." + }, + "gst_options": { + "label": "Additional GStreamer options", + "description": "Extra options passed to gSlapper's GStreamer pipeline." + }, + "glyph": { + "label": "Widget glyph", + "description": "Glyph shown in the bar." + } + } +} diff --git a/gslapper/widget.luau b/gslapper/widget.luau new file mode 100644 index 0000000..7198eff --- /dev/null +++ b/gslapper/widget.luau @@ -0,0 +1,18 @@ +--!nonstrict + +local PANEL_ID = "nomadcxx/gslapper:picker" + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph") or "wallpaper-selector") + barWidget.setTooltip(noctalia.tr("widget.tooltip")) +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onConfigChanged() + render() +end + +render()