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 0000000..3857b2b Binary files /dev/null and b/gslapper/thumbnail.webp differ 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()