diff --git a/.scripts/record-script b/.scripts/record-script index 332e5aa..dea4209 100755 --- a/.scripts/record-script +++ b/.scripts/record-script @@ -1,6 +1,15 @@ #!/usr/bin/env bash # https://github.com/end-4/dots-hyprland/blob/main/.config/ags/scripts/record-script.sh +[ -z "$codec" ] && codec="av1_nvenc" +[ -z "$pixel_format" ] && pixel_format="p010le" +[ -z "$frame_rate" ] && frame_rate="60" +[ -z "$codec_params" ] && codec_params=\ +"preset=p5 rc=vbr cq=18 \ +b:v=80M maxrate=120M bufsize=160M \ +color_range=tv" +[ -z "$filter_args" ] && filter_args="" + getdate() { date '+%Y-%m-%d_%H.%M.%S' } @@ -8,23 +17,42 @@ getaudiooutput() { pactl list sources | grep 'Name' | grep 'monitor' | cut -d ' ' -f2 } getactivemonitor() { - hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .name' + if [ "$XDG_CURRENT_DESKTOP" = "Hyprland" ]; then + hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .name' + elif [ "$XDG_CURRENT_DESKTOP" = "niri" ]; then + niri msg focused-output | head -n 1 | sed -n 's/.*(\(.*\)).*/\1/p' + fi } +recorder_args=( + --codec "$codec" + --pixel-format "$pixel_format" + --framerate "$frame_rate" + -f './recording_'"$(getdate)"'.mkv' +) + +for param in $codec_params; do + recorder_args+=(-p "$param") +done + +for filter in $filter_args; do + recorder_args+=(-F "$filter") +done + mkdir -p "$(xdg-user-dir VIDEOS)" cd "$(xdg-user-dir VIDEOS)" || exit if pgrep wf-recorder > /dev/null; then - notify-send "Recording Stopped" "Stopped" -a 'record-script.sh' & + notify-send "Recording Stopped" "Stopped" -a 'record-script' & pkill wf-recorder & else - notify-send "Starting recording" 'recording_'"$(getdate)"'.mp4' -a 'record-script.sh' + notify-send "Starting recording" 'recording_'"$(getdate)"'.mkv' -a 'record-script' if [[ "$1" == "--sound" ]]; then - wf-recorder --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --geometry "$(slurp)" --audio="$(getaudiooutput)" & disown + wf-recorder --geometry "$(slurp)" --audio="$(getaudiooutput)" "${recorder_args[@]}" & disown elif [[ "$1" == "--fullscreen-sound" ]]; then - wf-recorder -o $(getactivemonitor) --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --audio="$(getaudiooutput)" & disown + wf-recorder -o "$(getactivemonitor)" --audio="$(getaudiooutput)" "${recorder_args[@]}" & disown elif [[ "$1" == "--fullscreen" ]]; then - wf-recorder -o $(getactivemonitor) --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t & disown + wf-recorder -o "$(getactivemonitor)" "${recorder_args[@]}" & disown else - wf-recorder --pixel-format yuv420p -f './recording_'"$(getdate)"'.mp4' -t --geometry "$(slurp)" & disown + wf-recorder --geometry "$(slurp)" "${recorder_args[@]}" & disown fi fi \ No newline at end of file diff --git a/niri/config.kdl b/niri/config.kdl index 18bea7a..9742483 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -238,15 +238,19 @@ window-rule { open-floating true } +// Block from recording +window-rule { + match app-id="thunderbird" + + block-out-from "screen-capture" +} + +// I love round corners window-rule { geometry-corner-radius 14 clip-to-geometry true } -window-rule { - match at-startup=true app-id="Spotify" - open-on-workspace "special" -} /************************Others************************/ @@ -289,6 +293,7 @@ binds { Mod+Shift+L { spawn-sh "qs ipc call lyrics toggleBarLyrics"; } Mod+Shift+K { spawn-sh "pkill -x quickshell || quickshell"; } Mod+I { spawn-sh "qs ipc call idleInhibitor toggleInhibitor"; } + Mod+Alt+R { spawn-sh "qs ipc call recording startOrStopRecording"; } // Rofi Mod+D { spawn-sh "pkill -x rofi || rofi -show run"; } diff --git a/niri/config.kdl.template b/niri/config.kdl.template index c6fae4f..ad655c3 100644 --- a/niri/config.kdl.template +++ b/niri/config.kdl.template @@ -238,15 +238,19 @@ window-rule { open-floating true } +// Block from recording +window-rule { + match app-id="thunderbird" + + block-out-from "screen-capture" +} + +// I love round corners window-rule { geometry-corner-radius 14 clip-to-geometry true } -window-rule { - match at-startup=true app-id="Spotify" - open-on-workspace "special" -} /************************Others************************/ @@ -289,6 +293,7 @@ binds { Mod+Shift+L { spawn-sh "qs ipc call lyrics toggleBarLyrics"; } Mod+Shift+K { spawn-sh "pkill -x quickshell || quickshell"; } Mod+I { spawn-sh "qs ipc call idleInhibitor toggleInhibitor"; } + Mod+Alt+R { spawn-sh "qs ipc call recording startOrStopRecording"; } // Rofi Mod+D { spawn-sh "pkill -x rofi || rofi -show run"; } diff --git a/quickshell/Assets/Config/Settings.json b/quickshell/Assets/Config/Settings.json index d37e67a..4a364f9 100644 --- a/quickshell/Assets/Config/Settings.json +++ b/quickshell/Assets/Config/Settings.json @@ -2,7 +2,7 @@ "location": "Munich", "notifications": { "doNotDisturb": false, - "lastSeenTs": 1760477228000 + "lastSeenTs": 1760623982000 }, "primaryColor": "#89b4fa", "showLyricsBar": false diff --git a/quickshell/Constants/Icons.qml b/quickshell/Constants/Icons.qml index 9f4d56b..a648bb9 100644 --- a/quickshell/Constants/Icons.qml +++ b/quickshell/Constants/Icons.qml @@ -36,6 +36,7 @@ Singleton { readonly property string speedReset: "󰾅" readonly property string reset: "󰑙" readonly property string lines: "" + readonly property string record: "" // Tabler icons // Expose the font family name for easy access readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : "" diff --git a/quickshell/Modules/Bar/Bar.qml b/quickshell/Modules/Bar/Bar.qml index 4d29a61..9113cb4 100644 --- a/quickshell/Modules/Bar/Bar.qml +++ b/quickshell/Modules/Bar/Bar.qml @@ -172,6 +172,9 @@ Variants { width: 10 } + RecordIndicator { + } + Ip { showCountryCode: true } diff --git a/quickshell/Modules/Bar/Components/RecordIndicator.qml b/quickshell/Modules/Bar/Components/RecordIndicator.qml new file mode 100644 index 0000000..ed141b5 --- /dev/null +++ b/quickshell/Modules/Bar/Components/RecordIndicator.qml @@ -0,0 +1,107 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Constants +import qs.Services + +Item { + id: root + + property color fillColor: Colors.red + property color _actualColor: Colors.red + + visible: RecordService.isRecording + implicitHeight: parent.height + implicitWidth: layout.width + 10 + + SequentialAnimation { + id: blinkAnimation + + running: RecordService.isRecording + loops: Animation.Infinite + + ColorAnimation { + target: root + property: "_actualColor" + to: Qt.rgba(fillColor.r, fillColor.g, fillColor.b, 0) + duration: Style.animationSlowest + easing.type: Easing.InOutCubic + } + + ColorAnimation { + target: root + property: "_actualColor" + to: fillColor + duration: Style.animationSlowest + easing.type: Easing.InOutCubic + } + + } + + RowLayout { + id: layout + + anchors.top: parent.top + anchors.bottom: parent.bottom + spacing: 0 + + Text { + text: Icons.record + font.pointSize: Fonts.icon + 6 + color: _actualColor + } + + Item { + id: expander + + implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0 + implicitHeight: parent.height + clip: true + + Text { + id: ipText + + text: RecordService.recordingDisplay + font.pointSize: Fonts.medium + font.family: Fonts.primary + color: fillColor + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 5 + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + + } + + } + + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) + RecordService.startOrStop(); + + } + } + + Behavior on _actualColor { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + + } + +} diff --git a/quickshell/Modules/Panel/ControlCenterPanel.qml b/quickshell/Modules/Panel/ControlCenterPanel.qml index 8f2cfb4..af6150a 100644 --- a/quickshell/Modules/Panel/ControlCenterPanel.qml +++ b/quickshell/Modules/Panel/ControlCenterPanel.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import Quickshell.Services.Pipewire import qs.Constants import qs.Modules.Panel.Cards import qs.Noctalia diff --git a/quickshell/Services/CacheService.qml b/quickshell/Services/CacheService.qml index 8b983d7..eed3d72 100644 --- a/quickshell/Services/CacheService.qml +++ b/quickshell/Services/CacheService.qml @@ -8,6 +8,7 @@ Singleton { id: root property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/" + property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/" property var cacheFiles: ["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt"] property bool loaded: false property string locationCacheFile: cacheDir + "Location.json" @@ -19,7 +20,7 @@ Singleton { id: process running: true - command: ["sh", "-c", `mkdir -p ${cacheDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`] + command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`] onExited: (code, status) => { if (code === 0) root.loaded = true; diff --git a/quickshell/Services/IPCService.qml b/quickshell/Services/IPCService.qml index 25e2ea8..12fdad6 100644 --- a/quickshell/Services/IPCService.qml +++ b/quickshell/Services/IPCService.qml @@ -40,4 +40,12 @@ Item { target: "idleInhibitor" } + IpcHandler { + function startOrStopRecording() { + RecordService.startOrStop(); + } + + target: "recording" + } + } diff --git a/quickshell/Services/LyricsService.qml b/quickshell/Services/LyricsService.qml index 5545f14..104b1b7 100644 --- a/quickshell/Services/LyricsService.qml +++ b/quickshell/Services/LyricsService.qml @@ -74,7 +74,7 @@ Singleton { onOffsetChanged: { if (SettingsService.showLyricsBar) - SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`, 1000); + SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`); writeOffset(); } diff --git a/quickshell/Services/RecordService.qml b/quickshell/Services/RecordService.qml new file mode 100644 index 0000000..cb769b5 --- /dev/null +++ b/quickshell/Services/RecordService.qml @@ -0,0 +1,163 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Services +import qs.Utils +pragma Singleton + +Singleton { + property string recordingDir: CacheService.recordingDir + property bool isRecording: false + property bool isStopping: false + property string codec: "av1_nvenc" + property string container: "mkv" + property string pixelFormat: "p010le" + property string recordingDisplay: "" + property int framerate: 60 + property var codecParams: ["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"] + property var filterArgs: [] + + function getFilename() { + var d = new Date(); + var year = d.getFullYear(); + var month = ("0" + (d.getMonth() + 1)).slice(-2); + var day = ("0" + d.getDate()).slice(-2); + var hours = ("0" + d.getHours()).slice(-2); + var minutes = ("0" + d.getMinutes()).slice(-2); + var seconds = ("0" + d.getSeconds()).slice(-2); + return "recording_" + year + "-" + month + "-" + day + "_" + hours + "." + minutes + "." + seconds + "." + container; + } + + function getAudioSink() { + return AudioService.sink ? AudioService.sink.name + '.monitor' : null; // this works on my machine :) + } + + function getVideoSource(callback) { + if (niriFocusedOutputProcess.running) { + Logger.warn("RecordService", "Already fetching focused output, returning null."); + callback(null); + } + niriFocusedOutputProcess.onGetName = callback; + niriFocusedOutputProcess.running = true; + } + + function startOrStop() { + if (isRecording) + stop(); + else + start(); + } + + function stop() { + if (!isRecording) { + Logger.warn("RecordService", "Not currently recording, cannot stop."); + return ; + } + if (isStopping) { + Logger.warn("RecordService", "Already stopping, please wait."); + return ; + } + isStopping = true; + recordProcess.signal(15); + } + + function start() { + if (isRecording || isStopping) { + Logger.warn("RecordService", "Already recording, cannot start."); + return ; + } + isRecording = true; + getVideoSource((source) => { + if (!source) { + SendNotification.show("Recording failed", "Could not determine which display to record from."); + return ; + } + recordingDisplay = source; + const audioSink = getAudioSink(); + if (!audioSink) { + SendNotification.show("Recording failed", "No audio sink available to record from."); + return ; + } + recordProcess.filePath = recordingDir + getFilename(); + recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath]; + for (const param of codecParams) { + recordProcess.command.push("-p"); + recordProcess.command.push(param); + } + for (const filter of filterArgs) { + recordProcess.command.push("-F"); + recordProcess.command.push(filter); + } + Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" ")); + recordProcess.onErrorExit = function() { + SendNotification.show("Recording failed", "An error occurred while trying to record the screen."); + }; + recordProcess.onNormalExit = function() { + SendNotification.show("Recording stopped", recordProcess.filePath); + }; + recordProcess.running = true; + SendNotification.show("Recording started", "Recording to " + recordProcess.filePath); + }); + } + + Process { + id: recordProcess + + property string filePath: "" + property var onNormalExit: null + property var onErrorExit: null + + running: false + onExited: function(exitCode, exitStatus) { + if (exitCode === 0) { + Logger.log("RecordService", "Recording stopped successfully."); + if (onNormalExit) { + onNormalExit(); + onNormalExit = null; + } + } else { + Logger.error("RecordService", "Recording process exited with error code: " + exitCode); + if (onErrorExit) { + onErrorExit(); + onErrorExit = null; + } + } + isRecording = false; + isStopping = false; + recordingDisplay = ""; + } + } + + Process { + id: niriFocusedOutputProcess + + property var onGetName: null + + running: false + command: ["niri", "msg", "focused-output"] + onExited: function(exitCode, exitStatus) { + if (exitCode !== 0) { + Logger.error("RecordService", "Failed to get focused output via niri."); + if (niriFocusedOutputProcess.onGetName) { + niriFocusedOutputProcess.onGetName(null); + niriFocusedOutputProcess.onGetName = null; + } + } + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: (data) => { + if (niriFocusedOutputProcess.onGetName) { + const parts = data.split(' '); + const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null; + name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found."); + niriFocusedOutputProcess.onGetName(name); + niriFocusedOutputProcess.onGetName = null; + } + } + } + + } + +} diff --git a/quickshell/Services/SendNotification.qml b/quickshell/Services/SendNotification.qml index e00ccaa..18c1afa 100644 --- a/quickshell/Services/SendNotification.qml +++ b/quickshell/Services/SendNotification.qml @@ -6,11 +6,11 @@ pragma Singleton Singleton { id: root - function show(title, message, timeout = 5000, icon = "", urgency = "normal") { + function show(title, message, icon = "", urgency = "normal") { if (icon) - Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, "-t", timeout.toString(), title, message, "-a", "quickshell"]); + Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, title, message, "-a", "quickshell"]); else - Quickshell.execDetached(["notify-send", "-u", urgency, "-t", timeout.toString(), title, message, "-a", "quickshell"]); + Quickshell.execDetached(["notify-send", "-u", urgency, title, message, "-a", "quickshell"]); } }