* feat: add gSlapper wallpaper plugin * docs: clarify gSlapper installation and testing * docs: state Noctalia v5 requirement * docs: use generated plugin thumbnail * fix: keep gSlapper videos playing by default
1003 lines
34 KiB
Luau
1003 lines
34 KiB
Luau
--!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,
|
|
}
|