diff --git a/hypr-screen-mirror/README.md b/hypr-screen-mirror/README.md new file mode 100644 index 0000000..b482f56 --- /dev/null +++ b/hypr-screen-mirror/README.md @@ -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. diff --git a/hypr-screen-mirror/panel.luau b/hypr-screen-mirror/panel.luau new file mode 100644 index 0000000..a0914c5 --- /dev/null +++ b/hypr-screen-mirror/panel.luau @@ -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) diff --git a/hypr-screen-mirror/plugin.toml b/hypr-screen-mirror/plugin.toml new file mode 100644 index 0000000..7cfee70 --- /dev/null +++ b/hypr-screen-mirror/plugin.toml @@ -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" diff --git a/hypr-screen-mirror/service.luau b/hypr-screen-mirror/service.luau new file mode 100644 index 0000000..ea21e5a --- /dev/null +++ b/hypr-screen-mirror/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) diff --git a/hypr-screen-mirror/thumbnail.webp b/hypr-screen-mirror/thumbnail.webp new file mode 100644 index 0000000..a803f3b Binary files /dev/null and b/hypr-screen-mirror/thumbnail.webp differ diff --git a/hypr-screen-mirror/translations/en.json b/hypr-screen-mirror/translations/en.json new file mode 100644 index 0000000..1c81695 --- /dev/null +++ b/hypr-screen-mirror/translations/en.json @@ -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" + } +} diff --git a/hypr-screen-mirror/widget.luau b/hypr-screen-mirror/widget.luau new file mode 100644 index 0000000..22972a5 --- /dev/null +++ b/hypr-screen-mirror/widget.luau @@ -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