feat: add Wayland Screen Mirror plugin (#86)
* feat: add wl-screen-mirror plugin * fix: naming * rename thumbnail * fix(error): treat user termination as a clean stop
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Wayland Screen Mirror
|
||||
|
||||
This plugin allows you to easily toggle screen mirroring in Wayland via `wl-mirror`.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `elijaharch/wl-screen-mirror` |
|
||||
| Entries | Bar widget: `mirror`; panel: `controls`; service: `mirror-service` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- [`wl-mirror`](https://github.com/Ferdi265/wl-mirror) available on `PATH`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add the `mirror` widget to your bar
|
||||
2. Open the panel and select the source and destination displays
|
||||
3. Click on **Start mirroring**
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle elijaharch/wl-screen-mirror:controls
|
||||
```
|
||||
|
||||
The service stops mirroring automatically if either selected output disconnects.
|
||||
|
||||
## Notes
|
||||
|
||||
- The plugin launches
|
||||
`wl-mirror --fullscreen-output DESTINATION --fullscreen SOURCE`.
|
||||
- A private marker and the latest `wl-mirror` error output are written under the
|
||||
plugin data directory. No user content is stored.
|
||||
- The managed process is terminated when mirroring stops, the plugin reloads,
|
||||
or Noctalia exits. Other `wl-mirror` processes are not affected.
|
||||
- The plugin makes no network requests.
|
||||
@@ -0,0 +1,360 @@
|
||||
--!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
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "elijaharch/wl-screen-mirror"
|
||||
name = "Wayland Screen Mirror"
|
||||
version = "1.0.0"
|
||||
plugin_api = 3
|
||||
author = "elijaharch"
|
||||
license = "MIT"
|
||||
dependencies = ["wl-mirror"]
|
||||
tags = ["bar", "panel", "service", "utility", "video"]
|
||||
icon = "screen-share"
|
||||
description = "Mirror one Wayland output to another with wl-mirror."
|
||||
|
||||
[[widget]]
|
||||
id = "mirror"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[panel]]
|
||||
id = "controls"
|
||||
entry = "panel.luau"
|
||||
width = 420
|
||||
height = 390
|
||||
placement = "attached"
|
||||
position = "auto"
|
||||
open_near_click = true
|
||||
|
||||
[[service]]
|
||||
id = "mirror-service"
|
||||
entry = "service.luau"
|
||||
@@ -0,0 +1,309 @@
|
||||
--!nonstrict
|
||||
|
||||
local MANAGED_TITLE = "Noctalia Screen Mirror"
|
||||
|
||||
local dataDir = noctalia.pluginDataDir()
|
||||
local runFile = dataDir and (dataDir .. "/mirror.run") or nil
|
||||
local errorFile = dataDir and (dataDir .. "/mirror.stderr") or nil
|
||||
local commandAvailable = noctalia.commandExists("wl-mirror")
|
||||
local streamActive = false
|
||||
local stopRequested = false
|
||||
local stopMessage = nil
|
||||
local notifyAfterStop = false
|
||||
|
||||
local status = {
|
||||
phase = "stopped",
|
||||
running = false,
|
||||
source = nil,
|
||||
destination = nil,
|
||||
available = commandAvailable and runFile ~= nil and errorFile ~= nil,
|
||||
command_available = commandAvailable,
|
||||
message = "",
|
||||
}
|
||||
|
||||
local function tr(key, values)
|
||||
return noctalia.tr(key, values)
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. string.gsub(tostring(value), "'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function publishStatus(phase, message)
|
||||
status.phase = phase
|
||||
status.running = phase == "running"
|
||||
status.message = message or ""
|
||||
status.available = commandAvailable and runFile ~= nil and errorFile ~= nil
|
||||
status.command_available = commandAvailable
|
||||
noctalia.state.set("mirror_status", status)
|
||||
end
|
||||
|
||||
local function connected(name)
|
||||
if name == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, output in ipairs(noctalia.outputs() or {}) do
|
||||
if output.name == name then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function readMirrorError(fallback)
|
||||
if errorFile == nil then
|
||||
return fallback
|
||||
end
|
||||
|
||||
local message = noctalia.string.trim(noctalia.readFile(errorFile) or "")
|
||||
return message ~= "" and message or fallback
|
||||
end
|
||||
|
||||
local function cleanupControlFiles()
|
||||
if runFile ~= nil then
|
||||
noctalia.removeFile(runFile)
|
||||
end
|
||||
if errorFile ~= nil then
|
||||
noctalia.removeFile(errorFile)
|
||||
end
|
||||
end
|
||||
|
||||
-- runStream owns this shell process group, so Noctalia tears down both the
|
||||
-- wrapper and wl-mirror when the entry unloads. The marker provides normal
|
||||
-- start/stop control; the parent check also prevents an orphan after a crash.
|
||||
local function mirrorCommand(source, destination)
|
||||
local launch = "wl-mirror"
|
||||
.. " --fullscreen-output "
|
||||
.. shellQuote(destination)
|
||||
.. " --fullscreen"
|
||||
.. " --title "
|
||||
.. shellQuote(MANAGED_TITLE)
|
||||
.. " "
|
||||
.. shellQuote(source)
|
||||
.. ' >"$ERR" 2>&1 &'
|
||||
|
||||
return table.concat({
|
||||
"RUN=" .. shellQuote(runFile),
|
||||
"ERR=" .. shellQuote(errorFile),
|
||||
"OWNER=$PPID",
|
||||
launch,
|
||||
"PID=$!",
|
||||
[[trap 'kill -TERM "$PID" 2>/dev/null || true' HUP INT TERM EXIT]],
|
||||
"sleep 0.15",
|
||||
[[if ! kill -0 "$PID" 2>/dev/null; then wait "$PID"; CODE=$?; printf 'failed:%s\n' "$CODE"; exit 0; fi]],
|
||||
[[printf 'started\n']],
|
||||
[[while [ -f "$RUN" ] && kill -0 "$OWNER" 2>/dev/null && kill -0 "$PID" 2>/dev/null; do sleep 0.1; done]],
|
||||
[[if [ ! -f "$RUN" ] || ! kill -0 "$OWNER" 2>/dev/null; then]],
|
||||
[[kill -TERM "$PID" 2>/dev/null || true]],
|
||||
[[ATTEMPT=0]],
|
||||
[[while kill -0 "$PID" 2>/dev/null && [ "$ATTEMPT" -lt 20 ]; do sleep 0.05; ATTEMPT=$((ATTEMPT + 1)); done]],
|
||||
[[kill -KILL "$PID" 2>/dev/null || true]],
|
||||
[[wait "$PID" 2>/dev/null || true]],
|
||||
[[trap - HUP INT TERM EXIT]],
|
||||
[[printf 'stopped\n']],
|
||||
[[else]],
|
||||
[[wait "$PID"; CODE=$?]],
|
||||
[[trap - HUP INT TERM EXIT]],
|
||||
[[printf 'exited:%s\n' "$CODE"]],
|
||||
[[fi]],
|
||||
}, "\n")
|
||||
end
|
||||
|
||||
local function finishStopped()
|
||||
local previousSource = status.source
|
||||
local previousDestination = status.destination
|
||||
local message = stopMessage or tr("service.mirror_stopped")
|
||||
local shouldNotify = notifyAfterStop
|
||||
|
||||
streamActive = false
|
||||
stopRequested = false
|
||||
stopMessage = nil
|
||||
notifyAfterStop = false
|
||||
cleanupControlFiles()
|
||||
|
||||
status.source = nil
|
||||
status.destination = nil
|
||||
publishStatus("stopped", message)
|
||||
|
||||
if shouldNotify then
|
||||
noctalia.notify(
|
||||
tr("title"),
|
||||
tr("notification.stopped", {
|
||||
source = previousSource or "?",
|
||||
destination = previousDestination or "?",
|
||||
})
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function finishFailed(fallback)
|
||||
local message = readMirrorError(fallback)
|
||||
|
||||
streamActive = false
|
||||
stopRequested = false
|
||||
stopMessage = nil
|
||||
notifyAfterStop = false
|
||||
cleanupControlFiles()
|
||||
|
||||
status.source = nil
|
||||
status.destination = nil
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
end
|
||||
|
||||
local function handleMirrorLine(line)
|
||||
line = noctalia.string.trim(line or "")
|
||||
|
||||
if line == "started" then
|
||||
if not stopRequested then
|
||||
publishStatus("running", tr("service.mirror_active"))
|
||||
noctalia.notify(
|
||||
tr("title"),
|
||||
tr("notification.started", {
|
||||
source = status.source or "?",
|
||||
destination = status.destination or "?",
|
||||
})
|
||||
)
|
||||
end
|
||||
elseif line == "stopped" then
|
||||
finishStopped()
|
||||
elseif string.match(line, "^failed:") ~= nil then
|
||||
finishFailed(tr("service.mirror_failed"))
|
||||
elseif string.match(line, "^exited:") ~= nil then
|
||||
local exitCode = tonumber(string.match(line, "^exited:(%d+)$"))
|
||||
local userTerminated = exitCode == 0
|
||||
or exitCode == 129 -- SIGHUP
|
||||
or exitCode == 130 -- SIGINT
|
||||
or exitCode == 131 -- SIGQUIT
|
||||
or exitCode == 137 -- SIGKILL
|
||||
or exitCode == 143 -- SIGTERM
|
||||
|
||||
if userTerminated then
|
||||
finishStopped()
|
||||
else
|
||||
finishFailed(tr("service.mirror_exited"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function stopMirror(message, notify)
|
||||
if status.phase == "stopping" then
|
||||
return
|
||||
end
|
||||
|
||||
if not streamActive then
|
||||
status.source = nil
|
||||
status.destination = nil
|
||||
publishStatus("stopped", message or tr("service.mirror_stopped"))
|
||||
return
|
||||
end
|
||||
|
||||
stopRequested = true
|
||||
stopMessage = message or tr("service.mirror_stopped")
|
||||
notifyAfterStop = notify == true
|
||||
publishStatus("stopping", tr("service.stopping"))
|
||||
|
||||
if runFile == nil or noctalia.removeFile(runFile) ~= true then
|
||||
finishFailed(tr("service.data_unavailable"))
|
||||
end
|
||||
end
|
||||
|
||||
local function startMirror(source, destination)
|
||||
if not commandAvailable then
|
||||
local message = tr("service.wl_mirror_missing")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
return
|
||||
end
|
||||
|
||||
if runFile == nil or errorFile == nil then
|
||||
local message = tr("service.data_unavailable")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
return
|
||||
end
|
||||
|
||||
if source == nil or destination == nil or source == destination then
|
||||
local message = tr("service.invalid_pair")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
return
|
||||
end
|
||||
|
||||
if not connected(source) or not connected(destination) then
|
||||
local message = tr("service.monitor_missing")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
return
|
||||
end
|
||||
|
||||
if streamActive or status.phase == "running" or status.phase == "starting" then
|
||||
return
|
||||
end
|
||||
|
||||
local written, writeError = noctalia.writeFile(runFile, source .. "\n" .. destination .. "\n")
|
||||
if not written then
|
||||
local message = writeError or tr("service.data_unavailable")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
return
|
||||
end
|
||||
noctalia.writeFile(errorFile, "")
|
||||
|
||||
status.source = source
|
||||
status.destination = destination
|
||||
stopRequested = false
|
||||
stopMessage = nil
|
||||
notifyAfterStop = false
|
||||
publishStatus("starting", tr("service.starting"))
|
||||
|
||||
streamActive = true
|
||||
local accepted = noctalia.runStream(mirrorCommand(source, destination), handleMirrorLine)
|
||||
if not accepted then
|
||||
streamActive = false
|
||||
cleanupControlFiles()
|
||||
status.source = nil
|
||||
status.destination = nil
|
||||
local message = tr("service.launch_failed")
|
||||
publishStatus("error", message)
|
||||
noctalia.notifyError(tr("title"), message)
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.watch("mirror_request", function(request)
|
||||
if type(request) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
if request.action == "start" then
|
||||
startMirror(request.source, request.destination)
|
||||
elseif request.action == "stop" then
|
||||
stopMirror(tr("service.mirror_stopped"), true)
|
||||
end
|
||||
end)
|
||||
|
||||
function onOutputsChanged()
|
||||
if
|
||||
(status.phase == "running" or status.phase == "starting")
|
||||
and (not connected(status.source) or not connected(status.destination))
|
||||
then
|
||||
stopMirror(tr("service.disconnected"), false)
|
||||
end
|
||||
end
|
||||
|
||||
function onExit(_signal)
|
||||
if runFile ~= nil then
|
||||
noctalia.removeFile(runFile)
|
||||
end
|
||||
end
|
||||
|
||||
cleanupControlFiles()
|
||||
if not commandAvailable then
|
||||
publishStatus("error", tr("service.wl_mirror_missing"))
|
||||
elseif runFile == nil or errorFile == nil then
|
||||
publishStatus("error", tr("service.data_unavailable"))
|
||||
else
|
||||
publishStatus("stopped", tr("service.ready"))
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"notification": {
|
||||
"started": "Mirroring {source} to {destination}.",
|
||||
"stopped": "Stopped mirroring {source} to {destination}."
|
||||
},
|
||||
"panel": {
|
||||
"action": {
|
||||
"start": "Start mirroring",
|
||||
"starting": "Starting…",
|
||||
"stop": "Stop mirroring",
|
||||
"stopping": "Stopping…"
|
||||
},
|
||||
"choose_another": "Choose another monitor",
|
||||
"close": "Close",
|
||||
"destination_monitor": "Destination monitor",
|
||||
"invalid_pair": "Choose two different connected monitors.",
|
||||
"no_monitor": "No monitor available",
|
||||
"source_monitor": "Source monitor",
|
||||
"status": {
|
||||
"error_message": "Screen mirroring failed.",
|
||||
"error_title": "Error",
|
||||
"ready_message": "Choose two different monitors.",
|
||||
"ready_title": "Ready",
|
||||
"running_message": "Screen mirroring is active.",
|
||||
"running_title": "Mirroring",
|
||||
"starting_message": "Launching wl-mirror…",
|
||||
"starting_title": "Starting",
|
||||
"stopping_message": "Stopping wl-mirror…",
|
||||
"stopping_title": "Stopping"
|
||||
},
|
||||
"two_monitors_required": "Connect at least two monitors to start mirroring.",
|
||||
"wl_mirror_missing": "wl-mirror was not found in PATH."
|
||||
},
|
||||
"service": {
|
||||
"data_unavailable": "The plugin data directory is unavailable.",
|
||||
"disconnected": "A selected monitor was disconnected.",
|
||||
"invalid_pair": "Choose two different connected monitors.",
|
||||
"launch_failed": "Noctalia could not launch wl-mirror.",
|
||||
"mirror_active": "Screen mirroring is active.",
|
||||
"mirror_exited": "wl-mirror exited unexpectedly.",
|
||||
"mirror_failed": "wl-mirror failed to start.",
|
||||
"mirror_stopped": "Screen mirroring stopped.",
|
||||
"monitor_missing": "One of the selected monitors is no longer connected.",
|
||||
"ready": "Choose two different monitors.",
|
||||
"starting": "Launching wl-mirror…",
|
||||
"stopping": "Stopping wl-mirror…",
|
||||
"wl_mirror_missing": "wl-mirror was not found in PATH."
|
||||
},
|
||||
"title": "Screen Mirror",
|
||||
"widget": {
|
||||
"error": "Screen mirror error",
|
||||
"ready": "Choose displays and start screen mirroring",
|
||||
"running": "Mirroring {source} to {destination}",
|
||||
"starting": "Starting screen mirroring",
|
||||
"stopping": "Stopping screen mirroring"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
--!nonstrict
|
||||
|
||||
local PANEL_ID = "elijaharch/wl-screen-mirror:controls"
|
||||
|
||||
local status = noctalia.state.get("mirror_status") or {
|
||||
phase = "stopped",
|
||||
running = false,
|
||||
}
|
||||
|
||||
local function tr(key, values)
|
||||
return noctalia.tr(key, values)
|
||||
end
|
||||
|
||||
local function render()
|
||||
local phase = status.phase or "stopped"
|
||||
barWidget.setText("")
|
||||
|
||||
if phase == "running" then
|
||||
barWidget.setGlyph("screen-share")
|
||||
barWidget.setGlyphColor("primary")
|
||||
barWidget.setTooltip(tr("widget.running", {
|
||||
source = status.source or "?",
|
||||
destination = status.destination or "?",
|
||||
}))
|
||||
elseif phase == "starting" then
|
||||
barWidget.setGlyph("loader-2")
|
||||
barWidget.setGlyphColor("on_surface_variant")
|
||||
barWidget.setTooltip(tr("widget.starting"))
|
||||
elseif phase == "stopping" then
|
||||
barWidget.setGlyph("loader-2")
|
||||
barWidget.setGlyphColor("on_surface_variant")
|
||||
barWidget.setTooltip(tr("widget.stopping"))
|
||||
elseif phase == "error" then
|
||||
barWidget.setGlyph("screen-share-off")
|
||||
barWidget.setGlyphColor("error")
|
||||
barWidget.setTooltip(status.message ~= nil and status.message ~= "" and status.message or tr("widget.error"))
|
||||
else
|
||||
barWidget.setGlyph("screen-share")
|
||||
barWidget.setGlyphColor("on_surface")
|
||||
barWidget.setTooltip(tr("widget.ready"))
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.watch("mirror_status", function(value)
|
||||
if type(value) == "table" then
|
||||
status = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
function update()
|
||||
local current = noctalia.state.get("mirror_status")
|
||||
if type(current) == "table" then
|
||||
status = current
|
||||
end
|
||||
render()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
|
||||
noctalia.setUpdateInterval(1000)
|
||||
render()
|
||||
Reference in New Issue
Block a user