* feat: add wl-screen-mirror plugin * fix: naming * rename thumbnail * fix(error): treat user termination as a clean stop
361 lines
8.5 KiB
Luau
361 lines
8.5 KiB
Luau
--!nonstrict
|
|
|
|
local opened = false
|
|
local requestSerial = 0
|
|
local outputs = {}
|
|
local destinationOutputs = {}
|
|
local sourceName = nil
|
|
local destinationName = nil
|
|
local sourceIndex = -1
|
|
local destinationIndex = -1
|
|
local render
|
|
|
|
local status = noctalia.state.get("mirror_status")
|
|
or {
|
|
phase = "stopped",
|
|
running = false,
|
|
available = noctalia.commandExists("wl-mirror"),
|
|
command_available = noctalia.commandExists("wl-mirror"),
|
|
message = "",
|
|
}
|
|
|
|
local function tr(key, values)
|
|
return noctalia.tr(key, values)
|
|
end
|
|
|
|
local function outputLabel(output)
|
|
local description = output.description or ""
|
|
if description == "" or description == output.name then
|
|
return output.name
|
|
end
|
|
|
|
return output.name .. " — " .. description
|
|
end
|
|
|
|
local function findOutputIndex(items, name)
|
|
for index, output in ipairs(items) do
|
|
if output.name == name then
|
|
return index - 1
|
|
end
|
|
end
|
|
|
|
return -1
|
|
end
|
|
|
|
local function hasOutput(name)
|
|
return name ~= nil and findOutputIndex(outputs, name) >= 0
|
|
end
|
|
|
|
local function rebuildDestinationOutputs()
|
|
destinationOutputs = {}
|
|
|
|
for _, output in ipairs(outputs) do
|
|
if output.name ~= sourceName then
|
|
table.insert(destinationOutputs, output)
|
|
end
|
|
end
|
|
|
|
if findOutputIndex(destinationOutputs, destinationName) < 0 then
|
|
destinationName = destinationOutputs[1] and destinationOutputs[1].name or nil
|
|
end
|
|
|
|
destinationIndex = findOutputIndex(destinationOutputs, destinationName)
|
|
end
|
|
|
|
local function refreshOutputs()
|
|
local refreshed = noctalia.outputs() or {}
|
|
outputs = {}
|
|
|
|
for _, output in ipairs(refreshed) do
|
|
if type(output.name) == "string" and output.name ~= "" then
|
|
table.insert(outputs, output)
|
|
end
|
|
end
|
|
|
|
table.sort(outputs, function(left, right)
|
|
return left.name < right.name
|
|
end)
|
|
|
|
if (status.phase == "running" or status.phase == "starting") and hasOutput(status.source) then
|
|
sourceName = status.source
|
|
end
|
|
|
|
if not hasOutput(sourceName) then
|
|
sourceName = outputs[1] and outputs[1].name or nil
|
|
end
|
|
|
|
if
|
|
(status.phase == "running" or status.phase == "starting")
|
|
and status.destination ~= sourceName
|
|
and hasOutput(status.destination)
|
|
then
|
|
destinationName = status.destination
|
|
end
|
|
|
|
sourceIndex = findOutputIndex(outputs, sourceName)
|
|
rebuildDestinationOutputs()
|
|
end
|
|
|
|
local function labels(items)
|
|
local result = {}
|
|
for _, output in ipairs(items) do
|
|
table.insert(result, outputLabel(output))
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function phaseDetails()
|
|
local phase = status.phase or "stopped"
|
|
|
|
if phase == "running" then
|
|
return tr("panel.status.running_title"), tr("panel.status.running_message"), "primary", "screen-share"
|
|
elseif phase == "starting" then
|
|
return tr("panel.status.starting_title"), tr("panel.status.starting_message"), "on_surface_variant", "loader-2"
|
|
elseif phase == "stopping" then
|
|
return tr("panel.status.stopping_title"), tr("panel.status.stopping_message"), "on_surface_variant", "loader-2"
|
|
elseif phase == "error" then
|
|
local message = status.message
|
|
if message == nil or message == "" then
|
|
message = tr("panel.status.error_message")
|
|
end
|
|
return tr("panel.status.error_title"), message, "error", "alert-circle"
|
|
end
|
|
|
|
local message = status.message
|
|
if message == nil or message == "" then
|
|
message = tr("panel.status.ready_message")
|
|
end
|
|
return tr("panel.status.ready_title"), message, "on_surface_variant", "devices"
|
|
end
|
|
|
|
local function section(title, control)
|
|
return ui.column({ gap = 7 }, {
|
|
ui.label({
|
|
text = title,
|
|
fontWeight = "medium",
|
|
color = "on_surface_variant",
|
|
}),
|
|
control,
|
|
})
|
|
end
|
|
|
|
render = function()
|
|
local phase = status.phase or "stopped"
|
|
local running = phase == "running"
|
|
local busy = phase == "starting" or phase == "stopping"
|
|
local available = status.available ~= false
|
|
local validPair = sourceName ~= nil and destinationName ~= nil and sourceName ~= destinationName
|
|
local canToggle = not busy and (running or (available and validPair))
|
|
local stateTitle, stateMessage, stateColor, stateGlyph = phaseDetails()
|
|
|
|
local actionText = tr("panel.action.start")
|
|
local actionGlyph = "player-play"
|
|
local actionVariant = "primary"
|
|
|
|
if running then
|
|
actionText = tr("panel.action.stop")
|
|
actionGlyph = "player-stop"
|
|
actionVariant = "destructive"
|
|
elseif phase == "starting" then
|
|
actionText = tr("panel.action.starting")
|
|
actionGlyph = "loader-2"
|
|
elseif phase == "stopping" then
|
|
actionText = tr("panel.action.stopping")
|
|
actionGlyph = "loader-2"
|
|
end
|
|
|
|
local hint = nil
|
|
if not available then
|
|
local message = status.message
|
|
if status.command_available == false then
|
|
message = tr("panel.wl_mirror_missing")
|
|
elseif message == nil or message == "" then
|
|
message = tr("panel.status.error_message")
|
|
end
|
|
hint = ui.label({
|
|
text = message,
|
|
color = "error",
|
|
maxLines = 2,
|
|
})
|
|
elseif #outputs < 2 then
|
|
hint = ui.label({
|
|
text = tr("panel.two_monitors_required"),
|
|
color = "error",
|
|
maxLines = 2,
|
|
})
|
|
end
|
|
|
|
local children = {
|
|
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
|
|
ui.row({ align = "center", gap = 9, flexGrow = 1 }, {
|
|
ui.glyph({ name = "screen-share", size = 18, color = "primary" }),
|
|
ui.label({
|
|
text = tr("title"),
|
|
fontSize = 16,
|
|
fontWeight = "bold",
|
|
color = "on_surface",
|
|
}),
|
|
}),
|
|
ui.button({
|
|
glyph = "close",
|
|
variant = "ghost",
|
|
tooltip = tr("panel.close"),
|
|
onClick = "onCloseClicked",
|
|
}),
|
|
}),
|
|
section(
|
|
tr("panel.source_monitor"),
|
|
ui.select({
|
|
options = labels(outputs),
|
|
selectedIndex = sourceIndex,
|
|
placeholder = tr("panel.no_monitor"),
|
|
controlSize = "md",
|
|
enabled = not busy and not running and #outputs > 0,
|
|
onChange = "onSourceChanged",
|
|
})
|
|
),
|
|
section(
|
|
tr("panel.destination_monitor"),
|
|
ui.select({
|
|
options = labels(destinationOutputs),
|
|
selectedIndex = destinationIndex,
|
|
placeholder = tr("panel.choose_another"),
|
|
controlSize = "md",
|
|
enabled = not busy and not running and #destinationOutputs > 0,
|
|
onChange = "onDestinationChanged",
|
|
})
|
|
),
|
|
ui.row({
|
|
align = "center",
|
|
gap = 9,
|
|
fill = "surface_variant/0.45",
|
|
radius = 9,
|
|
padding = 10,
|
|
}, {
|
|
ui.glyph({ name = stateGlyph, size = 17, color = stateColor }),
|
|
ui.column({ gap = 2, flexGrow = 1 }, {
|
|
ui.label({ text = stateTitle, fontWeight = "medium", color = stateColor }),
|
|
ui.label({
|
|
text = stateMessage,
|
|
color = "on_surface_variant",
|
|
maxLines = 2,
|
|
}),
|
|
}),
|
|
}),
|
|
}
|
|
|
|
if hint ~= nil then
|
|
table.insert(children, hint)
|
|
end
|
|
|
|
table.insert(
|
|
children,
|
|
ui.button({
|
|
text = actionText,
|
|
glyph = actionGlyph,
|
|
variant = actionVariant,
|
|
controlSize = "lg",
|
|
enabled = canToggle,
|
|
onClick = "onToggleMirroring",
|
|
})
|
|
)
|
|
|
|
panel.render(ui.column({ flexGrow = 1, gap = 14 }, children))
|
|
end
|
|
|
|
noctalia.state.watch("mirror_status", function(value)
|
|
if type(value) ~= "table" then
|
|
return
|
|
end
|
|
|
|
status = value
|
|
|
|
if status.phase == "running" or status.phase == "starting" then
|
|
sourceName = status.source or sourceName
|
|
destinationName = status.destination or destinationName
|
|
end
|
|
|
|
if opened then
|
|
refreshOutputs()
|
|
render()
|
|
end
|
|
end)
|
|
|
|
function onOpen(_context)
|
|
opened = true
|
|
status = noctalia.state.get("mirror_status") or status
|
|
refreshOutputs()
|
|
render()
|
|
end
|
|
|
|
function onClose()
|
|
opened = false
|
|
end
|
|
|
|
function onSourceChanged(index, _text)
|
|
local selected = outputs[(tonumber(index) or -1) + 1]
|
|
if selected == nil then
|
|
return
|
|
end
|
|
|
|
sourceName = selected.name
|
|
sourceIndex = tonumber(index) or -1
|
|
rebuildDestinationOutputs()
|
|
render()
|
|
end
|
|
|
|
function onDestinationChanged(index, _text)
|
|
local selected = destinationOutputs[(tonumber(index) or -1) + 1]
|
|
if selected == nil or selected.name == sourceName then
|
|
return
|
|
end
|
|
|
|
destinationName = selected.name
|
|
destinationIndex = tonumber(index) or -1
|
|
render()
|
|
end
|
|
|
|
function onToggleMirroring()
|
|
local phase = status.phase or "stopped"
|
|
|
|
if phase == "running" then
|
|
status.phase = "stopping"
|
|
status.message = tr("panel.status.stopping_message")
|
|
render()
|
|
|
|
requestSerial += 1
|
|
noctalia.state.set("mirror_request", {
|
|
action = "stop",
|
|
serial = requestSerial,
|
|
})
|
|
return
|
|
end
|
|
|
|
if phase == "starting" or phase == "stopping" then
|
|
return
|
|
end
|
|
|
|
if sourceName == nil or destinationName == nil or sourceName == destinationName then
|
|
noctalia.notifyError(tr("title"), tr("panel.invalid_pair"))
|
|
return
|
|
end
|
|
|
|
status.phase = "starting"
|
|
status.source = sourceName
|
|
status.destination = destinationName
|
|
status.message = tr("panel.status.starting_message")
|
|
render()
|
|
|
|
requestSerial += 1
|
|
noctalia.state.set("mirror_request", {
|
|
action = "start",
|
|
source = sourceName,
|
|
destination = destinationName,
|
|
serial = requestSerial,
|
|
})
|
|
end
|
|
|
|
function onCloseClicked()
|
|
panel.close()
|
|
end
|