From 1811e0c536f9b22533fc85e4bc5eab94a9b8eca3 Mon Sep 17 00:00:00 2001 From: Linus Walker Date: Fri, 2 Oct 2026 12:14:16 +0630 Subject: [PATCH 1/2] feat: screen recorder backend chooser, fix setup keyboard grab - Add backend chooser (wf-recorder / gpu-screen-recorder / wl-screenrec) to ScreenRecService and the options panel, persisted via PrefsService. - Stop/discard now signal whichever backend binary is actually selected, instead of always pkill wf-recorder. - "Recording Saved" notification now checks the file actually landed before claiming success; shows "Recording Failed" naming the backend otherwise (a dead/unsupported backend was silently reported as saved). - Fix Alt+F9 record setup grabbing exclusive keyboard focus for the whole desktop just to catch Escape, which blocked typing anywhere until Record or Escape was pressed. Escape-to-cancel now runs through a non-consuming "quickshell:screenrecCancel" global shortcut instead, so it still works without taking the keyboard from other windows. --- src/popups/ScreenRecOptionsPopup.qml | 35 ++++++++++- src/services/KeybindService.qml | 15 +++++ src/services/PrefsService.qml | 3 + src/services/ScreenRecService.qml | 87 ++++++++++++++++++++++++---- src/windows/DynamicSurface.qml | 8 ++- 5 files changed, 133 insertions(+), 15 deletions(-) diff --git a/src/popups/ScreenRecOptionsPopup.qml b/src/popups/ScreenRecOptionsPopup.qml index 465b28e..19f1a97 100644 --- a/src/popups/ScreenRecOptionsPopup.qml +++ b/src/popups/ScreenRecOptionsPopup.qml @@ -39,8 +39,9 @@ Item { readonly property int targetCardH: targetTileH + Math.round(16 * localScale) readonly property int bottomCardH: (audioFpsTileH * 2) + gap + Math.round(16 * localScale) + readonly property int backendCardH: audioFpsTileH + Math.round(16 * localScale) - height: targetCardH + gap + bottomCardH + height: targetCardH + gap + bottomCardH + gap + backendCardH property bool isOpen: ScreenRecService.optionsExpanded && !ScreenRecService.recording opacity: isOpen ? 1 : 0 @@ -96,7 +97,7 @@ Item { padding: root.gap anchors.top: targetCard.bottom; anchors.topMargin: root.gap anchors.left: parent.left - anchors.bottom: parent.bottom + height: root.bottomCardH width: (parent.width - root.gap) / 2 Column { @@ -116,7 +117,7 @@ Item { padding: root.gap anchors.top: targetCard.bottom; anchors.topMargin: root.gap anchors.right: parent.right - anchors.bottom: parent.bottom + height: root.bottomCardH width: (parent.width - root.gap) / 2 Column { @@ -128,6 +129,34 @@ Item { TglBtn { width: parent.width; height: root.audioFpsTileH; label: "30 FPS"; on: PrefsService.screenrecFramerate === 30; onToggled: PrefsService.screenrecFramerate = 30 } } } + + // Backend Card + StatCard { + id: backendCard + localScale: root.localScale + padding: root.gap + anchors.top: audioCard.bottom; anchors.topMargin: root.gap + anchors.left: parent.left + anchors.right: parent.right + height: root.backendCardH + + Row { + anchors.centerIn: parent + spacing: root.gap + + Repeater { + model: ScreenRecService.backends + delegate: TglBtn { + required property var modelData + width: root.tileW + height: root.audioFpsTileH + label: modelData.label + on: PrefsService.screenrecBackend === modelData.id + onToggled: PrefsService.screenrecBackend = modelData.id + } + } + } + } } component TglBtn: Rectangle { diff --git a/src/services/KeybindService.qml b/src/services/KeybindService.qml index 1eb8cd0..c0c74a2 100644 --- a/src/services/KeybindService.qml +++ b/src/services/KeybindService.qml @@ -336,6 +336,14 @@ QtObject { "end)", "", "-- ==============================================================================", + "-- Screen-Record Escape", + "-- non_consuming: Escape still reaches the focused app, so this costs nothing", + "-- outside record setup. The shell ignores it unless setup is open.", + "-- ==============================================================================", + "hl.bind(\"ESCAPE\", hl.dsp.global(\"quickshell:screenrecCancel\"),", + " { non_consuming = true, description = \"Brain Shell: Cancel screen record setup\" })", + "", + "-- ==============================================================================", "-- User Defined Bindings", "-- ==============================================================================", "" @@ -374,6 +382,13 @@ QtObject { "submap = reset", "", "# ==============================================================================", + "# Screen-Record Escape", + "# bindn (non-consuming): Escape still reaches the focused app, so this costs", + "# nothing outside record setup. The shell ignores it unless setup is open.", + "# ==============================================================================", + "bindn = , ESCAPE, global, quickshell:screenrecCancel", + "", + "# ==============================================================================", "# User Defined Bindings", "# ==============================================================================", "" diff --git a/src/services/PrefsService.qml b/src/services/PrefsService.qml index 0e5af75..a3177ef 100644 --- a/src/services/PrefsService.qml +++ b/src/services/PrefsService.qml @@ -50,6 +50,7 @@ QtObject { property bool screenrecAudioMic: false property bool screenrecAudioSystem: false property int screenrecFramerate: 30 + property string screenrecBackend: "wf" // wf | gsr | wlsr property string screenrecSaveDir: Quickshell.env("HOME") + "/Videos/screen_recordings" // Network (Hotspot) @@ -135,6 +136,7 @@ QtObject { if (o.screenrecAudioMic !== undefined) root.screenrecAudioMic = o.screenrecAudioMic if (o.screenrecAudioSystem !== undefined) root.screenrecAudioSystem = o.screenrecAudioSystem if (o.screenrecFramerate !== undefined) root.screenrecFramerate = o.screenrecFramerate + if (o.screenrecBackend !== undefined) root.screenrecBackend = o.screenrecBackend if (o.screenrecSaveDir !== undefined) root.screenrecSaveDir = o.screenrecSaveDir if (o.hotspotSsid !== undefined) root.hotspotSsid = o.hotspotSsid if (o.hotspotPassword !== undefined) root.hotspotPassword = o.hotspotPassword @@ -243,6 +245,7 @@ QtObject { screenrecAudioMic: root.screenrecAudioMic, screenrecAudioSystem: root.screenrecAudioSystem, screenrecFramerate: root.screenrecFramerate, + screenrecBackend: root.screenrecBackend, screenrecSaveDir: root.screenrecSaveDir, hotspotSsid: root.hotspotSsid, hotspotPassword: root.hotspotPassword, diff --git a/src/services/ScreenRecService.qml b/src/services/ScreenRecService.qml index 3f10d69..6483018 100644 --- a/src/services/ScreenRecService.qml +++ b/src/services/ScreenRecService.qml @@ -20,6 +20,7 @@ pragma Singleton import QtQuick import Quickshell import Quickshell.Io +import Quickshell.Hyprland import "../" // ScreenRecService — owns all screen recording state. @@ -45,6 +46,21 @@ QtObject { + // ── Backends ────────────────────────────────────────────────────────────── + // wf-recorder is the default; the other two are useful when it struggles + // (gpu-screen-recorder for GPU encoding, wl-screenrec as a lighter + // alternative). The chosen binary is also what stop/discard signals. + readonly property var backends: [ + { id: "wf", label: "wf-recorder", binary: "wf-recorder" }, + { id: "gsr", label: "GPU Recorder", binary: "gpu-screen-recorder" }, + { id: "wlsr", label: "wl-screenrec", binary: "wl-screenrec" }, + ] + + readonly property string backendBinary: { + var b = root.backends.find(function(x) { return x.id === PrefsService.screenrecBackend }) + return b ? b.binary : "wf-recorder" + } + // ── Display helpers ─────────────────────────────────────────────────────── readonly property var _captureIcons: ({ screen: "󰍹", window: "󱂬", region: "󰩭" }) readonly property var _captureLabels: ({ screen: "Screen", window: "Window", region: "Region" }) @@ -58,6 +74,21 @@ QtObject { return "Non" } + // ── Escape to cancel ────────────────────────────────────────────────────── + // Bound non-consuming in the generated keybinds, so Escape still reaches + // whatever app is focused; we only react while setup is actually open. + // This replaces grabbing exclusive keyboard focus for the setup strip, + // which used to block all typing. + property var _cancelShortcut: GlobalShortcut { + appid: "quickshell" + name: "screenrecCancel" + onPressed: { + if (root.recording || !ShellState.screenRecord) return + if (root.optionsExpanded) root.optionsExpanded = false + else root.cancelSetup() + } + } + // ── Expansion state ───────────────────────── property bool optionsExpanded: false @@ -260,10 +291,13 @@ QtObject { root._teardownNullSink() if (!root._discarding && savedFile !== "") { - // Normal stop — notify with interactive action buttons. + // The backend exiting is not proof it worked — a missing binary + // or an unsupported codec dies immediately and writes nothing. + // Check the file actually landed before claiming "Saved". // FILE/"$FILE" expands $HOME correctly inside bash. _notifyProc.command = ["bash", "-c", "FILE=\"" + savedFile + "\"; " + + "if [ -s \"$FILE\" ]; then " + "DIR=\"$(dirname \"$FILE\")\"; " + "ACTION=$(notify-send" + " --app-name 'ScreenRec'" + @@ -275,7 +309,12 @@ QtObject { "case \"$ACTION\" in" + " view) xdg-open \"$DIR\" ;;" + " open) mpv \"$FILE\" ;;" + - "esac"] + "esac; " + + "else " + + "notify-send --app-name 'ScreenRec' --icon 'dialog-error' -u critical" + + " 'Recording Failed'" + + " \"" + root.backendBinary + " did not produce a file. Check that it is installed and supports your GPU/codec.\"; " + + "fi"] _notifyProc.running = false _notifyProc.running = true } @@ -289,8 +328,34 @@ QtObject { root._currentFile = saveDir + "/" + ts + ".mp4" var fps = PrefsService.screenrecFramerate > 0 ? PrefsService.screenrecFramerate : 30 - - var cmd = "mkdir -p '" + saveDir.replace(/'/g, "'\\''") + "' && " + + var file = root._currentFile.replace(/'/g, "'\\''") + var mkdir = "mkdir -p '" + saveDir.replace(/'/g, "'\\''") + "' && " + var hasAudio = (PrefsService.screenrecAudioMic || PrefsService.screenrecAudioSystem) + && root._resolvedAudioDevice !== "" + + if (PrefsService.screenrecBackend === "gsr") { + var gsr = "gpu-screen-recorder -f " + fps + " -k h264 -cursor yes -o '" + file + "'" + if (root._pendingGeometry !== "") { + // "X,Y WxH" -> WxH+X+Y + var parts = root._pendingGeometry.split(" ") + gsr += " -w region -region '" + parts[1] + "+" + parts[0].replace(",", "+") + "'" + } else { + gsr += " -w screen" + } + if (hasAudio) gsr += " -a " + root._resolvedAudioDevice + return mkdir + gsr + } + + if (PrefsService.screenrecBackend === "wlsr") { + var wlsr = "wl-screenrec -f '" + file + "' --max-fps " + fps + if (root._pendingGeometry !== "") + wlsr += " -g '" + root._pendingGeometry + "'" + if (hasAudio) + wlsr += " --audio --audio-device " + root._resolvedAudioDevice + return mkdir + wlsr + } + + var cmd = mkdir + "wf-recorder -c libx264" + " -x yuv420p" + " -r " + fps + // Configurable FPS @@ -301,15 +366,15 @@ QtObject { " -p colorspace=bt709" + // Tags the correct HD color matrix " -p color_primaries=bt709" + " -p color_trc=bt709" + - " -f '" + root._currentFile.replace(/'/g, "'\\''") + "'" - + " -f '" + file + "'" + if (root._pendingGeometry !== "") cmd += " -g '" + root._pendingGeometry + "'" - + // Use --audio=DEVICE (matches wf-recorder working script convention) - if ((PrefsService.screenrecAudioMic || PrefsService.screenrecAudioSystem) && root._resolvedAudioDevice !== "") + if (hasAudio) cmd += " --audio=" + root._resolvedAudioDevice - + return cmd } @@ -353,7 +418,7 @@ QtObject { } function stopRecording() { - _sigProc.command = ["bash", "-c", "pkill -INT wf-recorder"] + _sigProc.command = ["bash", "-c", "pkill -INT " + root.backendBinary] _sigProc.running = false _sigProc.running = true } @@ -363,7 +428,7 @@ QtObject { var fileToDelete = root._currentFile // Kill wf-recorder; _recProc.onExited will see _discarding=true and skip // the saved notification. The timer below handles delete + notify. - _sigProc.command = ["bash", "-c", "pkill -INT wf-recorder"] + _sigProc.command = ["bash", "-c", "pkill -INT " + root.backendBinary] _sigProc.running = false _sigProc.running = true _discardTimer.fileToDelete = fileToDelete diff --git a/src/windows/DynamicSurface.qml b/src/windows/DynamicSurface.qml index 888ca3d..93e3191 100644 --- a/src/windows/DynamicSurface.qml +++ b/src/windows/DynamicSurface.qml @@ -66,7 +66,13 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Top WlrLayershell.namespace: "brain-shell-frame" - WlrLayershell.keyboardFocus: (SurfaceState.activeSurface !== "none" || (ShellState.screenRecord && !ScreenRecService.recording)) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + // Record setup deliberately does NOT take keyboard focus: it is a passive + // strip, not a text surface, and grabbing the keyboard for it meant you + // could not type into any window until you hit Record or Escape. Its + // Escape-to-cancel runs through the non-consuming "quickshell:screenrecCancel" + // global shortcut instead (see ScreenRecService), which leaves every other + // key — and every other bind — alone. + WlrLayershell.keyboardFocus: SurfaceState.activeSurface !== "none" ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None Region { id: fullRegion From 5686370caedc2aea209a3a726926b8cb91d5b082 Mon Sep 17 00:00:00 2001 From: Linus Walker Date: Fri, 2 Oct 2026 18:46:04 +0630 Subject: [PATCH 2/2] fix: address review on recorder backend chooser Restores the deliberate Esc-to-exit behaviour and fixes the backend blockers found in review. - Revert the keyboard-focus change: record setup is meant to be modal, like a region selection, so it cannot be started and forgotten. The global ESCAPE bind is dropped with it, since a bare non-consuming Escape also cancelled setup from inside other apps (Vim insert mode, dialogs) and hijacked a very common key. - Stop/discard the backend by pid instead of by name. Linux caps /proc/pid/comm at 15 chars, so "gpu-screen-recorder" (19) never matched pkill and the recording could not be stopped at all. The backend is now exec'd so it replaces the bash wrapper, making it the tracked process, and receives SIGINT directly. Avoids pkill -f, which would also match unrelated command lines. - gpu-screen-recorder audio: pass the node as -a device:; a bare node name is rejected or silently records silence. - Persist the backend choice: onScreenrecBackendChanged was missing, so the selection never reached shell_prefs.json. - Fullscreen capture now names the focused Hyprland output. wl-screenrec requires --output once more than one display is connected, and gpu-screen-recorder's "screen" target grabs the first enumerated monitor rather than the focused one. - Backend tiles keep their labels clear of the status dot and elide instead of overlapping it. - Detect which backend binaries are installed: missing ones are dimmed in the options panel, and starting one reports it instead of failing silently. - List gpu-screen-recorder and wl-screenrec as optional dependencies in install-arch.sh, install-nix.sh, validate-install.sh and the README. --- README.md | 4 +- dots-extra/install-arch.sh | 5 ++ dots-extra/install-nix.sh | 2 + dots-extra/validate-install.sh | 3 + src/popups/ScreenRecOptionsPopup.qml | 18 ++++- src/services/KeybindService.qml | 15 ---- src/services/PrefsService.qml | 1 + src/services/ScreenRecService.qml | 101 ++++++++++++++++++++------- src/windows/DynamicSurface.qml | 8 +-- 9 files changed, 104 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 16bb258..e13fae6 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,9 @@ _v0.2.0 basically got the TeamCherry treatment: meaning we rebuilt the entire ar
Screen Capture & Utilities -- **wf-recorder** - Screen recording (Wayland) +- **wf-recorder** - Screen recording (Wayland, default backend) +- **gpu-screen-recorder** - Optional recording backend (GPU encoding) +- **wl-screenrec** - Optional recording backend (lightweight) - **grimblast** - Screenshot utility (Wayland) - **cava** - Audio visualizer - **slurp** - Region/window selection diff --git a/dots-extra/install-arch.sh b/dots-extra/install-arch.sh index 254c2e0..776bdd2 100644 --- a/dots-extra/install-arch.sh +++ b/dots-extra/install-arch.sh @@ -295,6 +295,11 @@ fi AUR_DEPS+=( auto-cpufreq grimblast-git + + # Optional screen-recording backends. wf-recorder (repo, above) is the + # default; these are selectable alternatives in the recorder options. + gpu-screen-recorder + wl-screenrec ) if [[ "$AUR_HELPER" == "none" ]]; then diff --git a/dots-extra/install-nix.sh b/dots-extra/install-nix.sh index 9eb766a..2aab00f 100644 --- a/dots-extra/install-nix.sh +++ b/dots-extra/install-nix.sh @@ -396,6 +396,8 @@ if ! command -v quickshell &>/dev/null && ! command -v qs &>/dev/null; then - grimblast - slurp - wf-recorder +- gpu-screen-recorder (optional backend) +- wl-screenrec (optional backend) - cava - wtype - cliphist diff --git a/dots-extra/validate-install.sh b/dots-extra/validate-install.sh index 08311db..604ffa5 100644 --- a/dots-extra/validate-install.sh +++ b/dots-extra/validate-install.sh @@ -121,6 +121,9 @@ echo "" echo "# SCREEN RECORDING & MEDIA" check_command "wf-recorder" check_command "cava" +# Optional alternative recording backends (selectable in recorder options) +check_optional "gpu-screen-recorder" +check_optional "wl-screenrec" check_command "grimblast" echo "" diff --git a/src/popups/ScreenRecOptionsPopup.qml b/src/popups/ScreenRecOptionsPopup.qml index 19f1a97..be11481 100644 --- a/src/popups/ScreenRecOptionsPopup.qml +++ b/src/popups/ScreenRecOptionsPopup.qml @@ -151,6 +151,8 @@ Item { width: root.tileW height: root.audioFpsTileH label: modelData.label + labelSize: 10 + available: ScreenRecService.backendAvailable[modelData.id] !== false on: PrefsService.screenrecBackend === modelData.id onToggled: PrefsService.screenrecBackend = modelData.id } @@ -165,9 +167,13 @@ Item { property string icon: "" property string label: "" property string layoutMode: "vertical" + property int labelSize: 11 + property bool available: true signal toggled() radius: Math.round(10 * localScale) + opacity: available ? 1.0 : 0.4 + Behavior on opacity { NumberAnimation { duration: Anim.fast } } color: on ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14) : bH.hovered @@ -228,11 +234,17 @@ Item { } } - // FPS tiles + // FPS / backend tiles — margins keep the label clear of the status + // dot in the top-right corner, which longer names used to run under. Text { visible: btn.icon === "" - anchors.centerIn: parent - text: btn.label; font.pixelSize: Math.round(11 * localScale); font.weight: Font.Medium + anchors.fill: parent + anchors.leftMargin: Math.round(8 * localScale) + anchors.rightMargin: Math.round(8 * localScale) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: btn.label; font.pixelSize: Math.round(btn.labelSize * localScale); font.weight: Font.Medium color: btn.on ? Theme.text : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.45) Behavior on color { ColorAnimation { duration: Anim.color} } } diff --git a/src/services/KeybindService.qml b/src/services/KeybindService.qml index c0c74a2..1eb8cd0 100644 --- a/src/services/KeybindService.qml +++ b/src/services/KeybindService.qml @@ -336,14 +336,6 @@ QtObject { "end)", "", "-- ==============================================================================", - "-- Screen-Record Escape", - "-- non_consuming: Escape still reaches the focused app, so this costs nothing", - "-- outside record setup. The shell ignores it unless setup is open.", - "-- ==============================================================================", - "hl.bind(\"ESCAPE\", hl.dsp.global(\"quickshell:screenrecCancel\"),", - " { non_consuming = true, description = \"Brain Shell: Cancel screen record setup\" })", - "", - "-- ==============================================================================", "-- User Defined Bindings", "-- ==============================================================================", "" @@ -382,13 +374,6 @@ QtObject { "submap = reset", "", "# ==============================================================================", - "# Screen-Record Escape", - "# bindn (non-consuming): Escape still reaches the focused app, so this costs", - "# nothing outside record setup. The shell ignores it unless setup is open.", - "# ==============================================================================", - "bindn = , ESCAPE, global, quickshell:screenrecCancel", - "", - "# ==============================================================================", "# User Defined Bindings", "# ==============================================================================", "" diff --git a/src/services/PrefsService.qml b/src/services/PrefsService.qml index a3177ef..ed437ad 100644 --- a/src/services/PrefsService.qml +++ b/src/services/PrefsService.qml @@ -195,6 +195,7 @@ QtObject { onScreenrecAudioMicChanged: if (_loaded) saveConfig() onScreenrecAudioSystemChanged: if (_loaded) saveConfig() onScreenrecFramerateChanged: if (_loaded) saveConfig() + onScreenrecBackendChanged: if (_loaded) saveConfig() onScreenrecSaveDirChanged: if (_loaded) saveConfig() onHotspotSsidChanged: if (_loaded) saveConfig() onHotspotPasswordChanged: if (_loaded) saveConfig() diff --git a/src/services/ScreenRecService.qml b/src/services/ScreenRecService.qml index 6483018..5b461ca 100644 --- a/src/services/ScreenRecService.qml +++ b/src/services/ScreenRecService.qml @@ -20,7 +20,7 @@ pragma Singleton import QtQuick import Quickshell import Quickshell.Io -import Quickshell.Hyprland +import Quickshell.Hyprland // focusedMonitor for fullscreen capture import "../" // ScreenRecService — owns all screen recording state. @@ -61,6 +61,41 @@ QtObject { return b ? b.binary : "wf-recorder" } + // Which backend binaries are actually installed. The options panel dims the + // ones that are missing, and startRecording() refuses to launch them rather + // than silently producing nothing. + property var backendAvailable: ({}) + readonly property bool selectedBackendAvailable: + root.backendAvailable[PrefsService.screenrecBackend] !== false + + function backendLabelFor(id) { + var b = root.backends.find(function(x) { return x.id === id }) + return b ? b.label : id + } + + property var _probeProc: Process { + running: true + command: ["bash", "-c", + "for b in wf-recorder gpu-screen-recorder wl-screenrec; do " + + "command -v \"$b\" >/dev/null 2>&1 && echo \"$b 1\" || echo \"$b 0\"; done"] + stdout: StdioCollector { + id: probeOut + onStreamFinished: { + var map = {} + var byBinary = {} + for (var i = 0; i < root.backends.length; i++) + byBinary[root.backends[i].binary] = root.backends[i].id + var lines = probeOut.text.trim().split("\n") + for (var j = 0; j < lines.length; j++) { + var parts = lines[j].trim().split(" ") + if (parts.length === 2 && byBinary[parts[0]] !== undefined) + map[byBinary[parts[0]]] = parts[1] === "1" + } + root.backendAvailable = map + } + } + } + // ── Display helpers ─────────────────────────────────────────────────────── readonly property var _captureIcons: ({ screen: "󰍹", window: "󱂬", region: "󰩭" }) readonly property var _captureLabels: ({ screen: "Screen", window: "Window", region: "Region" }) @@ -74,21 +109,6 @@ QtObject { return "Non" } - // ── Escape to cancel ────────────────────────────────────────────────────── - // Bound non-consuming in the generated keybinds, so Escape still reaches - // whatever app is focused; we only react while setup is actually open. - // This replaces grabbing exclusive keyboard focus for the setup strip, - // which used to block all typing. - property var _cancelShortcut: GlobalShortcut { - appid: "quickshell" - name: "screenrecCancel" - onPressed: { - if (root.recording || !ShellState.screenRecord) return - if (root.optionsExpanded) root.optionsExpanded = false - else root.cancelSetup() - } - } - // ── Expansion state ───────────────────────── property bool optionsExpanded: false @@ -329,9 +349,16 @@ QtObject { var fps = PrefsService.screenrecFramerate > 0 ? PrefsService.screenrecFramerate : 30 var file = root._currentFile.replace(/'/g, "'\\''") - var mkdir = "mkdir -p '" + saveDir.replace(/'/g, "'\\''") + "' && " + // exec: the backend replaces this bash, so _recProc.processId is the + // recorder itself and _signalBackend() can SIGINT it directly. + var mkdir = "mkdir -p '" + saveDir.replace(/'/g, "'\\''") + "' && exec " var hasAudio = (PrefsService.screenrecAudioMic || PrefsService.screenrecAudioSystem) && root._resolvedAudioDevice !== "" + // Fullscreen capture must name an output: wl-screenrec requires --output + // whenever more than one display is connected, and gpu-screen-recorder's + // "screen" target grabs the first enumerated monitor rather than the + // focused one. Both take the Hyprland monitor name. + var monitor = Hyprland.focusedMonitor ? Hyprland.focusedMonitor.name : "" if (PrefsService.screenrecBackend === "gsr") { var gsr = "gpu-screen-recorder -f " + fps + " -k h264 -cursor yes -o '" + file + "'" @@ -340,9 +367,11 @@ QtObject { var parts = root._pendingGeometry.split(" ") gsr += " -w region -region '" + parts[1] + "+" + parts[0].replace(",", "+") + "'" } else { - gsr += " -w screen" + gsr += " -w " + (monitor !== "" ? "'" + monitor + "'" : "screen") } - if (hasAudio) gsr += " -a " + root._resolvedAudioDevice + // gpu-screen-recorder only accepts PulseAudio/PipeWire nodes under a + // "device:" prefix; a bare node name is rejected or records silence. + if (hasAudio) gsr += " -a 'device:" + root._resolvedAudioDevice + "'" return mkdir + gsr } @@ -350,6 +379,8 @@ QtObject { var wlsr = "wl-screenrec -f '" + file + "' --max-fps " + fps if (root._pendingGeometry !== "") wlsr += " -g '" + root._pendingGeometry + "'" + else if (monitor !== "") + wlsr += " --output '" + monitor + "'" if (hasAudio) wlsr += " --audio --audio-device " + root._resolvedAudioDevice return mkdir + wlsr @@ -390,6 +421,17 @@ QtObject { } function startRecording() { + // Refuse rather than launch a backend that is not installed: the process + // would exit instantly and the only feedback would be the generic + // "Recording Failed" toast after the fact. + if (!root.selectedBackendAvailable) { + _notifyProc.command = ["notify-send", "--app-name", "ScreenRec", + "--icon", "dialog-error", "-u", "critical", "Recording Backend Missing", + root.backendBinary + " is not installed. Pick another backend or install it."] + _notifyProc.running = false + _notifyProc.running = true + return + } root.optionsExpanded = false root._pendingGeometry = "" root._discarding = false @@ -417,20 +459,25 @@ QtObject { } } + // SIGINT the recorder directly by pid. _buildCmd() execs the backend so it + // replaces the bash wrapper, making _recProc.processId the backend itself. + // pkill by name cannot work here: Linux caps /proc/pid/comm at 15 chars, so + // "gpu-screen-recorder" (19) never matches, and pkill -f would also hit + // unrelated command lines containing the binary name. + function _signalBackend() { + if (_recProc.processId) _recProc.signal(2) // SIGINT + } + function stopRecording() { - _sigProc.command = ["bash", "-c", "pkill -INT " + root.backendBinary] - _sigProc.running = false - _sigProc.running = true + root._signalBackend() } function discardRecording() { root._discarding = true var fileToDelete = root._currentFile - // Kill wf-recorder; _recProc.onExited will see _discarding=true and skip - // the saved notification. The timer below handles delete + notify. - _sigProc.command = ["bash", "-c", "pkill -INT " + root.backendBinary] - _sigProc.running = false - _sigProc.running = true + // Interrupt the backend; _recProc.onExited will see _discarding=true and + // skip the saved notification. The timer below handles delete + notify. + root._signalBackend() _discardTimer.fileToDelete = fileToDelete _discardTimer.restart() } diff --git a/src/windows/DynamicSurface.qml b/src/windows/DynamicSurface.qml index 93e3191..888ca3d 100644 --- a/src/windows/DynamicSurface.qml +++ b/src/windows/DynamicSurface.qml @@ -66,13 +66,7 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Top WlrLayershell.namespace: "brain-shell-frame" - // Record setup deliberately does NOT take keyboard focus: it is a passive - // strip, not a text surface, and grabbing the keyboard for it meant you - // could not type into any window until you hit Record or Escape. Its - // Escape-to-cancel runs through the non-consuming "quickshell:screenrecCancel" - // global shortcut instead (see ScreenRecService), which leaves every other - // key — and every other bind — alone. - WlrLayershell.keyboardFocus: SurfaceState.activeSurface !== "none" ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: (SurfaceState.activeSurface !== "none" || (ShellState.screenRecord && !ScreenRecService.recording)) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None Region { id: fullRegion