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 465b28e..be11481 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,36 @@ 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 + labelSize: 10 + available: ScreenRecService.backendAvailable[modelData.id] !== false + on: PrefsService.screenrecBackend === modelData.id + onToggled: PrefsService.screenrecBackend = modelData.id + } + } + } + } } component TglBtn: Rectangle { @@ -136,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 @@ -199,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/PrefsService.qml b/src/services/PrefsService.qml index 0e5af75..ed437ad 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 @@ -193,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() @@ -243,6 +246,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..5b461ca 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 // focusedMonitor for fullscreen capture import "../" // ScreenRecService — owns all screen recording state. @@ -45,6 +46,56 @@ 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" + } + + // 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" }) @@ -260,10 +311,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 +329,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 +348,45 @@ 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, "'\\''") + // 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 + "'" + 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 " + (monitor !== "" ? "'" + monitor + "'" : "screen") + } + // 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 + } + + if (PrefsService.screenrecBackend === "wlsr") { + 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 + } + + var cmd = mkdir + "wf-recorder -c libx264" + " -x yuv420p" + " -r " + fps + // Configurable FPS @@ -301,15 +397,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 } @@ -325,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 @@ -352,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 wf-recorder"] - _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 wf-recorder"] - _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() }