feat: add hypr-screen-mirror (#66)
* feat(hypr-screen-mirror): setup plugin * feat(hypr-screen-mirror): added socat watch on hyprland socket for monitor change * feat(hypr-screen-mirror): added hyprctl commands for starting and stopping mirroring * fix(hypr-screen-mirror): check if a mirror is active from monitor output during monitor update * doc(hypr-screen-mirror): plugin readme and image * fix(hypr-screen-mirror): issues from review * fix(hypr-screen-mirror): separate observed state from mirror actions --------- Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
function tr(key)
|
||||
return noctalia.tr(key)
|
||||
end
|
||||
|
||||
local function shellQuote(s)
|
||||
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- This could be replaced by noctalia.outputs() and noctalia.focusedOutputName() but currently the focusedOutputName() function returns nil when testing so we can't use it
|
||||
local function updateMonitors()
|
||||
noctalia.runAsync("hyprctl monitors all -j", function(output)
|
||||
if output.timedOut or output.exitCode ~= 0 then
|
||||
noctalia.notify(tr("notification.monitor_list_failed"), output.stderr or tr("notification.timed_out"))
|
||||
return
|
||||
end
|
||||
|
||||
local ok, msg = pcall(noctalia.json.decode, output.stdout)
|
||||
if not ok or type(msg) ~= "table" then
|
||||
noctalia.notify(tr("notification.monitor_list_failed"), msg)
|
||||
return
|
||||
end
|
||||
|
||||
local monitors = {}
|
||||
local focusedMonitor = nil
|
||||
for i, monitor in ipairs(msg) do
|
||||
monitors[i] = monitor.name
|
||||
focusedMonitor = monitor.focused and monitor.name or focusedMonitor
|
||||
end
|
||||
|
||||
if #monitors == 0 then
|
||||
noctalia.notify(tr("notification.monitor_list_failed"), tr("notification.no_monitors_found"))
|
||||
return
|
||||
end
|
||||
|
||||
-- update monitor data
|
||||
noctalia.state.set("monitors", monitors)
|
||||
noctalia.state.set("focusedMonitor", focusedMonitor)
|
||||
|
||||
-- update source and target monitor indices if they are out of bounds or not set
|
||||
local sourceMonitor = tonumber(noctalia.state.get("sourceMonitor")) or 0
|
||||
local targetMonitor = tonumber(noctalia.state.get("targetMonitor")) or 0
|
||||
local isMirroring = false
|
||||
|
||||
if sourceMonitor >= #monitors then
|
||||
sourceMonitor = 0
|
||||
end
|
||||
if targetMonitor >= #monitors then
|
||||
targetMonitor = 0
|
||||
end
|
||||
|
||||
-- check if there is an active mirror and set the source and target monitors accordingly
|
||||
for _, monitor in ipairs(msg) do
|
||||
if monitor.mirrorOf == "none" then
|
||||
continue
|
||||
end
|
||||
|
||||
local mirrorId = tonumber(monitor.mirrorOf)
|
||||
local targetIndex = table.find(monitors, monitor.name)
|
||||
if mirrorId ~= nil and targetIndex ~= nil then
|
||||
for _, source in ipairs(msg) do
|
||||
if tonumber(source.id) == mirrorId then
|
||||
local sourceIndex = table.find(monitors, source.name)
|
||||
if sourceIndex ~= nil then
|
||||
sourceMonitor = sourceIndex - 1
|
||||
targetMonitor = targetIndex - 1
|
||||
isMirroring = true
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if isMirroring then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.set("sourceMonitor", sourceMonitor)
|
||||
noctalia.state.set("targetMonitor", targetMonitor)
|
||||
noctalia.state.set("isMirroring", isMirroring)
|
||||
|
||||
end, 5000)
|
||||
end
|
||||
|
||||
local function hyprlandSocketPath()
|
||||
local runtimeDir = noctalia.getenv("XDG_RUNTIME_DIR")
|
||||
local instanceSig = noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
|
||||
if not runtimeDir or not instanceSig then
|
||||
return nil
|
||||
end
|
||||
|
||||
return string.format("%s/hypr/%s/.socket2.sock", runtimeDir, instanceSig)
|
||||
end
|
||||
|
||||
local function handleHyprEvent(line)
|
||||
local event, data = line:match("^([^>]+)>>(.*)$")
|
||||
-- we can not use the event data itself for the monitor events because hyprland treats a mirror as a monitor remove/add so this would remove a mirrored screen from the list
|
||||
if event == "monitoradded" then
|
||||
updateMonitors()
|
||||
elseif event == "monitorremoved" then
|
||||
updateMonitors()
|
||||
elseif event == "focusedmon" then
|
||||
-- data is MONNAME,WORKSPACENAME
|
||||
local monitorName = data:match("([^,]+)")
|
||||
if not monitorName then
|
||||
return
|
||||
end
|
||||
noctalia.state.set("focusedMonitor", monitorName)
|
||||
end
|
||||
end
|
||||
|
||||
-- Watches the Hyprland socket for monitor events and updates the state accordingly
|
||||
local function watchMonitors()
|
||||
local socketPath = hyprlandSocketPath()
|
||||
if not socketPath then
|
||||
noctalia.notify(tr("notification.monitor_watch_failed"), tr("notification.hyprland_socket_not_found"))
|
||||
return
|
||||
end
|
||||
|
||||
local cmd = "socat -U - UNIX-CONNECT:" .. shellQuote(socketPath)
|
||||
noctalia.runStream(cmd, handleHyprEvent)
|
||||
end
|
||||
|
||||
-- main
|
||||
updateMonitors()
|
||||
watchMonitors()
|
||||
|
||||
noctalia.state.watch("monitor_refresh", function()
|
||||
updateMonitors()
|
||||
end)
|
||||
|
||||
local function stopMirroring(targetIndex)
|
||||
targetIndex = tonumber(targetIndex)
|
||||
local monitors = noctalia.state.get("monitors") or {}
|
||||
|
||||
if not targetIndex then
|
||||
noctalia.notify(tr("notification.stop_mirroring_failed"), tr("notification.invalid_monitor_selection"))
|
||||
return
|
||||
end
|
||||
|
||||
local targetMonitor = monitors[targetIndex + 1]
|
||||
|
||||
if not targetMonitor then
|
||||
noctalia.notify(tr("notification.stop_mirroring_failed"), tr("notification.invalid_monitor_selection"))
|
||||
return
|
||||
end
|
||||
|
||||
local expr = string.format("hl.monitor({ output = %q, mirror = \"\" })", targetMonitor)
|
||||
local cmd = "hyprctl eval " .. shellQuote(expr)
|
||||
|
||||
noctalia.runAsync(cmd, function(output)
|
||||
if output.timedOut or output.exitCode ~= 0 then
|
||||
noctalia.notify(tr("notification.stop_mirroring_failed"), output.stderr or tr("notification.timed_out"))
|
||||
return
|
||||
end
|
||||
updateMonitors()
|
||||
end, 5000)
|
||||
end
|
||||
|
||||
local function startMirroring(sourceIndex, targetIndex)
|
||||
sourceIndex = tonumber(sourceIndex)
|
||||
targetIndex = tonumber(targetIndex)
|
||||
local monitors = noctalia.state.get("monitors") or {}
|
||||
|
||||
if not sourceIndex or not targetIndex or sourceIndex == targetIndex then
|
||||
noctalia.notify(tr("notification.start_mirroring_failed"), tr("notification.invalid_monitor_selection"))
|
||||
return
|
||||
end
|
||||
|
||||
local sourceMonitor = monitors[sourceIndex + 1]
|
||||
local targetMonitor = monitors[targetIndex + 1]
|
||||
|
||||
if not sourceMonitor or not targetMonitor then
|
||||
noctalia.notify(tr("notification.start_mirroring_failed"), tr("notification.invalid_monitor_selection"))
|
||||
return
|
||||
end
|
||||
|
||||
local expr = string.format("hl.monitor({ output = %q, mirror = %q })", targetMonitor, sourceMonitor)
|
||||
local cmd = "hyprctl eval " .. shellQuote(expr)
|
||||
|
||||
noctalia.runAsync(cmd, function(output)
|
||||
if output.timedOut or output.exitCode ~= 0 then
|
||||
noctalia.notify(tr("notification.start_mirroring_failed"), output.stderr or tr("notification.timed_out"))
|
||||
return
|
||||
end
|
||||
updateMonitors()
|
||||
end, 5000)
|
||||
end
|
||||
|
||||
noctalia.state.watch("mirrorRequest", function(request)
|
||||
if type(request) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
if request.enabled == true then
|
||||
startMirroring(request.sourceMonitor, request.targetMonitor)
|
||||
else
|
||||
stopMirroring(request.targetMonitor)
|
||||
end
|
||||
end)
|
||||
Reference in New Issue
Block a user