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,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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user