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