* feat!: add favorite section * fix: change visual for 1 scroll: wallpaper and favorite wallpaper * feat!: update for v1.2.0 * fix: rm line downgrade quality * fix: add downgrade quality for improve storage cache * feat!: update thumbnail * feat!: add favorites list in data.json * feat!: add save favorites list across start plugin
813 lines
23 KiB
Luau
813 lines
23 KiB
Luau
--!nonstrict
|
|
-- W-Engine service — owns every linux-wallpaperengine process, the palette sync
|
|
-- and the cycle timer.
|
|
--
|
|
-- The panel publishes requests on noctalia.state and this entry carries them
|
|
-- out, so a cycle continues while the panel is closed and a single owner holds
|
|
-- the pid files.
|
|
|
|
local TICK_MS = 1000
|
|
local REQUEST_KEY = "w_engine_request"
|
|
local STATUS_KEY = "w_engine_status"
|
|
|
|
local WORKSHOP_PATHS = {
|
|
"~/.steam/steam/steamapps/workshop/content/431960/",
|
|
"~/.local/share/Steam/steamapps/workshop/content/431960/",
|
|
"~/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/workshop/content/431960/",
|
|
"~/snap/steam/common/.local/share/Steam/steamapps/workshop/content/431960/",
|
|
"~/snap/steam/common/.local/share/steam/steamapps/workshop/content/431960/",
|
|
}
|
|
|
|
-- Persisted across reloads (pluginDataDir()/data.json).
|
|
local data = {
|
|
personnalPath = {},
|
|
selection = {}, -- output -> { id, ... } in the order the user picked them
|
|
cycle = {}, -- output -> { enabled, minutes, order }
|
|
current = {}, -- output -> id currently playing
|
|
saved_wallpaper = {}, -- output -> the Noctalia wallpaper from before we took over
|
|
options = {}, -- id -> { engine = {...}, properties = {...} }, see buildLaunchArgs
|
|
defaults = {}, -- { engine = {...} } applied to every wallpaper, see optionsFor
|
|
favorites = {}, -- favorites wallpapers list
|
|
}
|
|
|
|
-- linux-wallpaperengine switches that take no value. Stored as booleans; the
|
|
-- flag is emitted only when true.
|
|
local ENGINE_FLAGS = {
|
|
{ key = "silent", flag = "--silent" },
|
|
{ key = "noautomute", flag = "--noautomute" },
|
|
{ key = "no_audio_processing", flag = "--no-audio-processing" },
|
|
{ key = "disable_particles", flag = "--disable-particles" },
|
|
{ key = "disable_mouse", flag = "--disable-mouse" },
|
|
{ key = "disable_parallax", flag = "--disable-parallax" },
|
|
{ key = "no_fullscreen_pause", flag = "--no-fullscreen-pause" },
|
|
{ key = "fullscreen_pause_only_active", flag = "--fullscreen-pause-only-active" },
|
|
}
|
|
|
|
-- Switches taking a value. scaling and clamp bind to the preceding --bg, so they
|
|
-- are emitted right after it; the rest are process-wide.
|
|
local ENGINE_SCREEN_VALUES = {
|
|
{ key = "scaling", flag = "--scaling" },
|
|
{ key = "clamp", flag = "--clamp" },
|
|
}
|
|
local ENGINE_GLOBAL_VALUES = {
|
|
{ key = "layer", flag = "--layer" },
|
|
{ key = "fps", flag = "--fps" },
|
|
{ key = "volume", flag = "--volume" },
|
|
}
|
|
|
|
local elapsed = {} -- output -> seconds since that output last switched
|
|
local shuffled = {} -- output -> ids left to play in this random pass
|
|
local lastNonce = nil
|
|
local workshopRoot = nil
|
|
local rngState = 0
|
|
|
|
local function tr(key, subst)
|
|
if subst then
|
|
return noctalia.tr(key, subst)
|
|
end
|
|
return noctalia.tr(key)
|
|
end
|
|
|
|
-- ── Persistence ──────────────────────────────────────────────────────────────
|
|
|
|
local function dataPath()
|
|
local dir = noctalia.pluginDataDir()
|
|
if not dir then
|
|
return nil
|
|
end
|
|
return dir .. "/data.json"
|
|
end
|
|
|
|
local function asTable(value)
|
|
return type(value) == "table" and value or {}
|
|
end
|
|
|
|
local function loadData()
|
|
local path = dataPath()
|
|
if not path then
|
|
return
|
|
end
|
|
local raw = noctalia.readFile(path)
|
|
if not raw then
|
|
return
|
|
end
|
|
local decoded = noctalia.json.decode(raw)
|
|
if type(decoded) ~= "table" then
|
|
return
|
|
end
|
|
-- personnalPath is user-authored (see README); a file holding only that key
|
|
-- is valid.
|
|
data.personnalPath = asTable(decoded.personnalPath)
|
|
data.selection = asTable(decoded.selection)
|
|
data.cycle = asTable(decoded.cycle)
|
|
data.current = asTable(decoded.current)
|
|
data.saved_wallpaper = asTable(decoded.saved_wallpaper)
|
|
data.options = asTable(decoded.options)
|
|
data.defaults = asTable(decoded.defaults)
|
|
data.favorites = asTable(decoded.favorites)
|
|
end
|
|
|
|
local function saveData()
|
|
local path = dataPath()
|
|
if not path then
|
|
return
|
|
end
|
|
local encoded = noctalia.json.encode(data, true)
|
|
if encoded then
|
|
noctalia.writeFile(path, encoded)
|
|
end
|
|
end
|
|
|
|
-- ── Workshop discovery ───────────────────────────────────────────────────────
|
|
|
|
local function candidateRoots()
|
|
local roots = {}
|
|
for _, path in ipairs(WORKSHOP_PATHS) do
|
|
table.insert(roots, path)
|
|
end
|
|
for _, path in ipairs(data.personnalPath) do
|
|
if type(path) == "string" and path ~= "" then
|
|
if not path:match("/$") then
|
|
path = path .. "/"
|
|
end
|
|
table.insert(roots, path)
|
|
end
|
|
end
|
|
return roots
|
|
end
|
|
|
|
-- Several stock locations can exist at once, and last match wins, so a
|
|
-- personnalPath entry takes precedence.
|
|
local function resolveWorkshopRoot()
|
|
local root = nil
|
|
for _, path in ipairs(candidateRoots()) do
|
|
if noctalia.listDir(path) then
|
|
root = path
|
|
end
|
|
end
|
|
return root
|
|
end
|
|
|
|
local function itemDir(id)
|
|
workshopRoot = workshopRoot or resolveWorkshopRoot()
|
|
if not workshopRoot then
|
|
return nil
|
|
end
|
|
return workshopRoot .. id .. "/"
|
|
end
|
|
|
|
local function project(id)
|
|
local dir = itemDir(id)
|
|
if not dir then
|
|
return nil, nil
|
|
end
|
|
local raw = noctalia.readFile(dir .. "project.json")
|
|
if not raw then
|
|
return nil, dir
|
|
end
|
|
local decoded = noctalia.json.decode(raw)
|
|
if type(decoded) ~= "table" then
|
|
return nil, dir
|
|
end
|
|
return decoded, dir
|
|
end
|
|
|
|
-- ── Palette sync ─────────────────────────────────────────────────────────────
|
|
--
|
|
-- Noctalia derives its palette from the wallpaper image, so setting a still that
|
|
-- represents the live wallpaper runs the configured generator, scheme, mode and
|
|
-- templates as for any other wallpaper.
|
|
--
|
|
-- The still is the Workshop preview, which Noctalia decodes directly (.jpg, .png
|
|
-- and .gif). Video wallpapers use a frame decoded with ffmpeg instead, as their
|
|
-- preview is often a stylised thumbnail rather than a frame of the video.
|
|
|
|
local function syncEnabled()
|
|
return noctalia.getConfig("sync_colors") ~= false
|
|
end
|
|
|
|
local function shellQuote(path)
|
|
return "'" .. noctalia.expandPath(path):gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function framePath(id)
|
|
local dir = noctalia.pluginDataDir()
|
|
if not dir then
|
|
return nil
|
|
end
|
|
return dir .. "/frames/" .. id .. ".jpg"
|
|
end
|
|
|
|
-- Calls back with a path to an image representing item `id`, or nil.
|
|
local function resolveColorSource(id, callback)
|
|
local info, dir = project(id)
|
|
if not dir then
|
|
callback(nil)
|
|
return
|
|
end
|
|
|
|
local preview = dir .. ((info and info.preview) or "preview.jpg")
|
|
if not noctalia.fileExists(preview) then
|
|
preview = nil
|
|
end
|
|
|
|
local kind = string.lower(tostring((info and info.type) or ""))
|
|
local file = info and info.file
|
|
local frame = framePath(id)
|
|
if kind ~= "video" or type(file) ~= "string" or file == "" or not frame then
|
|
callback(preview)
|
|
return
|
|
end
|
|
if noctalia.fileExists(frame) then
|
|
callback(frame)
|
|
return
|
|
end
|
|
if not noctalia.commandExists("ffmpeg") then
|
|
callback(preview)
|
|
return
|
|
end
|
|
|
|
local dataDir = noctalia.pluginDataDir()
|
|
if dataDir then
|
|
noctalia.mkdirAll(dataDir .. "/frames")
|
|
end
|
|
-- One frame, scaled down: the generator only needs the colors. Workshop file
|
|
-- names contain spaces and non-ASCII, so paths are quoted.
|
|
local cmd = "ffmpeg -y -loglevel error -ss 1 -i "
|
|
.. shellQuote(dir .. file)
|
|
.. " -frames:v 1 -vf scale=960:-2"
|
|
.. shellQuote(frame)
|
|
noctalia.runAsync(cmd, function(result)
|
|
-- A wallpaper shorter than the seek offset produces no output and no error,
|
|
-- so the file is the success signal rather than the exit code.
|
|
if noctalia.fileExists(frame) then
|
|
callback(frame)
|
|
else
|
|
callback(preview)
|
|
end
|
|
end, 20000)
|
|
end
|
|
|
|
-- Captures the Noctalia wallpaper for an output once, so stopping can restore
|
|
-- it. Later applies must not overwrite the stash with a generated still.
|
|
local function setShellWallpaper(output, path)
|
|
if data.saved_wallpaper[output] ~= nil then
|
|
noctalia.setWallpaper(output, path)
|
|
return
|
|
end
|
|
noctalia.runAsync("noctalia msg wallpaper-get " .. output, function(result)
|
|
local previous = noctalia.string.trim(result.stdout or "")
|
|
if result.exitCode == 0 and previous ~= "" then
|
|
data.saved_wallpaper[output] = previous
|
|
saveData()
|
|
end
|
|
noctalia.setWallpaper(output, path)
|
|
end, 3000)
|
|
end
|
|
|
|
local function syncPalette(output, id)
|
|
if not syncEnabled() then
|
|
return
|
|
end
|
|
resolveColorSource(id, function(path)
|
|
if path then
|
|
setShellWallpaper(output, path)
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function restoreShellWallpaper(output)
|
|
local previous = data.saved_wallpaper[output]
|
|
data.saved_wallpaper[output] = nil
|
|
saveData()
|
|
if type(previous) == "string" and previous ~= "" and noctalia.fileExists(previous) then
|
|
noctalia.setWallpaper(output, previous)
|
|
end
|
|
end
|
|
|
|
-- ── linux-wallpaperengine processes ──────────────────────────────────────────
|
|
|
|
local function pidFile(output)
|
|
return "/tmp/w-engine-" .. output .. ".pid"
|
|
end
|
|
|
|
-- The recorded pid for this output, when that process is still one of ours. The
|
|
-- pid file is the source of truth across script reloads.
|
|
local function livePid(output)
|
|
local raw = noctalia.readFile(pidFile(output))
|
|
if not raw then
|
|
return nil
|
|
end
|
|
local pid = noctalia.string.trim(raw)
|
|
if pid == "" or not tonumber(pid) then
|
|
return nil
|
|
end
|
|
local cmdline = noctalia.readFile("/proc/" .. pid .. "/cmdline")
|
|
if not cmdline then
|
|
return nil
|
|
end
|
|
-- Some packages wrap the binary and exec ./linux-wallpaperengine from its own
|
|
-- directory, so argv[0] is not always the bare name.
|
|
if cmdline:find("linux-wallpaperengine", 1, true) then
|
|
return pid
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Options are resolved in three layers. The global defaults apply to every
|
|
-- wallpaper, a wallpaper's own engine settings override them key by key, and any
|
|
-- key still absent produces no flag, leaving linux-wallpaperengine's default.
|
|
--
|
|
-- defaults = { engine = { silent = true, fps = 24, ... } }
|
|
-- options[id] = { engine = { fps = 60 },
|
|
-- properties = { embers = false, barcolor = "0.5 0.2 0.1" } }
|
|
--
|
|
-- Properties are not layered: they are declared by one wallpaper and mean
|
|
-- nothing to any other.
|
|
local function optionsFor(id)
|
|
local engine = {}
|
|
for key, value in pairs(asTable(data.defaults.engine)) do
|
|
engine[key] = value
|
|
end
|
|
|
|
local entry = data.options[id]
|
|
if type(entry) ~= "table" then
|
|
return engine, {}
|
|
end
|
|
for key, value in pairs(asTable(entry.engine)) do
|
|
engine[key] = value
|
|
end
|
|
return engine, asTable(entry.properties)
|
|
end
|
|
|
|
-- Wallpaper properties are typed in project.json but arrive here as plain Luau
|
|
-- values; the CLI wants booleans as true/false, colors as the "r g b" string the
|
|
-- panel already stores, and everything else as a bare number.
|
|
local function propertyValue(value)
|
|
if type(value) == "boolean" then
|
|
return value and "true" or "false"
|
|
end
|
|
return tostring(value)
|
|
end
|
|
|
|
local function buildLaunchArgs(output, id)
|
|
local engine, properties = optionsFor(id)
|
|
local args = { "--screen-root", output, "--bg", id }
|
|
|
|
for _, spec in ipairs(ENGINE_SCREEN_VALUES) do
|
|
local value = engine[spec.key]
|
|
if value ~= nil and value ~= "" and value ~= "default" then
|
|
table.insert(args, spec.flag)
|
|
table.insert(args, tostring(value))
|
|
end
|
|
end
|
|
for _, spec in ipairs(ENGINE_GLOBAL_VALUES) do
|
|
local value = engine[spec.key]
|
|
if value ~= nil and value ~= "" then
|
|
table.insert(args, spec.flag)
|
|
table.insert(args, tostring(value))
|
|
end
|
|
end
|
|
for _, spec in ipairs(ENGINE_FLAGS) do
|
|
if engine[spec.key] == true then
|
|
table.insert(args, spec.flag)
|
|
end
|
|
end
|
|
|
|
-- Sorted so a relaunch with unchanged settings produces an identical command.
|
|
local names = {}
|
|
for name in pairs(properties) do
|
|
table.insert(names, name)
|
|
end
|
|
table.sort(names)
|
|
for _, name in ipairs(names) do
|
|
table.insert(args, "--set-property")
|
|
table.insert(args, name .. "=" .. propertyValue(properties[name]))
|
|
end
|
|
|
|
local quoted = {}
|
|
for _, arg in ipairs(args) do
|
|
table.insert(quoted, "'" .. tostring(arg):gsub("'", "'\\''") .. "'")
|
|
end
|
|
return table.concat(quoted, " ")
|
|
end
|
|
|
|
local function launch(output, id)
|
|
local cmd = "setsid linux-wallpaperengine "
|
|
.. buildLaunchArgs(output, id)
|
|
.. " > /dev/null 2>&1 & echo $! > "
|
|
.. pidFile(output)
|
|
noctalia.runAsync(cmd, nil, 5000)
|
|
end
|
|
|
|
-- linux-wallpaperengine ignores SIGTERM and SIGINT, so stopping escalates to
|
|
-- SIGKILL after a grace period.
|
|
local function killCommand(pid)
|
|
return "kill "
|
|
.. pid
|
|
.. " 2>/dev/null; n=0; while kill -0 "
|
|
.. pid
|
|
.. " 2>/dev/null && [ $n -lt 10 ]; do sleep 0.2; n=$((n+1)); done; kill -9 "
|
|
.. pid
|
|
.. " 2>/dev/null; true"
|
|
end
|
|
|
|
local function stopProcess(output, onStopped)
|
|
local pid = livePid(output)
|
|
if not pid then
|
|
noctalia.removeFile(pidFile(output))
|
|
if onStopped then
|
|
onStopped()
|
|
end
|
|
return
|
|
end
|
|
noctalia.runAsync(killCommand(pid), function()
|
|
noctalia.removeFile(pidFile(output))
|
|
if onStopped then
|
|
onStopped()
|
|
end
|
|
end, 6000)
|
|
end
|
|
|
|
-- ── Status published to the panel ────────────────────────────────────────────
|
|
|
|
local function cycleFor(output)
|
|
local cycle = data.cycle[output]
|
|
if type(cycle) ~= "table" then
|
|
return { enabled = false, minutes = 15, order = "sequential" }
|
|
end
|
|
return {
|
|
enabled = cycle.enabled == true,
|
|
minutes = tonumber(cycle.minutes) or 15,
|
|
order = cycle.order == "random" and "random" or "sequential",
|
|
}
|
|
end
|
|
|
|
local function selectionFor(output)
|
|
local ids = data.selection[output]
|
|
return type(ids) == "table" and ids or {}
|
|
end
|
|
|
|
local function publishStatus()
|
|
local outputs = {}
|
|
for _, output in ipairs(noctalia.outputs()) do
|
|
local name = output.name
|
|
local cycle = cycleFor(name)
|
|
local remaining = nil
|
|
if cycle.enabled then
|
|
remaining = math.max(0, cycle.minutes * 60 - (elapsed[name] or 0))
|
|
end
|
|
outputs[name] = {
|
|
current = data.current[name],
|
|
selection = selectionFor(name),
|
|
cycle_enabled = cycle.enabled,
|
|
cycle_minutes = cycle.minutes,
|
|
cycle_order = cycle.order,
|
|
remaining_seconds = remaining,
|
|
restorable = data.saved_wallpaper[name] ~= nil,
|
|
}
|
|
end
|
|
noctalia.state.set(STATUS_KEY, {
|
|
outputs = outputs,
|
|
options = data.options,
|
|
defaults = data.defaults,
|
|
sync_colors = syncEnabled(),
|
|
})
|
|
end
|
|
|
|
-- ── Applying a wallpaper ─────────────────────────────────────────────────────
|
|
|
|
local function apply(output, id)
|
|
if type(output) ~= "string" or output == "" or type(id) ~= "string" or id == "" then
|
|
return
|
|
end
|
|
data.current[output] = id
|
|
elapsed[output] = 0
|
|
saveData()
|
|
|
|
stopProcess(output, function()
|
|
launch(output, id)
|
|
end)
|
|
syncPalette(output, id)
|
|
publishStatus()
|
|
end
|
|
|
|
-- ── Cycling ──────────────────────────────────────────────────────────────────
|
|
|
|
local function seedRng()
|
|
local seed = 0
|
|
if os and os.time then
|
|
seed = os.time()
|
|
end
|
|
rngState = seed % 2147483647
|
|
if rngState <= 0 then
|
|
rngState += 2147483646
|
|
end
|
|
end
|
|
|
|
local function randomIndex(n)
|
|
if n <= 1 then
|
|
return 1
|
|
end
|
|
if math.random then
|
|
return math.random(n)
|
|
end
|
|
rngState = (rngState * 16807) % 2147483647
|
|
return (rngState % n) + 1
|
|
end
|
|
|
|
-- Random plays a shuffled pass over the whole selection before reshuffling, so
|
|
-- no wallpaper repeats within a pass.
|
|
local function nextRandom(output, ids)
|
|
local remaining = shuffled[output]
|
|
if type(remaining) ~= "table" or #remaining == 0 then
|
|
remaining = {}
|
|
for _, id in ipairs(ids) do
|
|
table.insert(remaining, id)
|
|
end
|
|
for i = #remaining, 2, -1 do -- Fisher-Yates
|
|
local j = randomIndex(i)
|
|
remaining[i], remaining[j] = remaining[j], remaining[i]
|
|
end
|
|
-- A fresh pass must not open on the wallpaper that just finished.
|
|
if #remaining > 1 and remaining[1] == data.current[output] then
|
|
remaining[1], remaining[#remaining] = remaining[#remaining], remaining[1]
|
|
end
|
|
shuffled[output] = remaining
|
|
end
|
|
return table.remove(remaining, 1)
|
|
end
|
|
|
|
local function nextSequential(output, ids)
|
|
local current = data.current[output]
|
|
for index, id in ipairs(ids) do
|
|
if id == current then
|
|
return ids[(index % #ids) + 1]
|
|
end
|
|
end
|
|
return ids[1]
|
|
end
|
|
|
|
local function advance(output)
|
|
local ids = selectionFor(output)
|
|
if #ids == 0 then
|
|
return
|
|
end
|
|
local cycle = cycleFor(output)
|
|
local id
|
|
if cycle.order == "random" then
|
|
id = nextRandom(output, ids)
|
|
else
|
|
id = nextSequential(output, ids)
|
|
end
|
|
if id then
|
|
apply(output, id)
|
|
end
|
|
end
|
|
|
|
-- ── Requests from the panel ──────────────────────────────────────────────────
|
|
|
|
local function setCycle(output, enabled, minutes, order)
|
|
local cycle = cycleFor(output)
|
|
if enabled ~= nil then
|
|
cycle.enabled = enabled == true
|
|
end
|
|
if tonumber(minutes) then
|
|
cycle.minutes = math.max(1, math.floor(tonumber(minutes)))
|
|
end
|
|
if order == "random" or order == "sequential" then
|
|
cycle.order = order
|
|
end
|
|
data.cycle[output] = cycle
|
|
elapsed[output] = 0
|
|
shuffled[output] = nil
|
|
saveData()
|
|
end
|
|
|
|
local function handleRequest(request)
|
|
if type(request) ~= "table" then
|
|
return
|
|
end
|
|
if request.nonce ~= nil and request.nonce == lastNonce then
|
|
return
|
|
end
|
|
lastNonce = request.nonce
|
|
|
|
local output = request.output
|
|
if type(output) ~= "string" or output == "" then
|
|
output = noctalia.focusedOutputName()
|
|
end
|
|
if type(output) ~= "string" or output == "" then
|
|
return
|
|
end
|
|
|
|
local action = request.action
|
|
if action == "apply" then
|
|
-- A one-shot pick takes over from any cycle on that output.
|
|
setCycle(output, false)
|
|
apply(output, request.id)
|
|
elseif action == "select" then
|
|
local ids = {}
|
|
if type(request.ids) == "table" then
|
|
for _, id in ipairs(request.ids) do
|
|
if type(id) == "string" and id ~= "" then
|
|
table.insert(ids, id)
|
|
end
|
|
end
|
|
end
|
|
data.selection[output] = ids
|
|
shuffled[output] = nil
|
|
saveData()
|
|
publishStatus()
|
|
elseif action == "cycle" then
|
|
setCycle(output, request.enabled, request.minutes, request.order)
|
|
local cycle = cycleFor(output)
|
|
if cycle.enabled then
|
|
local ids = selectionFor(output)
|
|
if #ids == 0 then
|
|
setCycle(output, false)
|
|
noctalia.notify(tr("panel.title"), tr("panel.cycle_needs_selection"))
|
|
else
|
|
-- Start on the first pick straight away rather than leaving the
|
|
-- previous wallpaper up for a whole interval.
|
|
local first = cycle.order == "random" and nextRandom(output, ids) or ids[1]
|
|
apply(output, first)
|
|
noctalia.notify(
|
|
tr("panel.title"),
|
|
tr("panel.cycle_started", { count = #ids, minutes = cycle.minutes })
|
|
)
|
|
end
|
|
end
|
|
publishStatus()
|
|
elseif action == "stop" then
|
|
setCycle(output, false)
|
|
data.current[output] = nil
|
|
saveData()
|
|
stopProcess(output, nil)
|
|
restoreShellWallpaper(output)
|
|
publishStatus()
|
|
elseif action == "options" then
|
|
-- Whole-table replace, not a merge: the panel owns the form and sends the
|
|
-- complete state, so clearing a setting there has to clear it here.
|
|
local id = request.id
|
|
if type(id) == "string" and id ~= "" then
|
|
data.options[id] = {
|
|
engine = asTable(request.engine),
|
|
properties = asTable(request.properties),
|
|
}
|
|
saveData()
|
|
-- Options are command-line arguments, so they only take effect on a
|
|
-- fresh process. Restart any output currently showing this wallpaper.
|
|
if request.restart ~= false then
|
|
for output, current in pairs(data.current) do
|
|
if current == id then
|
|
local target = output
|
|
stopProcess(target, function()
|
|
launch(target, id)
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
publishStatus()
|
|
end
|
|
elseif action == "defaults" then
|
|
local previous = asTable(data.defaults.engine)
|
|
local engine = asTable(request.engine)
|
|
data.defaults = { engine = engine }
|
|
|
|
-- Optionally drop per-wallpaper overrides, but only for the keys whose
|
|
-- default actually changed. Settings the user tuned for one wallpaper and
|
|
-- did not touch here keep their override.
|
|
if request.clear_overrides == true then
|
|
for key, value in pairs(engine) do
|
|
if previous[key] ~= value then
|
|
for _, entry in pairs(data.options) do
|
|
if type(entry) == "table" and type(entry.engine) == "table" then
|
|
entry.engine[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for key in pairs(previous) do
|
|
if engine[key] == nil then
|
|
for _, entry in pairs(data.options) do
|
|
if type(entry) == "table" and type(entry.engine) == "table" then
|
|
entry.engine[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
saveData()
|
|
-- Every running wallpaper resolves these, so all of them restart.
|
|
if request.restart ~= false then
|
|
for output, id in pairs(data.current) do
|
|
if type(id) == "string" and id ~= "" then
|
|
local target, wallpaper = output, id
|
|
stopProcess(target, function()
|
|
launch(target, wallpaper)
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
publishStatus()
|
|
elseif action == "status" then
|
|
publishStatus()
|
|
end
|
|
end
|
|
|
|
-- ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
|
|
function update()
|
|
local switched = false
|
|
for _, output in ipairs(noctalia.outputs()) do
|
|
local name = output.name
|
|
local cycle = cycleFor(name)
|
|
if cycle.enabled and #selectionFor(name) > 1 then
|
|
elapsed[name] = (elapsed[name] or 0) + TICK_MS / 1000
|
|
if elapsed[name] >= cycle.minutes * 60 then
|
|
advance(name)
|
|
switched = true
|
|
end
|
|
end
|
|
end
|
|
if switched then
|
|
publishStatus()
|
|
end
|
|
end
|
|
|
|
function onIpc(event, payload)
|
|
-- Same verbs as the panel, for keybinds:
|
|
-- noctalia msg plugin tadomika_ari/w-engine:start all cycle-stop
|
|
if event == "cycle-stop" then
|
|
handleRequest({ action = "cycle", enabled = false, output = payload })
|
|
elseif event == "stop" then
|
|
handleRequest({ action = "stop", output = payload })
|
|
elseif event == "next" then
|
|
local output = payload
|
|
if type(output) ~= "string" or output == "" then
|
|
output = noctalia.focusedOutputName()
|
|
end
|
|
if output then
|
|
advance(output)
|
|
publishStatus()
|
|
end
|
|
end
|
|
end
|
|
|
|
function onConfigChanged()
|
|
-- Enabling the sync mid-session applies the palette for whatever is playing.
|
|
if syncEnabled() then
|
|
for output, id in pairs(data.current) do
|
|
if type(id) == "string" and id ~= "" then
|
|
syncPalette(output, id)
|
|
end
|
|
end
|
|
end
|
|
publishStatus()
|
|
end
|
|
|
|
noctalia.state.watch(REQUEST_KEY, handleRequest)
|
|
|
|
-- Sweep orphaned processes and the stale pid files left by a previous load.
|
|
--
|
|
-- Matches on the process name, which the kernel truncates to 15 characters. The
|
|
-- -f form would also match this command's own shell.
|
|
local entries = noctalia.listDir("/tmp")
|
|
if entries then
|
|
for _, entry in ipairs(entries) do
|
|
if entry:match("^w%-engine") then
|
|
noctalia.removeFile("/tmp/" .. entry)
|
|
end
|
|
end
|
|
end
|
|
|
|
seedRng()
|
|
loadData()
|
|
workshopRoot = resolveWorkshopRoot()
|
|
noctalia.setUpdateInterval(TICK_MS)
|
|
publishStatus()
|
|
|
|
-- The sweep completes before anything relaunches; its second pass runs a second
|
|
-- after the first.
|
|
noctalia.runAsync(
|
|
"pkill linux-wallpaper 2>/dev/null; sleep 1; pkill -KILL linux-wallpaper 2>/dev/null; true",
|
|
function()
|
|
-- Bring back whatever was playing before the reload, cycle included. The
|
|
-- palette is re-synced alongside the process: the shell restores its own
|
|
-- wallpaper on login, which drops the still we set for the live scene and
|
|
-- leaves the colors from whatever it picked instead.
|
|
for _, output in ipairs(noctalia.outputs()) do
|
|
local name = output.name
|
|
local id = data.current[name]
|
|
if type(id) == "string" and id ~= "" then
|
|
elapsed[name] = 0
|
|
launch(name, id)
|
|
syncPalette(name, id)
|
|
end
|
|
end
|
|
publishStatus()
|
|
end,
|
|
8000
|
|
)
|