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:
ProfiDev
2026-07-21 23:57:54 -04:00
committed by GitHub
co-authored by Lemmy
parent 58b6c81f09
commit 8a3bb49fdb
7 changed files with 398 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Hypr Screen Mirror
This plugin allows you to easily toggle screen mirroring in Hyprland.
## Plugin
| Field | Value |
| ------- | -------------------------------------------------------- |
| ID | `profidev/hypr-screen-mirror` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service` |
## Requirements
- `hyprctl` available on `PATH` (used to control Hyprland and query monitor information)
- `socat` available on `PATH` (used to watch Hyprland socket for monitor events)
## Usage
1. Add the widget to your bar
2. Open the panel and select your source and target monitors for mirroring
3. Click on "Mirror" to start mirroring, or "Stop" to stop mirroring
```sh
noctalia msg panel-toggle profidev/hypr-screen-mirror:panel
```
## Settings
| Setting | Type | Default | Description |
| ------- | ------- | -------------- | ------------------------------- |
| `glyph` | `glyph` | `screen-share` | The glyph to display in the bar |
## Notes
- Only supports Hyprland 0.55.0 or newer, as it relies on the `hyprctl eval` command to execute lua code for mirroring.
+88
View File
@@ -0,0 +1,88 @@
function tr(key)
return noctalia.tr(key)
end
local function render()
local monitors = noctalia.state.get("monitors") or {}
local focusedMonitor = noctalia.state.get("focusedMonitor")
local sourceMonitor = noctalia.state.get("sourceMonitor")
local targetMonitor = noctalia.state.get("targetMonitor")
local isMirroring = noctalia.state.get("isMirroring")
local buttonColor = isMirroring and "destructive" or "primary"
local buttonGlyph = isMirroring and "player-stop" or "player-play"
local buttonText = isMirroring and tr("panel.stop_mirroring") or tr("panel.start_mirroring")
panel.render(ui.column({ flexGrow = 1, gap = 8 }, {
ui.row({ align = "center", justify = "space-between", gap = 8 }, {
ui.label({ text = tr("panel.title"), fontSize = 14, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.row({ align = "center", justify = "space-between", gap = 8 }, {
ui.label({ text = tr("panel.focused_monitor") .. ":" , fontSize = 14, color = "on_surface_variant", flexGrow = 1 }),
ui.label({ text = focusedMonitor, fontSize = 14, color = "on_surface" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("panel.source_monitor"), fontSize = 14, color = "on_surface_variant" }),
ui.select({ options = monitors, onChange = "onSourceChange", selectedIndex = sourceMonitor, enabled = not isMirroring }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("panel.target_monitor"), fontSize = 14, color = "on_surface_variant" }),
ui.select({ options = monitors, onChange = "onTargetChange", selectedIndex = targetMonitor, enabled = not isMirroring }),
}),
ui.row({ align = "center", justify = "space-between", gap = 8 }, {
ui.button({ text = tr("panel.refresh"), glyph = "refresh", onClick = "onRefreshClicked", flexGrow = 1 }),
ui.button({ text = buttonText, glyph = buttonGlyph, onClick = "onStartMirroringClicked", flexGrow = 1, variant = buttonColor, enabled = sourceMonitor ~= nil and targetMonitor ~= nil and sourceMonitor ~= targetMonitor }),
}),
}))
end
function onOpen(_context)
render()
end
function onCloseClicked()
panel.close()
end
function onSourceChange(selected)
noctalia.state.set("sourceMonitor", tonumber(selected))
render()
end
function onTargetChange(selected)
noctalia.state.set("targetMonitor", tonumber(selected))
render()
end
function onRefreshClicked()
noctalia.state.set("monitor_refresh", os.clock())
end
function onStartMirroringClicked()
noctalia.state.set("mirrorRequest", {
enabled = not noctalia.state.get("isMirroring"),
sourceMonitor = noctalia.state.get("sourceMonitor"),
targetMonitor = noctalia.state.get("targetMonitor"),
})
end
noctalia.state.watch("monitors", function()
render()
end)
noctalia.state.watch("focusedMonitor", function()
render()
end)
noctalia.state.watch("sourceMonitor", function()
render()
end)
noctalia.state.watch("targetMonitor", function()
render()
end)
noctalia.state.watch("isMirroring", function()
render()
end)
+33
View File
@@ -0,0 +1,33 @@
id = "profidev/hypr-screen-mirror"
name = "Hypr-Screen-Mirror"
version = "0.1.0"
plugin_api = 5
author = "profidev"
license = "MIT"
dependencies = ["hyprctl", "socat"]
tags = ["utility", "hyprland"]
icon = "screen-share"
description = "A plugin to quickly enable and disable screen mirroring in Hyprland."
[[widget]]
id = "widget"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "widget.glyph.label"
description_key = "widget.glyph.description"
default = "screen-share"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 300
height = 265
placement = "floating"
position = "center"
[[service]]
id = "service"
entry = "service.luau"
+201
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"widget": {
"glyph": {
"label": "Glyph",
"description": "Glyph shown in the bar"
}
},
"notification": {
"monitor_list_failed": "Failed to list monitors",
"timed_out": "Command timed out",
"no_monitors_found": "No monitors found",
"stop_mirroring_failed": "Failed to stop mirroring",
"start_mirroring_failed": "Failed to start mirroring",
"monitor_watch_failed": "Failed to watch monitors",
"hyprland_socket_not_found": "Hyprland socket not found",
"invalid_monitor_selection": "Invalid monitor selection"
},
"panel": {
"title": "Hypr Screen Mirror",
"source_monitor": "Source Monitor",
"target_monitor": "Target Monitor",
"focused_monitor": "Current Monitor",
"start_mirroring": "Mirror",
"stop_mirroring": "Stop",
"refresh": "Refresh"
}
}
+14
View File
@@ -0,0 +1,14 @@
local glyph = noctalia.getConfig("glyph")
local function render()
barWidget.setGlyph(glyph)
end
function update()
noctalia.setUpdateInterval(1000)
render()
end
function onClick()
noctalia.togglePanel("profidev/hypr-screen-mirror:panel")
end