Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ _v0.2.0 basically got the TeamCherry treatment: meaning we rebuilt the entire ar
<details>
<summary><b>Screen Capture & Utilities</b></summary>

- **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
Expand Down
5 changes: 5 additions & 0 deletions dots-extra/install-arch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions dots-extra/install-nix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions dots-extra/validate-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
53 changes: 47 additions & 6 deletions src/popups/ScreenRecOptionsPopup.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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} }
}
Expand Down
4 changes: 4 additions & 0 deletions src/services/PrefsService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
146 changes: 129 additions & 17 deletions src/services/ScreenRecService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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" })
Expand Down Expand Up @@ -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'" +
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down