diff --git a/settings/quickshell.nix b/settings/quickshell.nix index a345eae..4207010 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -106,6 +106,29 @@ in pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null) [ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw" ''; + # Screen recording, driven by the quick-settings card. The shell execs + # into wf-recorder so the card's SIGINT lands on the recorder itself — + # wf-recorder finalises the mp4 on SIGINT, killing a wrapper instead + # would leave a truncated file. The path is echoed before the exec so + # the card can offer the result. Audio means the default sink's monitor + # (desktop sound), not the microphone. + screenRecScript = pkgs.writeShellScript "screen-rec" '' + out="$HOME/Videos/rec-$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S).mp4" + ${pkgs.coreutils}/bin/mkdir -p "$HOME/Videos" + audio="" + if [ "$2" = audio ]; then + sink=$(${pkgs.wireplumber}/bin/wpctl inspect @DEFAULT_AUDIO_SINK@ \ + | ${pkgs.gnused}/bin/sed -n 's/.*node\.name = "\(.*\)".*/\1/p') + [ -n "$sink" ] && audio="--audio=$sink.monitor" + fi + if [ "$1" = region ]; then + geom=$(${pkgs.slurp}/bin/slurp) || exit 1 + echo "$out" + exec ${pkgs.wf-recorder}/bin/wf-recorder $audio -g "$geom" -f "$out" + fi + echo "$out" + exec ${pkgs.wf-recorder}/bin/wf-recorder $audio -f "$out" + ''; nmcli = "${pkgs.networkmanager}/bin/nmcli"; # Follow stylix's sans choice so a font swap propagates to the bar sansFont = osConfig.stylix.fonts.sansSerif.name; @@ -1393,6 +1416,7 @@ in readonly property string askFetch: "${askFetchScript}" readonly property string wxFetch: "${wxFetchScript}" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" + readonly property string screenRec: "${screenRecScript}" readonly property string themeApply: "${themeApply}" } ''; @@ -4690,7 +4714,11 @@ in // centerIn, not verticalCenter: the cell used to be // qsIcon.width so horizontal centring was implicit. anchors.centerIn: parent - text: "moon" + // Doubles as the recording indicator: with the + // dropdown shut, this dot is the only sign that + // wf-recorder is still running. + text: recCard.recording ? "circle-dot" : "moon" + color: recCard.recording ? Theme.err : Theme.base05 font.pixelSize: 18 } @@ -6223,6 +6251,163 @@ in anchors.centerIn: parent width: 240 spacing: 8 + + // Screen recorder. wf-recorder is held as a live + // Process (not execDetached) so Stop can SIGINT it; + // slurp's region pick happens inside the script, + // before the shell execs into the recorder. + Card { + id: recCard + width: parent.width + readonly property bool recording: recProc.running + property bool audio: false + property int elapsed: 0 + property string lastFile: "" + + function start(mode) { + lastFile = ""; + elapsed = 0; + recProc.command = [Commands.screenRec, mode, recCard.audio ? "audio" : "silent"]; + recProc.running = true; + // The panel would otherwise be in the shot — + // and slurp needs the screen to itself. + qsDropdown.animateClose(); + } + + Process { + id: recProc + stdout: StdioCollector { + // First line is the path the script chose; + // wf-recorder's own chatter goes to stderr. + onStreamFinished: recCard.lastFile = text.trim().split("\n")[0] + } + } + + Timer { + interval: 1000 + repeat: true + running: recCard.recording + onTriggered: recCard.elapsed++ + } + + Item { + width: parent.width + height: 20 + + Row { + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "video" + color: recCard.recording ? Theme.err : Theme.base05 + font.pixelSize: 15 + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: recCard.recording + ? Math.floor(recCard.elapsed / 60) + ":" + ("0" + (recCard.elapsed % 60)).slice(-2) + : "Screen Recording" + font.weight: Font.Medium + } + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + visible: !recCard.recording + SText { + anchors.verticalCenter: parent.verticalCenter + text: "Audio" + color: Theme.base04 + font.pixelSize: 10 + } + ToggleSwitch { + anchors.verticalCenter: parent.verticalCenter + on: recCard.audio + onToggled: (v) => recCard.audio = v + } + } + } + + Row { + width: parent.width + spacing: 6 + visible: !recCard.recording + + Repeater { + model: [ + { key: "region", label: "Region" }, + { key: "screen", label: "Full screen" } + ] + + Rectangle { + required property var modelData + width: (parent.width - 6) / 2 + height: 26 + radius: Theme.radiusSmall + color: recBtn.containsMouse ? Theme.base02 : Theme.base02t + Behavior on color { ColorAnimation { duration: Theme.animFade } } + border.color: Theme.base03 + border.width: 1 + + SText { + anchors.centerIn: parent + text: modelData.label + font.pixelSize: 11 + } + + MouseArea { + id: recBtn + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: recCard.start(modelData.key) + } + } + } + } + + Rectangle { + visible: recCard.recording + width: parent.width + height: 26 + radius: Theme.radiusSmall + color: Theme.err + + SText { + anchors.centerIn: parent + text: "Stop" + font.pixelSize: 11 + color: Theme.base00 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // SIGINT, not kill — wf-recorder writes the + // container trailer on it. + onClicked: recProc.signal(2) + } + } + + SText { + visible: !recCard.recording && recCard.lastFile !== "" + width: parent.width + elide: Text.ElideMiddle + text: "Saved " + recCard.lastFile.split("/").pop() + color: Theme.base04 + font.pixelSize: 10 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Quickshell.execDetached([Commands.xdgOpen, recCard.lastFile]) + } + } + } + NightlightCard { width: parent.width } } }