Feat: Added a configuration option to define the save location of recordings.

* Add file picker for saving screen recordings

Implemented a file picker using Zenity to allow users to choose the save location for their screen recordings after stopping.

Replaced the hardcoded save path with dynamic user input.

Improved the notification system to inform users when recordings are saved or discarded.

* Refactored RecordingButton to fetch the latest recording path dynamically.

Removed static path references, ensuring the updated path from Hyprpanel config is always used.

* Update screen_record.sh

Added comment why use "sleep 1" at line 80

* Update module.nix

Updated nix module.

* Expand ~ in output directory, set default path, and add validation

- Properly expand `~` to `$HOME` in the output directory path.
- Set default recording directory to `$HOME/Videos` if none is provided.
- Validate that the output directory exists before starting a recording.

* Update scripts/screen_record.sh

Co-authored-by: Chase Taylor <11805686+dotaxis@users.noreply.github.com>

* Update scripts/screen_record.sh

Co-authored-by: Chase Taylor <11805686+dotaxis@users.noreply.github.com>

* Code Quality Check.

* Update RecordingButton.tsx

Removed debug logs as well.

* Update src/components/menus/dashboard/shortcuts/buttons/RecordingButton.tsx

Co-authored-by: Jas Singh <jaskiratpal.singh@outlook.com>

* updated RecordingButton.tsx && helper.tsx

Fixed the issues pointed by @Jas-SinghFSU

* Update RecordingButton.tsx

Fixed few linter errors.

---------

Co-authored-by: Chase Taylor <11805686+dotaxis@users.noreply.github.com>
Co-authored-by: Jas Singh <jaskiratpal.singh@outlook.com>
This commit is contained in:
Siddharth Jain
2025-03-25 09:22:17 +05:30
committed by GitHub
parent c57d512ced
commit dba7ac64c6
7 changed files with 163 additions and 90 deletions

View File

@@ -394,6 +394,7 @@ in
menus.dashboard.powermenu.reboot = mkStrOption "systemctl reboot"; menus.dashboard.powermenu.reboot = mkStrOption "systemctl reboot";
menus.dashboard.powermenu.shutdown = mkStrOption "systemctl poweroff"; menus.dashboard.powermenu.shutdown = mkStrOption "systemctl poweroff";
menus.dashboard.powermenu.sleep = mkStrOption "systemctl suspend"; menus.dashboard.powermenu.sleep = mkStrOption "systemctl suspend";
menus.dashboard.recording.path = mkStrOption "$HOME/Videos/Screencasts"
menus.dashboard.shortcuts.enabled = mkBoolOption true; menus.dashboard.shortcuts.enabled = mkBoolOption true;
menus.dashboard.shortcuts.left.shortcut1.command = mkStrOption "microsoft-edge-stable"; menus.dashboard.shortcuts.left.shortcut1.command = mkStrOption "microsoft-edge-stable";
menus.dashboard.shortcuts.left.shortcut1.icon = mkStrOption "󰇩"; menus.dashboard.shortcuts.left.shortcut1.icon = mkStrOption "󰇩";

7
package-lock.json generated
View File

@@ -24,8 +24,13 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
}, },
"../../../../../usr/share/astal/gjs": {
"name": "astal",
"license": "LGPL-2.1"
},
"../../../../usr/share/astal/gjs": { "../../../../usr/share/astal/gjs": {
"name": "astal", "name": "astal",
"extraneous": true,
"license": "LGPL-2.1" "license": "LGPL-2.1"
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
@@ -601,7 +606,7 @@
} }
}, },
"node_modules/astal": { "node_modules/astal": {
"resolved": "../../../../usr/share/astal/gjs", "resolved": "../../../../../usr/share/astal/gjs",
"link": true "link": true
}, },
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {

View File

@@ -1,18 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Requires wf-recorder: https://github.com/ammen99/wf-recorder # Requires wf-recorder: https://github.com/ammen99/wf-recorder
outputDir="$HOME/Videos/Screencasts" # Get the default audio sink
defaultSink=$(pactl get-default-sink) defaultSink=$(pactl get-default-sink)
WF_RECORDER_OPTS="--audio=$defaultSink.monitor -c libx264rgb" WF_RECORDER_OPTS="--audio=$defaultSink.monitor -c libx264rgb"
outputFile=""
outputDir=""
# Function to check if recording is active
checkRecording() { checkRecording() {
if pgrep -f "wf-recorder" >/dev/null; then pgrep -f "wf-recorder" >/dev/null
return 0
else
return 1
fi
} }
# Function to start screen recording
startRecording() { startRecording() {
if checkRecording; then if checkRecording; then
echo "A recording is already in progress." echo "A recording is already in progress."
@@ -21,66 +21,113 @@ startRecording() {
target="$2" target="$2"
outputFile="recording_$(date +%Y-%m-%d_%H-%M-%S)"
outputPath="$outputDir/${outputFile}.mp4"
mkdir -p "$outputDir"
if [ "$target" == "screen" ]; then if [ "$target" == "screen" ]; then
monitor_info=$(hyprctl -j monitors | jq -r ".[] | select(.name == \"$3\")") monitor_name="$3"
w=$(echo $monitor_info | jq -r '.width') outputDir="$4"
h=$(echo $monitor_info | jq -r '.height') elif [ "$target" == "region" ]; then
scale=$(echo $monitor_info | jq -r '.scale') outputDir="$3"
scaled_width=$(awk "BEGIN {print $w / $scale}") else
scaled_height=$(awk "BEGIN {print $h / $scale}") echo "Usage: $0 start {screen <monitor_name> | region} <output_directory>"
x=$(echo $monitor_info | jq -r '.x') exit 1
y=$(echo $monitor_info | jq -r '.y') fi
# Set a default output directory if not provided
outputDir="${outputDir:-$HOME/Videos}"
# Expand ~ to $HOME if present in outputDir
outputDir="${outputDir/#\~/$HOME}"
# Ensure output directory exists
if [ ! -d "$outputDir" ]; then
echo "Error: Output directory '$outputDir' does not exist."
exit 1
fi
# Generate output filename and path
outputFile="recording_$(date +%Y-%m-%d_%H-%M-%S).mp4"
outputPath="$outputDir/$outputFile"
echo "Target: $target"
echo "Monitor: ${monitor_name:-N/A}"
echo "Output dir: $outputDir"
echo "Output file: $outputPath"
# Start screen recording
if [ "$target" == "screen" ]; then
if [ -z "$monitor_name" ]; then
echo "Error: Monitor name is required for screen recording."
exit 1
fi
monitor_info=$(hyprctl -j monitors | jq -r ".[] | select(.name == \"$monitor_name\")")
if [ -z "$monitor_info" ]; then
echo "Error: Monitor '$monitor_name' not found."
exit 1
fi
w=$(echo "$monitor_info" | jq -r '.width')
h=$(echo "$monitor_info" | jq -r '.height')
scale=$(echo "$monitor_info" | jq -r '.scale')
scaled_width=$(awk "BEGIN { print $w / $scale }")
scaled_height=$(awk "BEGIN { print $h / $scale }")
x=$(echo "$monitor_info" | jq -r '.x')
y=$(echo "$monitor_info" | jq -r '.y')
wf-recorder $WF_RECORDER_OPTS --geometry "${x},${y} ${scaled_width}x${scaled_height}" --file "$outputPath" & wf-recorder $WF_RECORDER_OPTS --geometry "${x},${y} ${scaled_width}x${scaled_height}" --file "$outputPath" &
elif [ "$target" == "region" ]; then elif [ "$target" == "region" ]; then
wf-recorder $WF_RECORDER_OPTS --geometry "$(slurp)" --file "$outputPath" & wf-recorder $WF_RECORDER_OPTS --geometry "$(slurp)" --file "$outputPath" &
else
echo "Usage: $0 start {region|screen [screen_name]}"
exit 1
fi fi
disown "$(jobs -p | tail -n 1)"
echo "Recording started. Output will be saved to $outputPath" disown "$(jobs -p | tail -n 1)"
echo "Recording started. Saving to $outputPath"
echo "$outputPath" > /tmp/last_recording_path
} }
# Function to stop screen recording
stopRecording() { stopRecording() {
if ! checkRecording; then if ! checkRecording; then
echo "No recording is in progress." echo "No recording in progress."
exit 1 exit 1
fi fi
pkill -SIGINT -f wf-recorder pkill -SIGINT -f wf-recorder
sleep 1 # Allow wf-recorder time to terminate before proceeding
recentFile=$(ls -t "$outputDir"/recording_*.mp4 | head -n 1) outputPath=$(cat /tmp/last_recording_path 2>/dev/null)
notify-send "Recording stopped" "Your recording has been saved." \ if [ -z "$outputPath" ] || [ ! -f "$outputPath" ]; then
notify-send "Recording stopped" "No recent recording found." \
-i video-x-generic \
-a "Screen Recorder" \
-t 10000
exit 1
fi
notify-send "Recording stopped" "Saved to: $outputPath" \
-i video-x-generic \ -i video-x-generic \
-a "Screen Recorder" \ -a "Screen Recorder" \
-t 10000 \ -t 10000 \
-u normal \ --action="scriptAction:-xdg-open $(dirname "$outputPath")=Open Directory" \
--action="scriptAction:-xdg-open $outputDir=Directory" \ --action="scriptAction:-xdg-open $outputPath=Play"
--action="scriptAction:-xdg-open $recentFile=Play"
} }
# Handle script arguments
case "$1" in case "$1" in
start) start)
startRecording "$@" startRecording "$@"
;; ;;
stop) stop)
stopRecording stopRecording
;; ;;
status) status)
if checkRecording; then if checkRecording; then
echo "recording" echo "recording"
else else
echo "not recording" echo "not recording"
fi fi
;; ;;
*) *)
echo "Usage: $0 {start [screen screen_name|region]|stop|status}" echo "Usage: $0 {start [screen <monitor_name> | region] <output_directory> | stop | status}"
exit 1 exit 1
;; ;;
esac esac

View File

@@ -1,8 +1,8 @@
import { bind, execAsync, Variable } from 'astal'; import { bind, Variable } from 'astal';
import { App, Gdk, Gtk } from 'astal/gtk3'; import { App, Gdk, Gtk } from 'astal/gtk3';
import Menu from 'src/components/shared/Menu'; import Menu from 'src/components/shared/Menu';
import MenuItem from 'src/components/shared/MenuItem'; import MenuItem from 'src/components/shared/MenuItem';
import { isRecording } from '../helpers'; import { isRecording, getRecordingPath, executeCommand } from '../helpers';
import AstalHyprland from 'gi://AstalHyprland?version=0.1'; import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprlandService = AstalHyprland.get_default(); const hyprlandService = AstalHyprland.get_default();
@@ -10,44 +10,41 @@ const hyprlandService = AstalHyprland.get_default();
const MonitorListDropdown = (): JSX.Element => { const MonitorListDropdown = (): JSX.Element => {
const monitorList: Variable<AstalHyprland.Monitor[]> = Variable([]); const monitorList: Variable<AstalHyprland.Monitor[]> = Variable([]);
const monitorBinding = Variable.derive([bind(hyprlandService, 'monitors')], () => const monitorBinding = Variable.derive([bind(hyprlandService, 'monitors')], () => {
monitorList.set(hyprlandService.get_monitors()), monitorList.set(hyprlandService.get_monitors());
); });
return ( return (
<Menu className={'dropdown recording'} halign={Gtk.Align.FILL} onDestroy={() => monitorBinding.drop()} hexpand> <Menu className={'dropdown recording'} halign={Gtk.Align.FILL} onDestroy={() => monitorBinding.drop()} hexpand>
{bind(monitorList).as((monitors) => { {bind(monitorList).as((monitors) =>
return monitors.map((monitor) => ( monitors.map((monitor) => {
const sanitizedPath = getRecordingPath().replace(/"/g, '\\"');
return (
<MenuItem <MenuItem
label={`Display ${monitor.name}`} label={`Display ${monitor.name}`}
onButtonPressEvent={(_, event) => { onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1]; if (event.get_button()[1] !== Gdk.BUTTON_PRIMARY) return;
if (buttonClicked !== Gdk.BUTTON_PRIMARY) {
return;
}
App.get_window('dashboardmenu')?.set_visible(false); App.get_window('dashboardmenu')?.set_visible(false);
execAsync(`${SRC_DIR}/scripts/screen_record.sh start screen ${monitor.name}`).catch((err) => const command = `${SRC_DIR}/scripts/screen_record.sh start screen "${monitor.name}" "${sanitizedPath}"`;
console.error(err), executeCommand(command);
);
}} }}
/> />
)); );
})} }),
)}
<MenuItem <MenuItem
label="Region" label="Region"
onButtonPressEvent={(_, event) => { onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1]; if (event.get_button()[1] !== Gdk.BUTTON_PRIMARY) return;
if (buttonClicked !== Gdk.BUTTON_PRIMARY) {
return;
}
App.get_window('dashboardmenu')?.set_visible(false); App.get_window('dashboardmenu')?.set_visible(false);
execAsync(`${SRC_DIR}/scripts/screen_record.sh start region`).catch((err) => console.error(err)); const sanitizedPath = getRecordingPath().replace(/"/g, '\\"');
const command = `${SRC_DIR}/scripts/screen_record.sh start region "${sanitizedPath}"`;
executeCommand(command);
}} }}
/> />
</Menu> </Menu>
@@ -58,7 +55,7 @@ export const RecordingButton = (): JSX.Element => {
return ( return (
<button <button
className={`dashboard-button record ${isRecording.get() ? 'active' : ''}`} className={`dashboard-button record ${isRecording.get() ? 'active' : ''}`}
tooltipText={'Record Screen'} tooltipText="Record Screen"
vexpand vexpand
onButtonPressEvent={(_, event) => { onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1]; const buttonClicked = event.get_button()[1];
@@ -67,9 +64,12 @@ export const RecordingButton = (): JSX.Element => {
return; return;
} }
const sanitizedPath = getRecordingPath().replace(/"/g, '\\"');
if (isRecording.get() === true) { if (isRecording.get() === true) {
App.get_window('dashboardmenu')?.set_visible(false); App.get_window('dashboardmenu')?.set_visible(false);
return execAsync(`${SRC_DIR}/scripts/screen_record.sh stop`).catch((err) => console.error(err)); const command = `${SRC_DIR}/scripts/screen_record.sh stop "${sanitizedPath}"`;
executeCommand(command);
} else { } else {
const monitorDropdownList = MonitorListDropdown() as Gtk.Menu; const monitorDropdownList = MonitorListDropdown() as Gtk.Menu;
monitorDropdownList.popup_at_pointer(event); monitorDropdownList.popup_at_pointer(event);

View File

@@ -6,6 +6,27 @@ import options from 'src/options';
const { left } = options.menus.dashboard.shortcuts; const { left } = options.menus.dashboard.shortcuts;
/**
* Retrieves the latest recording path from options.
*
* @returns The configured recording path.
*/
export const getRecordingPath = (): string => options.menus.dashboard.recording.path.get();
/**
* Executes a shell command asynchronously with proper error handling.
*
* @param command The command to execute.
*/
export const executeCommand = async (command: string): Promise<void> => {
try {
await execAsync(`/bin/bash -c '${command}'`);
} catch (err) {
console.error('Command failed:', command);
console.error('Error:', err);
}
};
/** /**
* Handles the recorder status based on the command output. * Handles the recorder status based on the command output.
* *
@@ -16,10 +37,7 @@ const { left } = options.menus.dashboard.shortcuts;
* @returns True if the command output is 'recording', false otherwise. * @returns True if the command output is 'recording', false otherwise.
*/ */
export const handleRecorder = (commandOutput: string): boolean => { export const handleRecorder = (commandOutput: string): boolean => {
if (commandOutput === 'recording') { return commandOutput === 'recording';
return true;
}
return false;
}; };
/** /**
@@ -35,9 +53,7 @@ export const handleClick = (action: string, tOut: number = 0): void => {
timeout(tOut, () => { timeout(tOut, () => {
execAsync(`bash -c "${action}"`) execAsync(`bash -c "${action}"`)
.then((res) => { .then((res) => res)
return res;
})
.catch((err) => console.error(err)); .catch((err) => console.error(err));
}); });
}; };
@@ -45,10 +61,7 @@ export const handleClick = (action: string, tOut: number = 0): void => {
/** /**
* Checks if a shortcut has a command. * Checks if a shortcut has a command.
* *
* This function determines if the provided shortcut has a command defined.
*
* @param shortCut The shortcut to check. * @param shortCut The shortcut to check.
*
* @returns True if the shortcut has a command, false otherwise. * @returns True if the shortcut has a command, false otherwise.
*/ */
export const hasCommand = (shortCut: ShortcutVariable): boolean => { export const hasCommand = (shortCut: ShortcutVariable): boolean => {
@@ -58,7 +71,7 @@ export const hasCommand = (shortCut: ShortcutVariable): boolean => {
/** /**
* A variable indicating whether the left card is hidden. * A variable indicating whether the left card is hidden.
* *
* This variable is set to true if none of the left shortcuts have commands defined. * This is set to true if none of the left shortcuts have commands.
*/ */
export const leftCardHidden = Variable( export const leftCardHidden = Variable(
!( !(
@@ -82,7 +95,8 @@ export const isRecording = Variable(false);
/** /**
* A poller for checking the recording status. * A poller for checking the recording status.
* *
* This poller periodically checks the recording status by executing a bash command and updates the `isRecording` variable. * This poller periodically checks the recording status by executing a bash command
* and updates the `isRecording` variable accordingly.
*/ */
export const recordingPoller = new BashPoller<boolean, []>( export const recordingPoller = new BashPoller<boolean, []>(
isRecording, isRecording,

View File

@@ -48,6 +48,9 @@ export const DashboardMenuSettings = (): JSX.Element => {
<Option opt={options.menus.dashboard.powermenu.logout} title="Logout Command" type="string" /> <Option opt={options.menus.dashboard.powermenu.logout} title="Logout Command" type="string" />
<Option opt={options.menus.dashboard.powermenu.sleep} title="Sleep Command" type="string" /> <Option opt={options.menus.dashboard.powermenu.sleep} title="Sleep Command" type="string" />
<Header title="Recording" />
<Option opt={options.menus.dashboard.recording.path} title="Recording Path" type="string" />
<Header title="Controls" /> <Header title="Controls" />
<Option opt={options.menus.dashboard.controls.enabled} title="Enabled" type="boolean" /> <Option opt={options.menus.dashboard.controls.enabled} title="Enabled" type="boolean" />

View File

@@ -1280,6 +1280,9 @@ const options = mkOptions({
interval: opt(2000), interval: opt(2000),
enable_gpu: opt(false), enable_gpu: opt(false),
}, },
recording: {
path: opt('$HOME/Videos/Screencasts'),
},
controls: { controls: {
enabled: opt(true), enabled: opt(true),
}, },