diff options
| author | Trung Lê <8@tle.id.au> | 2026-02-21 23:40:05 +1100 |
|---|---|---|
| committer | Trung Lê <8@tle.id.au> | 2026-02-21 23:47:49 +1100 |
| commit | 3eed1b64af72dcb4bcb8ae28d45856767d4acff1 (patch) | |
| tree | 0aeef768169d69c4ed24c3cc03398c63aef3ff59 /src | |
Initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/global_shortcuts.c | 336 | ||||
| -rw-r--r-- | src/global_shortcuts.h | 41 | ||||
| -rw-r--r-- | src/gsr-config-page.c | 1403 | ||||
| -rw-r--r-- | src/gsr-config-page.h | 101 | ||||
| -rw-r--r-- | src/gsr-config.c | 591 | ||||
| -rw-r--r-- | src/gsr-config.h | 175 | ||||
| -rw-r--r-- | src/gsr-hotkeys.c | 435 | ||||
| -rw-r--r-- | src/gsr-hotkeys.h | 52 | ||||
| -rw-r--r-- | src/gsr-info.c | 393 | ||||
| -rw-r--r-- | src/gsr-info.h | 111 | ||||
| -rw-r--r-- | src/gsr-record-page.c | 747 | ||||
| -rw-r--r-- | src/gsr-record-page.h | 44 | ||||
| -rw-r--r-- | src/gsr-replay-page.c | 726 | ||||
| -rw-r--r-- | src/gsr-replay-page.h | 45 | ||||
| -rw-r--r-- | src/gsr-shortcut-accel-dialog.c | 372 | ||||
| -rw-r--r-- | src/gsr-shortcut-accel-dialog.h | 35 | ||||
| -rw-r--r-- | src/gsr-stream-page.c | 691 | ||||
| -rw-r--r-- | src/gsr-stream-page.h | 39 | ||||
| -rw-r--r-- | src/gsr-window.c | 1214 | ||||
| -rw-r--r-- | src/gsr-window.h | 113 | ||||
| -rw-r--r-- | src/gsr-x11-hotkeys.c | 233 | ||||
| -rw-r--r-- | src/gsr-x11-hotkeys.h | 46 | ||||
| -rw-r--r-- | src/gsr-x11-window-picker.c | 329 | ||||
| -rw-r--r-- | src/gsr-x11-window-picker.h | 56 | ||||
| -rw-r--r-- | src/main.c | 138 |
25 files changed, 8466 insertions, 0 deletions
diff --git a/src/global_shortcuts.c b/src/global_shortcuts.c new file mode 100644 index 0000000..a7540f0 --- /dev/null +++ b/src/global_shortcuts.c @@ -0,0 +1,336 @@ +#include "global_shortcuts.h" +#include <stdio.h> +#include <errno.h> +#include <string.h> +#include <sys/random.h> +#include <gio/gio.h> + +/* TODO: Remove G_DBUS_CALL_FLAGS_NO_AUTO_START and G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START? also in gpu screen recorder equivalent */ +/* TODO: More error handling and clean up resources after done */ +/* TODO: Use GArray instead of GVariant where possible */ + +static bool generate_random_characters(char *buffer, int buffer_size, const char *alphabet, size_t alphabet_size) { + /* TODO: Use other functions on other platforms than linux */ + if(getrandom(buffer, buffer_size, 0) < buffer_size) { + fprintf(stderr, "gsr error: generate_random_characters: failed to get random bytes, error: %s\n", strerror(errno)); + return false; + } + + for(int i = 0; i < buffer_size; ++i) { + unsigned char c = *(unsigned char*)&buffer[i]; + buffer[i] = alphabet[c % alphabet_size]; + } + + return true; +} + +static void gsr_dbus_portal_get_unique_handle_token(gsr_global_shortcuts *self, char *buffer, int size) { + snprintf(buffer, size, "gpu_screen_recorder_adwaita_handle_%s_%u", self->random_str, self->handle_counter++); +} + +/* Assumes shortcuts is an array */ +static void handle_shortcuts_data(GVariant *shortcuts, gsr_shortcut_callback callback, void *userdata) { + for(guint i = 0; i < g_variant_n_children(shortcuts); ++i) { + gchar *shortcut_id = NULL; + GVariant *shortcut_values = NULL; + g_variant_get_child(shortcuts, i, "(s@a{sv})", &shortcut_id, &shortcut_values); + + if(!shortcut_id || !shortcut_values) + continue; + + // gchar *description = NULL; + // g_variant_lookup(shortcut_values, "description", "s", &description); + + gchar *trigger_description = NULL; + g_variant_lookup(shortcut_values, "trigger_description", "s", &trigger_description); + + gsr_shortcut shortcut; + shortcut.id = shortcut_id; + shortcut.trigger_description = trigger_description ? trigger_description : ""; + callback(shortcut, userdata); + } +} + +typedef struct { + gsr_global_shortcuts *self; + gsr_shortcut_callback callback; + void *userdata; +} signal_list_bind_userdata; + +static void dbus_signal_list_bind(GDBusProxy *proxy, gchar *sender_name, gchar *signal_name, GVariant *parameters, signal_list_bind_userdata *userdata) { + (void)proxy; + (void)sender_name; + if(g_strcmp0(signal_name, "Response") != 0) + goto done; + + guint32 response = 0; + GVariant *results = NULL; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + + if(response != 0 || !results) + goto done; + + GVariant *shortcuts = g_variant_lookup_value(results, "shortcuts", G_VARIANT_TYPE("a(sa{sv})")); + if(!shortcuts) + goto done; + + handle_shortcuts_data(shortcuts, userdata->callback, userdata->userdata); + + done: + free(userdata); +} + +typedef struct { + gsr_global_shortcuts *self; + gsr_deactivated_callback deactivated_callback; + gsr_shortcut_callback shortcut_changed_callback; + void *userdata; +} signal_userdata; + +static void signal_callback(GDBusConnection *connection, + const gchar *sender_name, + const gchar *object_path, + const gchar *interface_name, + const gchar *signal_name, + GVariant *parameters, + gpointer userdata) +{ + (void)connection; + (void)sender_name; + (void)object_path; + (void)interface_name; + (void)signal_name; + (void)parameters; + signal_userdata *cu = userdata; + + /* Button released */ + if(strcmp(signal_name, "Deactivated") == 0) { + gchar *session_handle = NULL; + gchar *shortcut_id = NULL; + gchar *timestamp = NULL; + GVariant *options = NULL; + g_variant_get(parameters, "(ost@a{sv})", &session_handle, &shortcut_id, ×tamp, &options); + + if(session_handle && shortcut_id && strcmp(session_handle, cu->self->session_handle) == 0) + cu->deactivated_callback(shortcut_id, cu->userdata); + } else if(strcmp(signal_name, "ShortcutsChanged") == 0) { + gchar *session_handle = NULL; + GVariant *shortcuts = NULL; + g_variant_get(parameters, "(o@a(sa{sv}))", &session_handle, &shortcuts); + + if(session_handle && shortcuts && strcmp(session_handle, cu->self->session_handle) == 0) + handle_shortcuts_data(shortcuts, cu->shortcut_changed_callback, cu->userdata); + } +} + +typedef struct { + gsr_global_shortcuts *self; + gsr_init_callback callback; + void *userdata; +} signal_create_session_userdata; + +static void dbus_signal_create_session(GDBusProxy *proxy, gchar *sender_name, gchar *signal_name, GVariant *parameters, signal_create_session_userdata *cu) { + (void)proxy; + (void)sender_name; + if(g_strcmp0(signal_name, "Response") != 0) + goto done; + + guint32 response = 0; + GVariant *results = NULL; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + + if(response != 0 || !results) { + cu->callback(false, cu->userdata); + goto done; + } + + gchar *session_handle = NULL; + if(g_variant_lookup(results, "session_handle", "s", &session_handle) && session_handle) { + cu->self->session_handle = strdup(session_handle); + cu->self->session_created = true; + cu->callback(true, cu->userdata); + } + + done: + free(cu); +} + +static bool gsr_global_shortcuts_create_session(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata) { + char handle_token[64]; + gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token)); + + char session_handle_token[64]; + snprintf(session_handle_token, sizeof(session_handle_token), "gpu_screen_recorder_adwaita"); + + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder, "{sv}", "handle_token", g_variant_new_string(handle_token)); + g_variant_builder_add(&builder, "{sv}", "session_handle_token", g_variant_new_string(session_handle_token)); + GVariant *aa = g_variant_builder_end(&builder); + + GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "CreateSession", g_variant_new_tuple(&aa, 1), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, 1000, NULL, NULL); + + if(ret) { + const gchar *val = NULL; + g_variant_get(ret, "(&o)", &val); + if(!val) + return false; + //g_variant_unref(ret); + + GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL); + if(!proxy) + return false; + //g_object_unref(proxy); + + signal_create_session_userdata *cu = malloc(sizeof(signal_create_session_userdata)); + cu->self = self; + cu->callback = callback; + cu->userdata = userdata; + g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_create_session), cu); + return true; + } else { + return false; + } +} + +bool gsr_global_shortcuts_init(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata) { + memset(self, 0, sizeof(*self)); + + self->random_str[DBUS_RANDOM_STR_SIZE] = '\0'; + if(!generate_random_characters(self->random_str, DBUS_RANDOM_STR_SIZE, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 62)) { + fprintf(stderr, "gsr error: gsr_global_shortcuts_init: failed to generate random string\n"); + return false; + } + + self->gdbus_con = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, NULL); + if(!self->gdbus_con) { + fprintf(stderr, "gsr error: gsr_global_shortcuts_init: g_bus_get_sync failed\n"); + return false; + } + + if(!gsr_global_shortcuts_create_session(self, callback, userdata)) { + gsr_global_shortcuts_deinit(self); + return false; + } + + return true; +} + +void gsr_global_shortcuts_deinit(gsr_global_shortcuts *self) { + if(self->gdbus_con) { + /* TODO: Re-add this. Right now it causes errors as the connection is already closed, but checking if it's already closed here has no effect */ + //g_dbus_connection_close(self->gdbus_con, NULL, NULL, NULL); + self->gdbus_con = NULL; + } + + if(self->session_handle) { + free(self->session_handle); + self->session_handle = NULL; + } +} + +bool gsr_global_shortcuts_list_shortcuts(gsr_global_shortcuts *self, gsr_shortcut_callback callback, void *userdata) { + if(!self->session_created) + return false; + + char handle_token[64]; + gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token)); + + GVariant *session_handle_obj = g_variant_new_object_path(self->session_handle); + + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder, "{sv}", "handle_token", g_variant_new_string(handle_token)); + GVariant *aa = g_variant_builder_end(&builder); + + GVariant *args[2] = { session_handle_obj, aa }; + + GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "ListShortcuts", g_variant_new_tuple(args, 2), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, 1000, NULL, NULL); + if(ret) { + const gchar *val = NULL; + g_variant_get(ret, "(&o)", &val); + if(!val) + return false; + + GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL); + if(!proxy) + return false; + //g_object_unref(proxy); + + signal_list_bind_userdata *cu = malloc(sizeof(signal_list_bind_userdata)); + cu->self = self; + cu->callback = callback; + cu->userdata = userdata; + g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_list_bind), cu); + return true; + } else { + return false; + } +} + +bool gsr_global_shortcuts_bind_shortcuts(gsr_global_shortcuts *self, const gsr_bind_shortcut *shortcuts, int num_shortcuts, gsr_shortcut_callback callback, void *userdata) { + if(!self->session_created) + return false; + + char handle_token[64]; + gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token)); + + GVariant *session_handle_obj = g_variant_new_object_path(self->session_handle); + + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("a(sa{sv})")); + + for(int i = 0; i < num_shortcuts; ++i) { + GVariantBuilder shortcuts_builder; + g_variant_builder_init(&shortcuts_builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&shortcuts_builder, "{sv}", "description", g_variant_new_string(shortcuts[i].description)); + g_variant_builder_add(&shortcuts_builder, "{sv}", "preferred_trigger", g_variant_new_string(shortcuts[i].shortcut.trigger_description)); + GVariant *shortcuts_data = g_variant_builder_end(&shortcuts_builder); + GVariant *ss_l[2] = { g_variant_new_string(shortcuts[i].shortcut.id), shortcuts_data }; + g_variant_builder_add_value(&builder, g_variant_new_tuple(ss_l, 2)); + } + GVariant *aa = g_variant_builder_end(&builder); + + GVariantBuilder builder_zzz; + g_variant_builder_init(&builder_zzz, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder_zzz, "{sv}", "handle_token", g_variant_new_string(handle_token)); + GVariant *bb = g_variant_builder_end(&builder_zzz); + + GVariant *parent_window = g_variant_new_string(""); + GVariant *args[4] = { session_handle_obj, aa, parent_window, bb }; + + GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "BindShortcuts", g_variant_new_tuple(args, 4), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, -1, NULL, NULL); + if(ret) { + const gchar *val = NULL; + g_variant_get(ret, "(&o)", &val); + if(!val) + return false; + + GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL); + if(!proxy) + return false; + //g_object_unref(proxy); + + signal_list_bind_userdata *cu = malloc(sizeof(signal_list_bind_userdata)); + cu->self = self; + cu->callback = callback; + cu->userdata = userdata; + g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_list_bind), cu); + return true; + } else { + return false; + } +} + +bool gsr_global_shortcuts_subscribe_activated_signal(gsr_global_shortcuts *self, gsr_deactivated_callback deactivated_callback, gsr_shortcut_callback shortcut_changed_callback, void *userdata) { + if(!self->session_created) + return false; + + signal_userdata *cu = malloc(sizeof(signal_userdata)); + cu->self = self; + cu->deactivated_callback = deactivated_callback; + cu->shortcut_changed_callback = shortcut_changed_callback; + cu->userdata = userdata; + g_dbus_connection_signal_subscribe(self->gdbus_con, "org.freedesktop.portal.Desktop", "org.freedesktop.portal.GlobalShortcuts", NULL, "/org/freedesktop/portal/desktop", NULL, G_DBUS_SIGNAL_FLAGS_NONE, signal_callback, cu, free); + return true; +} diff --git a/src/global_shortcuts.h b/src/global_shortcuts.h new file mode 100644 index 0000000..087c177 --- /dev/null +++ b/src/global_shortcuts.h @@ -0,0 +1,41 @@ +#ifndef GLOBAL_SHORTCUTS_H +#define GLOBAL_SHORTCUTS_H + +/* Global shortcuts via desktop portal */ + +#include <stdbool.h> +#include <gio/gio.h> + +#define DBUS_RANDOM_STR_SIZE 16 + +typedef struct { + const char *id; + const char *trigger_description; +} gsr_shortcut; + +typedef struct { + const char *description; + gsr_shortcut shortcut; +} gsr_bind_shortcut; + +typedef void (*gsr_init_callback)(bool success, void *userdata); +typedef void (*gsr_shortcut_callback)(gsr_shortcut shortcut, void *userdata); +typedef void (*gsr_deactivated_callback)(const char *id, void *userdata); + +typedef struct { + GDBusConnection *gdbus_con; + char *session_handle; + bool session_created; + char random_str[DBUS_RANDOM_STR_SIZE + 1]; + unsigned int handle_counter; +} gsr_global_shortcuts; + +bool gsr_global_shortcuts_init(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata); +void gsr_global_shortcuts_deinit(gsr_global_shortcuts *self); + +bool gsr_global_shortcuts_list_shortcuts(gsr_global_shortcuts *self, gsr_shortcut_callback callback, void *userdata); +bool gsr_global_shortcuts_bind_shortcuts(gsr_global_shortcuts *self, const gsr_bind_shortcut *shortcuts, int num_shortcuts, gsr_shortcut_callback callback, void *userdata); + +bool gsr_global_shortcuts_subscribe_activated_signal(gsr_global_shortcuts *self, gsr_deactivated_callback deactivated_callback, gsr_shortcut_callback shortcut_changed_callback, void *userdata); + +#endif /* GLOBAL_SHORTCUTS_H */ diff --git a/src/gsr-config-page.c b/src/gsr-config-page.c new file mode 100644 index 0000000..b1c325c --- /dev/null +++ b/src/gsr-config-page.c @@ -0,0 +1,1403 @@ +#include "gsr-config-page.h" +#ifdef HAVE_X11 +#include "gsr-x11-window-picker.h" +#endif + +/* ═══════════════════════════════════════════════════════════════════ + * GsrConfigPage — "Config" tab + * + * Groups: Capture Target · Audio · Video · Notifications + * All widgets are built programmatically from GsrInfo data. + * ═══════════════════════════════════════════════════════════════════ */ + +struct _GsrConfigPage { + AdwPreferencesPage parent_instance; + + const GsrInfo *info; /* borrowed, must outlive page */ + + /* ── Capture Target group ─── */ + AdwPreferencesGroup *capture_group; + AdwComboRow *record_area_row; + GtkStringList *record_area_model; + /* parallel array of IDs for the record_area_model */ + char **record_area_ids; + int n_record_area_ids; + + AdwSwitchRow *change_resolution_row; + AdwSpinRow *video_width_row; + AdwSpinRow *video_height_row; + AdwSpinRow *area_width_row; + AdwSpinRow *area_height_row; + AdwSwitchRow *restore_portal_row; + +#ifdef HAVE_X11 + /* X11 window picker */ + AdwActionRow *select_window_row; + unsigned long selected_window_id; /* X11 Window, 0 = none */ + char *selected_window_name; /* owned, or NULL */ + GsrX11WindowPicker *active_picker; /* non-NULL during pick */ +#endif + + /* ── Audio group ─── */ + AdwPreferencesGroup *audio_group; + GtkListBox *audio_rows_box; + GtkButton *add_device_btn; + GtkButton *add_app_btn; + GtkButton *add_custom_app_btn; + AdwSwitchRow *split_audio_row; + AdwSwitchRow *app_audio_inverted_row; + AdwComboRow *audio_codec_row; + + /* ── Video group ─── */ + AdwPreferencesGroup *video_group; + AdwComboRow *quality_row; + AdwSpinRow *bitrate_row; + AdwComboRow *video_codec_row; + GtkStringList *video_codec_model; + char **video_codec_ids; + int n_video_codec_ids; + AdwComboRow *color_range_row; + AdwSpinRow *fps_row; + AdwComboRow *framerate_mode_row; + AdwSwitchRow *overclock_row; + AdwSwitchRow *record_cursor_row; + + /* ── Notifications group ─── */ + AdwPreferencesGroup *notifications_group; + AdwSwitchRow *notify_started_row; + AdwSwitchRow *notify_stopped_row; + AdwSwitchRow *notify_saved_row; +}; + +G_DEFINE_FINAL_TYPE(GsrConfigPage, gsr_config_page, ADW_TYPE_PREFERENCES_PAGE) + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static void +ids_array_append(char ***ids, int *n, int *cap, const char *id) +{ + if (*n >= *cap) { + *cap = *cap ? *cap * 2 : 16; + *ids = g_realloc_n(*ids, (gsize)(*cap + 1), sizeof(char *)); + } + (*ids)[*n] = g_strdup(id); + (*n)++; + (*ids)[*n] = NULL; +} + +/* ── Capture Target ──────────────────────────────────────────────── */ + +static void +on_record_area_changed(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + guint idx = adw_combo_row_get_selected(ADW_COMBO_ROW(obj)); + const char *id = (idx < (guint)self->n_record_area_ids) + ? self->record_area_ids[idx] : ""; + + gboolean is_focused = g_str_equal(id, "focused"); + gboolean is_portal = g_str_equal(id, "portal"); + + /* area size rows only for "focused" */ + gtk_widget_set_visible(GTK_WIDGET(self->area_width_row), is_focused); + gtk_widget_set_visible(GTK_WIDGET(self->area_height_row), is_focused); + + /* change-resolution + video size rows hidden for "focused" */ + gtk_widget_set_visible(GTK_WIDGET(self->change_resolution_row), !is_focused); + + gboolean show_vid_res = !is_focused && + adw_switch_row_get_active(self->change_resolution_row); + gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), show_vid_res); + gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), show_vid_res); + + /* portal session restore */ + gtk_widget_set_visible(GTK_WIDGET(self->restore_portal_row), is_portal); + +#ifdef HAVE_X11 + /* "Select window..." row */ + gboolean is_window = g_str_equal(id, "window"); + gtk_widget_set_visible(GTK_WIDGET(self->select_window_row), is_window); +#endif +} + +static void +on_change_resolution_toggled(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + gboolean active = adw_switch_row_get_active(ADW_SWITCH_ROW(obj)); + + /* Also check that we're not in "focused" mode */ + guint idx = adw_combo_row_get_selected(self->record_area_row); + const char *id = (idx < (guint)self->n_record_area_ids) + ? self->record_area_ids[idx] : ""; + gboolean show = active && !g_str_equal(id, "focused"); + + gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), show); + gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), show); +} + +/* ── Window picker callback & handler ────────────────────────────── */ + +#ifdef HAVE_X11 +static void +on_window_picked(const GsrX11WindowPickResult *result, void *userdata) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(userdata); + self->active_picker = NULL; /* picker self-destructs after callback */ + + if (result->window == None) { + /* Cancelled — keep previous selection (if any) */ + return; + } + + /* Store the selected window */ + g_free(self->selected_window_name); + self->selected_window_id = (unsigned long)result->window; + self->selected_window_name = g_strdup(result->name); + + /* Update the row subtitle */ + char *subtitle = g_strdup_printf("%s (0x%lx)", + result->name ? result->name : "(no name)", + (unsigned long)result->window); + adw_action_row_set_subtitle(self->select_window_row, subtitle); + g_free(subtitle); +} + +static void +on_select_window_activated(AdwActionRow *row G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + + /* Only allow on X11 */ + if (self->info->system_info.display_server != GSR_DISPLAY_SERVER_X11) + return; + + /* Cancel any existing picker */ + if (self->active_picker) { + gsr_x11_window_picker_free(self->active_picker); + self->active_picker = NULL; + } + + self->active_picker = gsr_x11_window_picker_new(on_window_picked, self); + if (!self->active_picker) { + adw_action_row_set_subtitle(self->select_window_row, + "Failed to grab pointer"); + } +} +#endif /* HAVE_X11 */ + +static void +build_capture_group(GsrConfigPage *self) +{ + self->capture_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->capture_group, "Capture Target"); + + /* Record area combo */ + self->record_area_model = gtk_string_list_new(NULL); + self->record_area_ids = NULL; + self->n_record_area_ids = 0; + int cap = 0; + + const GsrInfo *info = self->info; + + /* "Window" */ + if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) + gtk_string_list_append(self->record_area_model, "Window (Wayland N/A)"); + else + gtk_string_list_append(self->record_area_model, "Window"); + ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "window"); + + /* "Follow focused window" */ + if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) + gtk_string_list_append(self->record_area_model, "Focused window (Wayland N/A)"); + else + gtk_string_list_append(self->record_area_model, "Focused window"); + ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "focused"); + + /* Monitors */ + for (int i = 0; i < info->supported_capture_options.n_monitors; i++) { + const GsrMonitor *m = &info->supported_capture_options.monitors[i]; + char *label; + if (m->width > 0 && m->height > 0) + label = g_strdup_printf("Monitor %s (%dx%d)", m->name, m->width, m->height); + else + label = g_strdup_printf("Monitor %s", m->name); + gtk_string_list_append(self->record_area_model, label); + ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, m->name); + g_free(label); + } + + /* Desktop portal */ + if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) { + if (info->supported_capture_options.portal) + gtk_string_list_append(self->record_area_model, "Desktop portal (no HDR)"); + else + gtk_string_list_append(self->record_area_model, "Desktop portal (N/A)"); + } else { + gtk_string_list_append(self->record_area_model, "Desktop portal (X11 N/A)"); + } + ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "portal"); + + self->record_area_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->record_area_row), "Record area"); + adw_combo_row_set_model(self->record_area_row, + G_LIST_MODEL(self->record_area_model)); + adw_combo_row_set_use_subtitle(self->record_area_row, TRUE); + + /* Default selection: first monitor if available (index 2), else window (index 0) */ + if (info->supported_capture_options.n_monitors > 0) + adw_combo_row_set_selected(self->record_area_row, 2); + else + adw_combo_row_set_selected(self->record_area_row, 0); + + g_signal_connect(self->record_area_row, "notify::selected", + G_CALLBACK(on_record_area_changed), self); + + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->record_area_row)); + + /* "Select window..." row (X11 only, shown when record area = "window") */ +#ifdef HAVE_X11 + self->select_window_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->select_window_row), + "Select window..."); + adw_action_row_set_subtitle(self->select_window_row, "Click to pick a window"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->select_window_row), TRUE); + GtkImage *pick_icon = GTK_IMAGE(gtk_image_new_from_icon_name("find-location-symbolic")); + adw_action_row_add_suffix(self->select_window_row, GTK_WIDGET(pick_icon)); + g_signal_connect(self->select_window_row, "activated", + G_CALLBACK(on_select_window_activated), self); + gtk_widget_set_visible(GTK_WIDGET(self->select_window_row), FALSE); + self->selected_window_id = 0; + self->selected_window_name = NULL; + self->active_picker = NULL; + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->select_window_row)); +#endif /* HAVE_X11 */ + + /* Change video resolution */ + self->change_resolution_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->change_resolution_row), + "Change video resolution"); + adw_switch_row_set_active(self->change_resolution_row, FALSE); + g_signal_connect(self->change_resolution_row, "notify::active", + G_CALLBACK(on_change_resolution_toggled), self); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->change_resolution_row)); + + /* Video resolution W×H */ + self->video_width_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_width_row), "Video width"); + adw_spin_row_set_value(self->video_width_row, 1920); + gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), FALSE); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->video_width_row)); + + self->video_height_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_height_row), "Video height"); + adw_spin_row_set_value(self->video_height_row, 1080); + gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), FALSE); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->video_height_row)); + + /* Area size W×H (focused mode) */ + self->area_width_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->area_width_row), "Area width"); + adw_spin_row_set_value(self->area_width_row, 1920); + gtk_widget_set_visible(GTK_WIDGET(self->area_width_row), FALSE); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->area_width_row)); + + self->area_height_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->area_height_row), "Area height"); + adw_spin_row_set_value(self->area_height_row, 1080); + gtk_widget_set_visible(GTK_WIDGET(self->area_height_row), FALSE); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->area_height_row)); + + /* Restore portal session */ + self->restore_portal_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->restore_portal_row), + "Restore portal session"); + adw_switch_row_set_active(self->restore_portal_row, TRUE); + gtk_widget_set_visible(GTK_WIDGET(self->restore_portal_row), FALSE); + adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->restore_portal_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->capture_group); +} + +/* ── Audio group ─────────────────────────────────────────────────── */ + +static void on_add_audio_device_clicked(GtkButton *btn, gpointer user_data); +static void on_add_app_audio_clicked(GtkButton *btn, gpointer user_data); +static void on_add_custom_app_clicked(GtkButton *btn, gpointer user_data); + +static void +build_audio_group(GsrConfigPage *self) +{ + self->audio_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->audio_group, "Audio"); + + /* Button row for adding audio tracks */ + GtkBox *btn_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6)); + gtk_widget_set_margin_top(GTK_WIDGET(btn_box), 4); + gtk_widget_set_margin_bottom(GTK_WIDGET(btn_box), 4); + + self->add_device_btn = GTK_BUTTON(gtk_button_new()); + AdwButtonContent *dev_bc = ADW_BUTTON_CONTENT(adw_button_content_new()); + adw_button_content_set_icon_name(dev_bc, "list-add-symbolic"); + adw_button_content_set_label(dev_bc, "Audio device"); + gtk_button_set_child(self->add_device_btn, GTK_WIDGET(dev_bc)); + gtk_widget_add_css_class(GTK_WIDGET(self->add_device_btn), "flat"); + g_signal_connect(self->add_device_btn, "clicked", + G_CALLBACK(on_add_audio_device_clicked), self); + gtk_box_append(btn_box, GTK_WIDGET(self->add_device_btn)); + + self->add_app_btn = GTK_BUTTON(gtk_button_new()); + AdwButtonContent *app_bc = ADW_BUTTON_CONTENT(adw_button_content_new()); + adw_button_content_set_icon_name(app_bc, "list-add-symbolic"); + adw_button_content_set_label(app_bc, "App audio"); + gtk_button_set_child(self->add_app_btn, GTK_WIDGET(app_bc)); + gtk_widget_add_css_class(GTK_WIDGET(self->add_app_btn), "flat"); + g_signal_connect(self->add_app_btn, "clicked", + G_CALLBACK(on_add_app_audio_clicked), self); + gtk_box_append(btn_box, GTK_WIDGET(self->add_app_btn)); + + self->add_custom_app_btn = GTK_BUTTON(gtk_button_new()); + AdwButtonContent *custom_bc = ADW_BUTTON_CONTENT(adw_button_content_new()); + adw_button_content_set_icon_name(custom_bc, "list-add-symbolic"); + adw_button_content_set_label(custom_bc, "Custom app"); + gtk_button_set_child(self->add_custom_app_btn, GTK_WIDGET(custom_bc)); + gtk_widget_add_css_class(GTK_WIDGET(self->add_custom_app_btn), "flat"); + g_signal_connect(self->add_custom_app_btn, "clicked", + G_CALLBACK(on_add_custom_app_clicked), self); + gtk_box_append(btn_box, GTK_WIDGET(self->add_custom_app_btn)); + + if (!self->info->system_info.supports_app_audio) { + gtk_widget_set_visible(GTK_WIDGET(self->add_app_btn), FALSE); + gtk_widget_set_visible(GTK_WIDGET(self->add_custom_app_btn), FALSE); + } + + adw_preferences_group_add(self->audio_group, GTK_WIDGET(btn_box)); + + /* Container for dynamic audio rows — boxed-list gives the card look */ + self->audio_rows_box = GTK_LIST_BOX(gtk_list_box_new()); + gtk_list_box_set_selection_mode(self->audio_rows_box, GTK_SELECTION_NONE); + gtk_widget_add_css_class(GTK_WIDGET(self->audio_rows_box), "boxed-list"); + gtk_widget_set_visible(GTK_WIDGET(self->audio_rows_box), FALSE); + g_object_set_data(G_OBJECT(self->audio_rows_box), "config-page", self); + adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->audio_rows_box)); + + /* Split audio */ + self->split_audio_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->split_audio_row), + "Split audio tracks"); + adw_switch_row_set_active(self->split_audio_row, FALSE); + gtk_widget_set_visible(GTK_WIDGET(self->split_audio_row), FALSE); /* advanced only */ + adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->split_audio_row)); + + /* Record app audio inverted */ + self->app_audio_inverted_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->app_audio_inverted_row), + "Record all apps except selected"); + adw_switch_row_set_active(self->app_audio_inverted_row, FALSE); + if (!self->info->system_info.supports_app_audio) + gtk_widget_set_visible(GTK_WIDGET(self->app_audio_inverted_row), FALSE); + adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->app_audio_inverted_row)); + + /* Audio codec */ + self->audio_codec_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->audio_codec_row), "Audio codec"); + GtkStringList *ac_model = gtk_string_list_new( + (const char *const[]){ "Opus (Recommended)", "AAC", NULL }); + adw_combo_row_set_model(self->audio_codec_row, G_LIST_MODEL(ac_model)); + adw_combo_row_set_selected(self->audio_codec_row, 0); + gtk_widget_set_visible(GTK_WIDGET(self->audio_codec_row), FALSE); /* advanced only */ + adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->audio_codec_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->audio_group); +} + +/* ── Audio row management ─────────────────────────────────────────── */ + +static void +update_audio_rows_visibility(GsrConfigPage *self) +{ + gboolean has_children = + gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box)) != NULL; + gtk_widget_set_visible(GTK_WIDGET(self->audio_rows_box), has_children); +} + +static void +on_remove_audio_row(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GtkWidget *row = GTK_WIDGET(user_data); + GtkWidget *parent = gtk_widget_get_parent(row); + if (parent && GTK_IS_LIST_BOX(parent)) { + GsrConfigPage *self = g_object_get_data(G_OBJECT(parent), "config-page"); + gtk_list_box_remove(GTK_LIST_BOX(parent), row); + if (self) + update_audio_rows_visibility(self); + } +} + +static GtkWidget * +create_audio_row(const char *track_type, const char *title_text, + const char *const *items, int n_items, + const char *preselect, GsrConfigPage *self) +{ + AdwActionRow *row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title_text); + + g_object_set_data_full(G_OBJECT(row), "audio-track-type", + g_strdup(track_type), g_free); + + if (g_str_equal(track_type, "app-custom")) { + /* Free-form text entry */ + GtkEntry *entry = GTK_ENTRY(gtk_entry_new()); + gtk_widget_set_hexpand(GTK_WIDGET(entry), TRUE); + gtk_widget_set_valign(GTK_WIDGET(entry), GTK_ALIGN_CENTER); + if (preselect) + gtk_editable_set_text(GTK_EDITABLE(entry), preselect); + g_object_set_data(G_OBJECT(row), "input-widget", entry); + adw_action_row_add_suffix(row, GTK_WIDGET(entry)); + } else { + /* Dropdown from list */ + GtkStringList *model = gtk_string_list_new(NULL); + int selected_idx = 0; + for (int i = 0; i < n_items; i++) { + gtk_string_list_append(model, items[i]); + if (preselect && g_str_equal(items[i], preselect)) + selected_idx = i; + } + GtkDropDown *dd = GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(model), NULL)); + gtk_widget_set_valign(GTK_WIDGET(dd), GTK_ALIGN_CENTER); + if (n_items > 0) + gtk_drop_down_set_selected(dd, (guint)selected_idx); + g_object_set_data(G_OBJECT(row), "input-widget", dd); + g_object_set_data_full(G_OBJECT(row), "string-list", + g_object_ref(model), g_object_unref); + adw_action_row_add_suffix(row, GTK_WIDGET(dd)); + } + + /* Remove button with × icon */ + GtkButton *rm = GTK_BUTTON(gtk_button_new_from_icon_name("window-close-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(rm), "flat"); + gtk_widget_add_css_class(GTK_WIDGET(rm), "circular"); + gtk_widget_set_valign(GTK_WIDGET(rm), GTK_ALIGN_CENTER); + g_signal_connect(rm, "clicked", G_CALLBACK(on_remove_audio_row), row); + adw_action_row_add_suffix(row, GTK_WIDGET(rm)); + + (void)self; + return GTK_WIDGET(row); +} + +static void +on_add_audio_device_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + int n_devs = 0; + GsrAudioDevice *devs = gsr_audio_devices_get(&n_devs); + + const char **labels = g_new0(const char *, n_devs + 1); + for (int i = 0; i < n_devs; i++) + labels[i] = devs[i].description; + + GtkWidget *row = create_audio_row("device", "Device", labels, n_devs, + NULL, self); + gtk_list_box_append(self->audio_rows_box, row); + update_audio_rows_visibility(self); + + /* Store device names as data on the row for later retrieval */ + char **names = g_new0(char *, n_devs + 1); + for (int i = 0; i < n_devs; i++) + names[i] = g_strdup(devs[i].name); + g_object_set_data_full(G_OBJECT(row), "device-names", + names, (GDestroyNotify)g_strfreev); + + g_free(labels); + gsr_audio_devices_free(devs, n_devs); +} + +static void +on_add_app_audio_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + int n_apps = 0; + char **apps = gsr_application_audio_get(&n_apps); + + GtkWidget *row = create_audio_row("app", "Application", + (const char *const *)apps, n_apps, + NULL, self); + gtk_list_box_append(self->audio_rows_box, row); + update_audio_rows_visibility(self); + gsr_application_audio_free(apps, n_apps); +} + +static void +on_add_custom_app_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + GtkWidget *row = create_audio_row("app-custom", "Application", + NULL, 0, "", self); + gtk_list_box_append(self->audio_rows_box, row); + update_audio_rows_visibility(self); +} + +/* ── Video group ─────────────────────────────────────────────────── */ + +static void +on_quality_changed(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(user_data); + guint idx = adw_combo_row_get_selected(ADW_COMBO_ROW(obj)); + /* index 0 = "Constant bitrate" (custom) */ + gtk_widget_set_visible(GTK_WIDGET(self->bitrate_row), idx == 0); +} + +static void +build_video_group(GsrConfigPage *self) +{ + self->video_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->video_group, "Video"); + + const GsrInfo *info = self->info; + + /* Quality */ + self->quality_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->quality_row), "Video quality"); + GtkStringList *q_model = gtk_string_list_new((const char *const[]){ + "Constant bitrate", + "Medium", "High", + "Very High", + "Ultra", NULL }); + adw_combo_row_set_model(self->quality_row, G_LIST_MODEL(q_model)); + adw_combo_row_set_selected(self->quality_row, 0); + g_signal_connect(self->quality_row, "notify::selected", + G_CALLBACK(on_quality_changed), self); + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->quality_row)); + + /* Bitrate */ + self->bitrate_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(1, 500000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->bitrate_row), "Video bitrate (kbps)"); + adw_spin_row_set_value(self->bitrate_row, 15000); + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->bitrate_row)); + + /* Video codec */ + self->video_codec_model = gtk_string_list_new(NULL); + self->video_codec_ids = NULL; + self->n_video_codec_ids = 0; + int vc_cap = 0; + + struct { const char *id; const char *label_ok; const char *label_na; } codecs[] = { + { "auto", "Auto", NULL }, + { "h264", "H.264", + "H.264 (N/A)" }, + { "hevc", "HEVC", + "HEVC (N/A)" }, + { "hevc_10bit", "HEVC 10-bit", + "HEVC 10-bit (N/A)" }, + { "hevc_hdr", + info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND + ? "HEVC HDR" : "HEVC HDR (X11 N/A)", + "HEVC HDR (N/A)" }, + { "av1", "AV1", + "AV1 (N/A)" }, + { "av1_10bit", "AV1 10-bit", + "AV1 10-bit (N/A)" }, + { "av1_hdr", + info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND + ? "AV1 HDR" : "AV1 HDR (X11 N/A)", + "AV1 HDR (N/A)" }, + { "vp8", "VP8", "VP8 (N/A)" }, + { "vp9", "VP9", "VP9 (N/A)" }, + { "h264_software", "H.264 Software (slow)", + "H.264 Software (N/A)" }, + }; + + for (size_t i = 0; i < G_N_ELEMENTS(codecs); i++) { + bool ok = gsr_info_is_codec_supported(info, codecs[i].id); + const char *label = (ok || !codecs[i].label_na) ? codecs[i].label_ok : codecs[i].label_na; + gtk_string_list_append(self->video_codec_model, label); + ids_array_append(&self->video_codec_ids, &self->n_video_codec_ids, &vc_cap, codecs[i].id); + } + + self->video_codec_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_codec_row), "Video codec"); + adw_combo_row_set_model(self->video_codec_row, G_LIST_MODEL(self->video_codec_model)); + adw_combo_row_set_selected(self->video_codec_row, 0); + gtk_widget_set_visible(GTK_WIDGET(self->video_codec_row), FALSE); /* advanced only */ + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->video_codec_row)); + + /* Color range */ + self->color_range_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->color_range_row), "Color range"); + GtkStringList *cr_model = gtk_string_list_new( + (const char *const[]){ "Limited", "Full", NULL }); + adw_combo_row_set_model(self->color_range_row, G_LIST_MODEL(cr_model)); + adw_combo_row_set_selected(self->color_range_row, 0); + gtk_widget_set_visible(GTK_WIDGET(self->color_range_row), FALSE); /* advanced only */ + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->color_range_row)); + + /* FPS */ + self->fps_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(1, 500, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->fps_row), "Frame rate"); + adw_spin_row_set_value(self->fps_row, 60); + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->fps_row)); + + /* Framerate mode */ + self->framerate_mode_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->framerate_mode_row), "Frame rate mode"); + GtkStringList *fm_model = gtk_string_list_new( + (const char *const[]){ "Auto (Recommended)", "Constant", "Variable", NULL }); + adw_combo_row_set_model(self->framerate_mode_row, G_LIST_MODEL(fm_model)); + adw_combo_row_set_selected(self->framerate_mode_row, 0); + gtk_widget_set_visible(GTK_WIDGET(self->framerate_mode_row), FALSE); /* advanced only */ + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->framerate_mode_row)); + + /* Overclock (NVIDIA + X11 only) */ + self->overclock_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->overclock_row), + "Overclock memory transfer rate"); + adw_switch_row_set_active(self->overclock_row, FALSE); + /* Only visible in advanced mode AND nvidia AND not wayland */ + gtk_widget_set_visible(GTK_WIDGET(self->overclock_row), FALSE); + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->overclock_row)); + + /* Record cursor */ + self->record_cursor_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->record_cursor_row), "Record cursor"); + adw_switch_row_set_active(self->record_cursor_row, TRUE); + adw_preferences_group_add(self->video_group, GTK_WIDGET(self->record_cursor_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->video_group); +} + +/* ── Notifications group ─────────────────────────────────────────── */ + +static void +build_notifications_group(GsrConfigPage *self) +{ + self->notifications_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->notifications_group, "Notifications"); + gtk_widget_set_visible(GTK_WIDGET(self->notifications_group), FALSE); /* advanced only */ + + self->notify_started_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_started_row), + "Show started notification"); + adw_switch_row_set_active(self->notify_started_row, FALSE); + adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_started_row)); + + self->notify_stopped_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_stopped_row), + "Show stopped notification"); + adw_switch_row_set_active(self->notify_stopped_row, FALSE); + adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_stopped_row)); + + self->notify_saved_row = ADW_SWITCH_ROW(adw_switch_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_saved_row), + "Show video saved notification"); + adw_switch_row_set_active(self->notify_saved_row, TRUE); + adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_saved_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->notifications_group); +} + +/* ── GObject lifecycle ───────────────────────────────────────────── */ + +static void +gsr_config_page_finalize(GObject *object) +{ + GsrConfigPage *self = GSR_CONFIG_PAGE(object); + + g_strfreev(self->record_area_ids); + g_strfreev(self->video_codec_ids); + + /* Window picker cleanup */ +#ifdef HAVE_X11 + if (self->active_picker) { + gsr_x11_window_picker_free(self->active_picker); + self->active_picker = NULL; + } + g_free(self->selected_window_name); +#endif + + G_OBJECT_CLASS(gsr_config_page_parent_class)->finalize(object); +} + +static void +gsr_config_page_init(GsrConfigPage *self) +{ + (void)self; + /* Real init happens in gsr_config_page_new after info is set */ +} + +static void +gsr_config_page_class_init(GsrConfigPageClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_config_page_finalize; +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrConfigPage * +gsr_config_page_new(const GsrInfo *info) +{ + GsrConfigPage *self = g_object_new(GSR_TYPE_CONFIG_PAGE, NULL); + self->info = info; + + adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Config"); + adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self), + "preferences-system-symbolic"); + + build_capture_group(self); + build_audio_group(self); + build_video_group(self); + build_notifications_group(self); + + /* Trigger initial visibility */ + on_record_area_changed(G_OBJECT(self->record_area_row), NULL, self); + + return self; +} + +void +gsr_config_page_set_advanced(GsrConfigPage *self, gboolean advanced) +{ + /* Audio: advanced-only widgets */ + gtk_widget_set_visible(GTK_WIDGET(self->split_audio_row), advanced); + gtk_widget_set_visible(GTK_WIDGET(self->audio_codec_row), advanced); + + /* Video: advanced-only widgets */ + gtk_widget_set_visible(GTK_WIDGET(self->video_codec_row), advanced); + gtk_widget_set_visible(GTK_WIDGET(self->color_range_row), advanced); + gtk_widget_set_visible(GTK_WIDGET(self->framerate_mode_row), advanced); + + /* Overclock: advanced + NVIDIA + not Wayland */ + gboolean show_oc = advanced + && self->info->gpu_info.vendor == GSR_GPU_VENDOR_NVIDIA + && self->info->system_info.display_server != GSR_DISPLAY_SERVER_WAYLAND; + gtk_widget_set_visible(GTK_WIDGET(self->overclock_row), show_oc); + + /* Notifications: advanced only */ + gtk_widget_set_visible(GTK_WIDGET(self->notifications_group), advanced); +} + +/* ── Config apply/read ───────────────────────────────────────────── */ + +/* Helper: find index in a parallel ID array matching a string, or -1 */ +static int +find_id_index(char **ids, int n, const char *id) +{ + if (!id || !id[0]) + return -1; + for (int i = 0; i < n; i++) { + if (g_str_equal(ids[i], id)) + return i; + } + return -1; +} + +/* Map config quality string to combo index */ +static guint +quality_string_to_index(const char *q) +{ + if (!q) return 3; /* very_high */ + if (g_str_equal(q, "custom")) return 0; + if (g_str_equal(q, "medium")) return 1; + if (g_str_equal(q, "high")) return 2; + if (g_str_equal(q, "very_high")) return 3; + if (g_str_equal(q, "ultra")) return 4; + return 3; +} + +static const char * +quality_index_to_string(guint idx) +{ + switch (idx) { + case 0: return "custom"; + case 1: return "medium"; + case 2: return "high"; + case 3: return "very_high"; + case 4: return "ultra"; + default: return "very_high"; + } +} + +/* Map config audio_codec string to combo index */ +static guint +audio_codec_string_to_index(const char *ac) +{ + if (ac && g_str_equal(ac, "aac")) return 1; + return 0; /* opus */ +} + +static const char * +audio_codec_index_to_string(guint idx) +{ + return (idx == 1) ? "aac" : "opus"; +} + +/* Map config color_range string to combo index */ +static guint +color_range_string_to_index(const char *cr) +{ + if (cr && g_str_equal(cr, "full")) return 1; + return 0; /* limited */ +} + +static const char * +color_range_index_to_string(guint idx) +{ + return (idx == 1) ? "full" : "limited"; +} + +/* Map config framerate_mode string to combo index */ +static guint +framerate_mode_string_to_index(const char *fm) +{ + if (!fm) return 0; + if (g_str_equal(fm, "auto")) return 0; + if (g_str_equal(fm, "cfr")) return 1; + if (g_str_equal(fm, "vfr")) return 2; + return 0; +} + +static const char * +framerate_mode_index_to_string(guint idx) +{ + switch (idx) { + case 0: return "auto"; + case 1: return "cfr"; + case 2: return "vfr"; + default: return "auto"; + } +} + +void +gsr_config_page_apply_config(GsrConfigPage *self, const GsrConfig *config) +{ + const GsrMainConfig *m = &config->main_config; + + /* ── Capture Target ── */ + + /* Record area: find matching ID in our combo model */ + int ra_idx = find_id_index(self->record_area_ids, self->n_record_area_ids, + m->record_area_option); + if (ra_idx >= 0) + adw_combo_row_set_selected(self->record_area_row, (guint)ra_idx); + + /* Resolution */ + adw_switch_row_set_active(self->change_resolution_row, m->change_video_resolution); + if (m->video_width > 0) + adw_spin_row_set_value(self->video_width_row, m->video_width); + if (m->video_height > 0) + adw_spin_row_set_value(self->video_height_row, m->video_height); + if (m->record_area_width > 0) + adw_spin_row_set_value(self->area_width_row, m->record_area_width); + if (m->record_area_height > 0) + adw_spin_row_set_value(self->area_height_row, m->record_area_height); + + /* Portal session */ + adw_switch_row_set_active(self->restore_portal_row, m->restore_portal_session); + + /* ── Audio ── */ + + /* Clear existing audio rows */ + GtkWidget *child; + while ((child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box))) != NULL) + gtk_list_box_remove(self->audio_rows_box, child); + + /* Populate from config audio_input array */ + for (int i = 0; i < m->n_audio_input; i++) { + const char *input = m->audio_input[i]; + if (!input) continue; + + if (g_str_has_prefix(input, "app:")) { + const char *app_name = input + 4; + if (!self->info->system_info.supports_app_audio) + continue; + + /* Try to find in app list; if not found, create custom row */ + int n_apps = 0; + char **apps = gsr_application_audio_get(&n_apps); + + gboolean found = FALSE; + for (int j = 0; j < n_apps; j++) { + if (g_ascii_strcasecmp(apps[j], app_name) == 0) { + found = TRUE; + break; + } + } + + if (found) { + GtkWidget *row = create_audio_row("app", "Application", + (const char *const *)apps, n_apps, app_name, self); + gtk_list_box_append(self->audio_rows_box, row); + } else { + GtkWidget *row = create_audio_row("app-custom", "Application", + NULL, 0, app_name, self); + gtk_list_box_append(self->audio_rows_box, row); + } + gsr_application_audio_free(apps, n_apps); + + } else { + /* "device:xxx" or bare legacy name */ + const char *desc = input; + if (g_str_has_prefix(input, "device:")) + desc = input + 7; + + int n_devs = 0; + GsrAudioDevice *devs = gsr_audio_devices_get(&n_devs); + + const char **labels = g_new0(const char *, n_devs + 1); + for (int j = 0; j < n_devs; j++) + labels[j] = devs[j].description; + + GtkWidget *row = create_audio_row("device", "Device", + labels, n_devs, desc, self); + gtk_list_box_append(self->audio_rows_box, row); + + /* Attach device names */ + char **names = g_new0(char *, n_devs + 1); + for (int j = 0; j < n_devs; j++) + names[j] = g_strdup(devs[j].name); + g_object_set_data_full(G_OBJECT(row), "device-names", + names, (GDestroyNotify)g_strfreev); + + g_free(labels); + gsr_audio_devices_free(devs, n_devs); + } + } + + update_audio_rows_visibility(self); + + /* Split audio (inverted from merge_audio_tracks) */ + adw_switch_row_set_active(self->split_audio_row, !m->merge_audio_tracks); + adw_switch_row_set_active(self->app_audio_inverted_row, m->record_app_audio_inverted); + + /* Audio codec */ + adw_combo_row_set_selected(self->audio_codec_row, + audio_codec_string_to_index(m->audio_codec)); + + /* ── Video ── */ + adw_combo_row_set_selected(self->quality_row, + quality_string_to_index(m->quality)); + if (m->video_bitrate > 0) + adw_spin_row_set_value(self->bitrate_row, m->video_bitrate); + + /* Video codec: set "auto" first as fallback, then try actual value */ + int vc_auto = find_id_index(self->video_codec_ids, self->n_video_codec_ids, "auto"); + if (vc_auto >= 0) + adw_combo_row_set_selected(self->video_codec_row, (guint)vc_auto); + int vc_idx = find_id_index(self->video_codec_ids, self->n_video_codec_ids, m->codec); + if (vc_idx >= 0) + adw_combo_row_set_selected(self->video_codec_row, (guint)vc_idx); + + /* Color range */ + adw_combo_row_set_selected(self->color_range_row, + color_range_string_to_index(m->color_range)); + + /* FPS */ + if (m->fps > 0) + adw_spin_row_set_value(self->fps_row, m->fps); + + /* Framerate mode */ + adw_combo_row_set_selected(self->framerate_mode_row, + framerate_mode_string_to_index(m->framerate_mode)); + + /* Overclock */ + adw_switch_row_set_active(self->overclock_row, m->overclock); + + /* Record cursor */ + adw_switch_row_set_active(self->record_cursor_row, m->record_cursor); + + /* ── Notifications ── */ + adw_switch_row_set_active(self->notify_started_row, + m->show_recording_started_notifications); + adw_switch_row_set_active(self->notify_stopped_row, + m->show_recording_stopped_notifications); + adw_switch_row_set_active(self->notify_saved_row, + m->show_recording_saved_notifications); + + /* Trigger visibility callbacks */ + on_record_area_changed(G_OBJECT(self->record_area_row), NULL, self); + on_quality_changed(G_OBJECT(self->quality_row), NULL, self); +} + +void +gsr_config_page_read_config(GsrConfigPage *self, GsrConfig *config) +{ + GsrMainConfig *m = &config->main_config; + + /* ── Capture Target ── */ + guint ra_idx = adw_combo_row_get_selected(self->record_area_row); + g_free(m->record_area_option); + m->record_area_option = (ra_idx < (guint)self->n_record_area_ids) + ? g_strdup(self->record_area_ids[ra_idx]) + : g_strdup(""); + + m->change_video_resolution = adw_switch_row_get_active(self->change_resolution_row); + m->video_width = (int32_t)adw_spin_row_get_value(self->video_width_row); + m->video_height = (int32_t)adw_spin_row_get_value(self->video_height_row); + m->record_area_width = (int32_t)adw_spin_row_get_value(self->area_width_row); + m->record_area_height = (int32_t)adw_spin_row_get_value(self->area_height_row); + m->restore_portal_session = adw_switch_row_get_active(self->restore_portal_row); + + /* ── Audio ── */ + + /* Clear old audio_input array */ + if (m->audio_input) { + for (int i = 0; i < m->n_audio_input; i++) + g_free(m->audio_input[i]); + g_free(m->audio_input); + m->audio_input = NULL; + m->n_audio_input = 0; + } + + /* Walk audio rows */ + int n_audio = 0; + int audio_cap = 0; + char **audio_arr = NULL; + + for (GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box)); + child != NULL; + child = gtk_widget_get_next_sibling(child)) + { + const char *track_type = g_object_get_data(G_OBJECT(child), "audio-track-type"); + if (!track_type) continue; + + char *value = NULL; + + if (g_str_equal(track_type, "device")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + if (GTK_IS_DROP_DOWN(input_w)) { + GtkDropDown *dd = GTK_DROP_DOWN(input_w); + guint sel = gtk_drop_down_get_selected(dd); + GtkStringList *model = GTK_STRING_LIST( + g_object_get_data(G_OBJECT(child), "string-list")); + if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) { + const char *desc = gtk_string_list_get_string(model, sel); + value = g_strdup_printf("device:%s", desc ? desc : ""); + } + } + } else if (g_str_equal(track_type, "app")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + if (GTK_IS_DROP_DOWN(input_w)) { + GtkDropDown *dd = GTK_DROP_DOWN(input_w); + guint sel = gtk_drop_down_get_selected(dd); + GtkStringList *model = GTK_STRING_LIST( + g_object_get_data(G_OBJECT(child), "string-list")); + if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) { + const char *app = gtk_string_list_get_string(model, sel); + value = g_strdup_printf("app:%s", app ? app : ""); + } + } + } else if (g_str_equal(track_type, "app-custom")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + if (GTK_IS_ENTRY(input_w)) { + const char *text = gtk_editable_get_text(GTK_EDITABLE(input_w)); + value = g_strdup_printf("app:%s", text ? text : ""); + } + } + + if (value) { + if (n_audio >= audio_cap) { + audio_cap = audio_cap ? audio_cap * 2 : 8; + audio_arr = g_realloc(audio_arr, sizeof(char *) * (gsize)(audio_cap + 1)); + } + audio_arr[n_audio] = value; + n_audio++; + audio_arr[n_audio] = NULL; + } + } + + m->audio_input = audio_arr; + m->n_audio_input = n_audio; + + /* Split audio (inverted to merge) */ + m->merge_audio_tracks = !adw_switch_row_get_active(self->split_audio_row); + m->record_app_audio_inverted = adw_switch_row_get_active(self->app_audio_inverted_row); + + /* Audio codec */ + g_free(m->audio_codec); + m->audio_codec = g_strdup(audio_codec_index_to_string( + adw_combo_row_get_selected(self->audio_codec_row))); + + /* ── Video ── */ + g_free(m->quality); + m->quality = g_strdup(quality_index_to_string( + adw_combo_row_get_selected(self->quality_row))); + + m->video_bitrate = (int32_t)adw_spin_row_get_value(self->bitrate_row); + + /* Video codec */ + guint vc_idx = adw_combo_row_get_selected(self->video_codec_row); + g_free(m->codec); + m->codec = (vc_idx < (guint)self->n_video_codec_ids) + ? g_strdup(self->video_codec_ids[vc_idx]) + : g_strdup("auto"); + + /* Color range */ + g_free(m->color_range); + m->color_range = g_strdup(color_range_index_to_string( + adw_combo_row_get_selected(self->color_range_row))); + + m->fps = (int32_t)adw_spin_row_get_value(self->fps_row); + + g_free(m->framerate_mode); + m->framerate_mode = g_strdup(framerate_mode_index_to_string( + adw_combo_row_get_selected(self->framerate_mode_row))); + + m->overclock = adw_switch_row_get_active(self->overclock_row); + m->record_cursor = adw_switch_row_get_active(self->record_cursor_row); + + /* ── Notifications ── */ + m->show_recording_started_notifications = + adw_switch_row_get_active(self->notify_started_row); + m->show_recording_stopped_notifications = + adw_switch_row_get_active(self->notify_stopped_row); + m->show_recording_saved_notifications = + adw_switch_row_get_active(self->notify_saved_row); +} + +/* ── Command-line helpers (Phase 5) ──────────────────────────────── */ + +const char * +gsr_config_page_get_record_area_id(GsrConfigPage *self) +{ + guint idx = adw_combo_row_get_selected(self->record_area_row); + if (idx < (guint)self->n_record_area_ids) + return self->record_area_ids[idx]; + return ""; +} + +const char * +gsr_config_page_get_video_codec_id(GsrConfigPage *self) +{ + guint idx = adw_combo_row_get_selected(self->video_codec_row); + if (idx < (guint)self->n_video_codec_ids) + return self->video_codec_ids[idx]; + return "auto"; +} + +gboolean +gsr_config_page_get_app_audio_inverted(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->app_audio_inverted_row); +} + +/* ── Scalar getters ──────────────────────────────────────────────── */ + +int +gsr_config_page_get_fps(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->fps_row); +} + +const char * +gsr_config_page_get_quality_id(GsrConfigPage *self) +{ + return quality_index_to_string(adw_combo_row_get_selected(self->quality_row)); +} + +int +gsr_config_page_get_video_bitrate(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->bitrate_row); +} + +const char * +gsr_config_page_get_color_range_id(GsrConfigPage *self) +{ + return color_range_index_to_string( + adw_combo_row_get_selected(self->color_range_row)); +} + +const char * +gsr_config_page_get_audio_codec_id(GsrConfigPage *self) +{ + return audio_codec_index_to_string( + adw_combo_row_get_selected(self->audio_codec_row)); +} + +const char * +gsr_config_page_get_framerate_mode_id(GsrConfigPage *self) +{ + return framerate_mode_index_to_string( + adw_combo_row_get_selected(self->framerate_mode_row)); +} + +gboolean +gsr_config_page_get_record_cursor(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->record_cursor_row); +} + +gboolean +gsr_config_page_get_overclock(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->overclock_row); +} + +gboolean +gsr_config_page_get_restore_portal_session(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->restore_portal_row); +} + +gboolean +gsr_config_page_get_change_video_resolution(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->change_resolution_row); +} + +int +gsr_config_page_get_video_width(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->video_width_row); +} + +int +gsr_config_page_get_video_height(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->video_height_row); +} + +int +gsr_config_page_get_area_width(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->area_width_row); +} + +int +gsr_config_page_get_area_height(GsrConfigPage *self) +{ + return (int)adw_spin_row_get_value(self->area_height_row); +} + +gboolean +gsr_config_page_get_split_audio(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->split_audio_row); +} + +gboolean +gsr_config_page_get_notify_started(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->notify_started_row); +} + +gboolean +gsr_config_page_get_notify_stopped(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->notify_stopped_row); +} + +gboolean +gsr_config_page_get_notify_saved(GsrConfigPage *self) +{ + return adw_switch_row_get_active(self->notify_saved_row); +} + +GPtrArray * +gsr_config_page_build_audio_args(GsrConfigPage *self, gboolean merge_tracks) +{ + /* + * Walk the audio_rows_box children. Each child has: + * - "audio-track-type": "device", "app", or "app-custom" + * - "input-widget": GtkDropDown (device/app) or GtkEntry (app-custom) + * - "device-names": char** (only for device rows) + * + * For device rows: the real ID is device-names[selected_index]. + * For app rows: the display string IS the app name. + * For app-custom: the entry text IS the app name. + * + * If app_audio_inverted: prefix app tracks with "app-inverse:" instead of "app:". + * + * Returns a GPtrArray of individual track strings. + * If merge_tracks, all tracks are merged into one pipe-delimited string. + */ + gboolean inverted = adw_switch_row_get_active(self->app_audio_inverted_row); + + GPtrArray *tracks = g_ptr_array_new_with_free_func(g_free); + + for (GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box)); + child != NULL; + child = gtk_widget_get_next_sibling(child)) + { + const char *track_type = g_object_get_data(G_OBJECT(child), "audio-track-type"); + if (!track_type) continue; + + char *value = NULL; + + if (g_str_equal(track_type, "device")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + char **device_names = g_object_get_data(G_OBJECT(child), "device-names"); + if (GTK_IS_DROP_DOWN(input_w) && device_names) { + guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(input_w)); + /* Count device names to bounds-check */ + guint n_names = 0; + while (device_names[n_names]) n_names++; + if (sel < n_names) + value = g_strdup(device_names[sel]); + } + } else if (g_str_equal(track_type, "app")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + if (GTK_IS_DROP_DOWN(input_w)) { + GtkStringList *model = GTK_STRING_LIST( + g_object_get_data(G_OBJECT(child), "string-list")); + guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(input_w)); + if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) { + const char *app = gtk_string_list_get_string(model, sel); + const char *prefix = inverted ? "app-inverse:" : "app:"; + value = g_strdup_printf("%s%s", prefix, app ? app : ""); + } + } + } else if (g_str_equal(track_type, "app-custom")) { + GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget"); + if (GTK_IS_ENTRY(input_w)) { + const char *text = gtk_editable_get_text(GTK_EDITABLE(input_w)); + const char *prefix = inverted ? "app-inverse:" : "app:"; + value = g_strdup_printf("%s%s", prefix, text ? text : ""); + } + } + + if (value) + g_ptr_array_add(tracks, value); + } + + if (merge_tracks && tracks->len > 1) { + /* Merge all into one pipe-delimited string */ + GString *merged = g_string_new(g_ptr_array_index(tracks, 0)); + for (guint i = 1; i < tracks->len; i++) { + g_string_append_c(merged, '|'); + g_string_append(merged, g_ptr_array_index(tracks, i)); + } + g_ptr_array_set_size(tracks, 0); + g_ptr_array_add(tracks, g_string_free(merged, FALSE)); + } + + return tracks; +} + +unsigned long +gsr_config_page_get_selected_window(GsrConfigPage *self) +{ + g_return_val_if_fail(GSR_IS_CONFIG_PAGE(self), 0); +#ifdef HAVE_X11 + return self->selected_window_id; +#else + return 0; +#endif +} + +gboolean +gsr_config_page_has_valid_window_selection(GsrConfigPage *self) +{ + g_return_val_if_fail(GSR_IS_CONFIG_PAGE(self), FALSE); + + const char *area_id = gsr_config_page_get_record_area_id(self); + if (!g_str_equal(area_id, "window")) + return TRUE; /* not in window mode, always valid */ + +#ifdef HAVE_X11 + return self->selected_window_id != 0; +#else + return FALSE; +#endif +} diff --git a/src/gsr-config-page.h b/src/gsr-config-page.h new file mode 100644 index 0000000..851bd76 --- /dev/null +++ b/src/gsr-config-page.h @@ -0,0 +1,101 @@ +#pragma once + +#include <adwaita.h> +#include "gsr-info.h" +#include "gsr-config.h" + +G_BEGIN_DECLS + +#define GSR_TYPE_CONFIG_PAGE (gsr_config_page_get_type()) +G_DECLARE_FINAL_TYPE(GsrConfigPage, gsr_config_page, GSR, CONFIG_PAGE, AdwPreferencesPage) + +GsrConfigPage *gsr_config_page_new (const GsrInfo *info); +void gsr_config_page_set_advanced (GsrConfigPage *self, + gboolean advanced); +void gsr_config_page_apply_config (GsrConfigPage *self, + const GsrConfig *config); +void gsr_config_page_read_config (GsrConfigPage *self, + GsrConfig *config); + +/* ── Command-line helpers (Phase 5) ──────────────────────────────── */ + +/** + * Get the record area ID currently selected. + * Borrowed pointer, do NOT free. + */ +const char *gsr_config_page_get_record_area_id (GsrConfigPage *self); + +/** + * Get video codec ID. Borrowed pointer, do NOT free. + */ +const char *gsr_config_page_get_video_codec_id (GsrConfigPage *self); + +/** + * Build audio "-a" arguments into a GPtrArray of strings. + * Each string is one "-a" value (e.g. "device:xxx" or merged pipe-delimited). + * merge_tracks: if TRUE, merge all tracks into one pipe-delimited string. + * Caller must free the array with g_ptr_array_unref() (strings owned by array). + */ +GPtrArray *gsr_config_page_build_audio_args (GsrConfigPage *self, + gboolean merge_tracks); + +/** + * Check if app_audio_inverted is enabled. + */ +gboolean gsr_config_page_get_app_audio_inverted(GsrConfigPage *self); + +/* ── Scalar getters for command-line building ──────────────────── */ + +int gsr_config_page_get_fps (GsrConfigPage *self); + +/** + * Get quality ID: "custom", "medium", "high", "very_high", "ultra". + */ +const char *gsr_config_page_get_quality_id (GsrConfigPage *self); + +int gsr_config_page_get_video_bitrate (GsrConfigPage *self); + +/** + * Get color range: "limited" or "full". + */ +const char *gsr_config_page_get_color_range_id (GsrConfigPage *self); + +/** + * Get audio codec: "opus" or "aac". + */ +const char *gsr_config_page_get_audio_codec_id (GsrConfigPage *self); + +/** + * Get framerate mode: "auto", "cfr", or "vfr". + */ +const char *gsr_config_page_get_framerate_mode_id (GsrConfigPage *self); + +gboolean gsr_config_page_get_record_cursor (GsrConfigPage *self); +gboolean gsr_config_page_get_overclock (GsrConfigPage *self); +gboolean gsr_config_page_get_restore_portal_session(GsrConfigPage *self); +gboolean gsr_config_page_get_change_video_resolution(GsrConfigPage *self); +int gsr_config_page_get_video_width (GsrConfigPage *self); +int gsr_config_page_get_video_height (GsrConfigPage *self); +int gsr_config_page_get_area_width (GsrConfigPage *self); +int gsr_config_page_get_area_height (GsrConfigPage *self); +gboolean gsr_config_page_get_split_audio (GsrConfigPage *self); +gboolean gsr_config_page_get_notify_started (GsrConfigPage *self); +gboolean gsr_config_page_get_notify_stopped (GsrConfigPage *self); +gboolean gsr_config_page_get_notify_saved (GsrConfigPage *self); + +/* ── Window picker ──────────────────────────────────────────────── */ + +/** + * Get the X11 window ID selected via the "Select window" picker. + * Returns 0 if no window has been selected. + */ +unsigned long gsr_config_page_get_selected_window (GsrConfigPage *self); + +/** + * Check if we have a valid window selection. + * Returns TRUE if not in "window" mode, or if a window has been picked. + * Returns FALSE if in "window" mode but no window selected yet. + */ +gboolean gsr_config_page_has_valid_window_selection(GsrConfigPage *self); + +G_END_DECLS diff --git a/src/gsr-config.c b/src/gsr-config.c new file mode 100644 index 0000000..21405ea --- /dev/null +++ b/src/gsr-config.c @@ -0,0 +1,591 @@ +#include "gsr-config.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <errno.h> +#include <inttypes.h> +#include <unistd.h> +#include <limits.h> +#include <libgen.h> +#include <pwd.h> +#include <sys/stat.h> + +#include <gtk/gtk.h> + +/* + * X keysym constants for the custom modifier bitmask encoding. + * These are just integer values used for config file serialization — + * they do NOT require X11 headers or libraries at runtime. + */ +#define XK_Shift_L 0xFFE1 +#define XK_Shift_R 0xFFE2 +#define XK_Control_L 0xFFE3 +#define XK_Control_R 0xFFE4 +#define XK_Meta_L 0xFFE7 +#define XK_Meta_R 0xFFE8 +#define XK_Alt_L 0xFFE9 +#define XK_Alt_R 0xFFEA +#define XK_Super_L 0xFFEB +#define XK_Super_R 0xFFEC + +#ifdef HAVE_X11 +#include <X11/Xlib.h> /* ControlMask, ShiftMask, Mod1Mask, Mod4Mask */ +#endif + +/* ═══════════════════════════════════════════════════════════════════ + * gsr-config.c — Config file read/write + * + * File format: one key-value pair per line, space-separated. + * STRING_ARRAY keys appear multiple times (one per element). + * Backward-compatible with the C++ GTK3 version. + * ═══════════════════════════════════════════════════════════════════ */ + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static char * +get_home_dir(void) +{ + const char *home = g_get_home_dir(); + if (home) + return g_strdup(home); + return g_strdup("/tmp"); +} + +char * +gsr_config_get_dir(void) +{ + const char *xdg = g_getenv("XDG_CONFIG_HOME"); + if (xdg && xdg[0]) + return g_build_filename(xdg, "gpu-screen-recorder", NULL); + + char *home = get_home_dir(); + char *result = g_build_filename(home, ".config", "gpu-screen-recorder", NULL); + g_free(home); + return result; +} + +char * +gsr_config_get_videos_dir(void) +{ + const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS); + if (vdir) + return g_strdup(vdir); + char *home = get_home_dir(); + char *result = g_build_filename(home, "Videos", NULL); + g_free(home); + return result; +} + +static int +create_directory_recursive(char *path) +{ + int path_len = (int)strlen(path); + char *p = path; + char *end = path + path_len; + + for (;;) { + char *slash_p = strchr(p, '/'); + + /* Skip leading '/' — don't try to mkdir root */ + if (slash_p == path) { + ++p; + continue; + } + + if (!slash_p) + slash_p = end; + + char prev = *slash_p; + *slash_p = '\0'; + int err = mkdir(path, S_IRWXU); + *slash_p = prev; + + if (err == -1 && errno != EEXIST) + return err; + + if (slash_p == end) + break; + else + p = slash_p + 1; + } + return 0; +} + +/* ── Config value type dispatch ──────────────────────────────────── */ + +typedef enum { + CFG_BOOL, + CFG_STRING, + CFG_I32, + CFG_HOTKEY, + CFG_STRING_ARRAY, +} CfgType; + +typedef struct { + const char *key; + CfgType type; + size_t offset; /* offset into GsrConfig */ + size_t count_offset; /* for STRING_ARRAY: offset of n_audio_input */ +} CfgEntry; + +/* Helper macro to compute offset from a nested config member */ +#define CFG_OFF(section, member) offsetof(GsrConfig, section.member) + +static const CfgEntry config_entries[] = { + /* ── main ── */ + { "main.record_area_option", CFG_STRING, CFG_OFF(main_config, record_area_option), 0 }, + { "main.record_area_width", CFG_I32, CFG_OFF(main_config, record_area_width), 0 }, + { "main.record_area_height", CFG_I32, CFG_OFF(main_config, record_area_height), 0 }, + { "main.video_width", CFG_I32, CFG_OFF(main_config, video_width), 0 }, + { "main.video_height", CFG_I32, CFG_OFF(main_config, video_height), 0 }, + { "main.fps", CFG_I32, CFG_OFF(main_config, fps), 0 }, + { "main.video_bitrate", CFG_I32, CFG_OFF(main_config, video_bitrate), 0 }, + { "main.merge_audio_tracks", CFG_BOOL, CFG_OFF(main_config, merge_audio_tracks), 0 }, + { "main.record_app_audio_inverted", CFG_BOOL, CFG_OFF(main_config, record_app_audio_inverted),0 }, + { "main.change_video_resolution", CFG_BOOL, CFG_OFF(main_config, change_video_resolution), 0 }, + { "main.audio_input", CFG_STRING_ARRAY, CFG_OFF(main_config, audio_input), + CFG_OFF(main_config, n_audio_input) }, + { "main.color_range", CFG_STRING, CFG_OFF(main_config, color_range), 0 }, + { "main.quality", CFG_STRING, CFG_OFF(main_config, quality), 0 }, + { "main.codec", CFG_STRING, CFG_OFF(main_config, codec), 0 }, + { "main.audio_codec", CFG_STRING, CFG_OFF(main_config, audio_codec), 0 }, + { "main.framerate_mode", CFG_STRING, CFG_OFF(main_config, framerate_mode), 0 }, + { "main.advanced_view", CFG_BOOL, CFG_OFF(main_config, advanced_view), 0 }, + { "main.overclock", CFG_BOOL, CFG_OFF(main_config, overclock), 0 }, + { "main.show_recording_started_notifications",CFG_BOOL, CFG_OFF(main_config, show_recording_started_notifications), 0 }, + { "main.show_recording_stopped_notifications",CFG_BOOL, CFG_OFF(main_config, show_recording_stopped_notifications), 0 }, + { "main.show_recording_saved_notifications", CFG_BOOL, CFG_OFF(main_config, show_recording_saved_notifications), 0 }, + { "main.record_cursor", CFG_BOOL, CFG_OFF(main_config, record_cursor), 0 }, + { "main.hide_window_when_recording", CFG_BOOL, CFG_OFF(main_config, hide_window_when_recording),0 }, + { "main.software_encoding_warning_shown", CFG_BOOL, CFG_OFF(main_config, software_encoding_warning_shown),0 }, + { "main.steam_deck_warning_shown", CFG_BOOL, CFG_OFF(main_config, steam_deck_warning_shown), 0 }, + { "main.hevc_amd_bug_warning_shown", CFG_BOOL, CFG_OFF(main_config, hevc_amd_bug_warning_shown),0 }, + { "main.av1_amd_bug_warning_shown", CFG_BOOL, CFG_OFF(main_config, av1_amd_bug_warning_shown),0 }, + { "main.restore_portal_session", CFG_BOOL, CFG_OFF(main_config, restore_portal_session), 0 }, + { "main.use_new_ui", CFG_BOOL, CFG_OFF(main_config, use_new_ui), 0 }, + { "main.installed_gsr_global_hotkeys_version",CFG_I32, CFG_OFF(main_config, installed_gsr_global_hotkeys_version),0 }, + + /* ── streaming ── */ + { "streaming.service", CFG_STRING, CFG_OFF(streaming_config, streaming_service), 0 }, + { "streaming.youtube.key", CFG_STRING, CFG_OFF(streaming_config, youtube_stream_key), 0 }, + { "streaming.twitch.key", CFG_STRING, CFG_OFF(streaming_config, twitch_stream_key), 0 }, + { "streaming.custom.url", CFG_STRING, CFG_OFF(streaming_config, custom_url), 0 }, + { "streaming.custom.container", CFG_STRING, CFG_OFF(streaming_config, custom_container), 0 }, + { "streaming.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(streaming_config, start_stop_hotkey), 0 }, + + /* ── record ── */ + { "record.save_directory", CFG_STRING, CFG_OFF(record_config, save_directory), 0 }, + { "record.container", CFG_STRING, CFG_OFF(record_config, container), 0 }, + { "record.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(record_config, start_stop_hotkey), 0 }, + { "record.pause_unpause_recording_hotkey", CFG_HOTKEY, CFG_OFF(record_config, pause_unpause_hotkey), 0 }, + + /* ── replay ── */ + { "replay.save_directory", CFG_STRING, CFG_OFF(replay_config, save_directory), 0 }, + { "replay.container", CFG_STRING, CFG_OFF(replay_config, container), 0 }, + { "replay.time", CFG_I32, CFG_OFF(replay_config, replay_time), 0 }, + { "replay.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(replay_config, start_stop_hotkey), 0 }, + { "replay.save_recording_hotkey", CFG_HOTKEY, CFG_OFF(replay_config, save_hotkey), 0 }, +}; + +#define N_CONFIG_ENTRIES ((int)(sizeof(config_entries) / sizeof(config_entries[0]))) + +static const CfgEntry * +find_entry(const char *key, int key_len) +{ + for (int i = 0; i < N_CONFIG_ENTRIES; i++) { + if ((int)strlen(config_entries[i].key) == key_len && + memcmp(config_entries[i].key, key, (size_t)key_len) == 0) + return &config_entries[i]; + } + return NULL; +} + +/* ── Default initialization ──────────────────────────────────────── */ + +void +gsr_config_init_defaults(GsrConfig *config) +{ + memset(config, 0, sizeof(*config)); + + GsrMainConfig *m = &config->main_config; + m->record_area_option = g_strdup(""); + m->record_area_width = 0; + m->record_area_height = 0; + m->video_width = 0; + m->video_height = 0; + m->fps = 60; + m->video_bitrate = 15000; + m->merge_audio_tracks = true; + m->record_app_audio_inverted = false; + m->change_video_resolution = false; + m->audio_input = NULL; + m->n_audio_input = 0; + m->color_range = g_strdup("limited"); + m->quality = g_strdup("very_high"); + m->codec = g_strdup("auto"); + m->audio_codec = g_strdup("opus"); + m->framerate_mode = g_strdup("auto"); + m->advanced_view = false; + m->overclock = false; + m->show_recording_started_notifications = false; + m->show_recording_stopped_notifications = false; + m->show_recording_saved_notifications = true; + m->record_cursor = true; + m->hide_window_when_recording = false; + m->restore_portal_session = true; + m->software_encoding_warning_shown = false; + m->steam_deck_warning_shown = false; + m->hevc_amd_bug_warning_shown = false; + m->av1_amd_bug_warning_shown = false; + m->use_new_ui = false; + m->installed_gsr_global_hotkeys_version = 0; + + /* Default hotkeys: Alt+1 = start/stop, Alt+2 = pause/save + * Custom bitmask: Alt_L = 1 << (XK_Alt_L - XK_Shift_L) = 1 << 8 = 256 + * XK_1 = 0x31 = 49, XK_2 = 0x32 = 50 */ + #define DEFAULT_HOTKEY_START_STOP ((GsrConfigHotkey){ .keysym = 49, .modifiers = 256 }) + #define DEFAULT_HOTKEY_SECONDARY ((GsrConfigHotkey){ .keysym = 50, .modifiers = 256 }) + + GsrStreamingConfig *s = &config->streaming_config; + s->streaming_service = g_strdup("twitch"); + s->youtube_stream_key = g_strdup(""); + s->twitch_stream_key = g_strdup(""); + s->custom_url = g_strdup(""); + s->custom_container = g_strdup("flv"); + s->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP; + + GsrRecordConfig *r = &config->record_config; + r->save_directory = gsr_config_get_videos_dir(); + r->container = g_strdup("mp4"); + r->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP; + r->pause_unpause_hotkey = DEFAULT_HOTKEY_SECONDARY; + + GsrReplayConfig *rp = &config->replay_config; + rp->save_directory = gsr_config_get_videos_dir(); + rp->container = g_strdup("mp4"); + rp->replay_time = 30; + rp->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP; + rp->save_hotkey = DEFAULT_HOTKEY_SECONDARY; + + #undef DEFAULT_HOTKEY_START_STOP + #undef DEFAULT_HOTKEY_SECONDARY +} + +/* ── Read ────────────────────────────────────────────────────────── */ + +gboolean +gsr_config_read(GsrConfig *config) +{ + char *config_dir = gsr_config_get_dir(); + char *config_path = g_build_filename(config_dir, "config", NULL); + g_free(config_dir); + + gchar *contents = NULL; + gsize length = 0; + if (!g_file_get_contents(config_path, &contents, &length, NULL)) { + g_free(config_path); + return FALSE; + } + g_free(config_path); + + /* Parse line by line */ + char *p = contents; + char *end = contents + length; + + while (p < end) { + /* Find end of line */ + char *nl = memchr(p, '\n', (size_t)(end - p)); + if (!nl) + nl = end; + + int line_len = (int)(nl - p); + + /* Find space separator between key and value */ + char *sp = memchr(p, ' ', (size_t)line_len); + if (sp && sp > p) { + int key_len = (int)(sp - p); + const char *val = sp + 1; + int val_len = (int)(nl - val); + + if (key_len > 0 && val_len > 0) { + const CfgEntry *entry = find_entry(p, key_len); + if (entry) { + char *base = (char *)config; + switch (entry->type) { + case CFG_BOOL: { + bool *ptr = (bool *)(base + entry->offset); + *ptr = (val_len == 4 && memcmp(val, "true", 4) == 0); + break; + } + case CFG_STRING: { + char **ptr = (char **)(void *)(base + entry->offset); + g_free(*ptr); + *ptr = g_strndup(val, (gsize)val_len); + break; + } + case CFG_I32: { + int32_t *ptr = (int32_t *)(void *)(base + entry->offset); + char tmp[32]; + int copy_len = val_len < 31 ? val_len : 31; + memcpy(tmp, val, (size_t)copy_len); + tmp[copy_len] = '\0'; + if (sscanf(tmp, "%" PRIi32, ptr) != 1) { + *ptr = 0; + } + break; + } + case CFG_HOTKEY: { + GsrConfigHotkey *hk = (GsrConfigHotkey *)(void *)(base + entry->offset); + char tmp[64]; + int copy_len = val_len < 63 ? val_len : 63; + memcpy(tmp, val, (size_t)copy_len); + tmp[copy_len] = '\0'; + if (sscanf(tmp, "%" PRIi64 " %" PRIu32, + &hk->keysym, &hk->modifiers) != 2) { + hk->keysym = 0; + hk->modifiers = 0; + } + break; + } + case CFG_STRING_ARRAY: { + char ***arr_ptr = (char ***)(void *)(base + entry->offset); + int *count_ptr = (int *)(void *)(base + entry->count_offset); + int n = *count_ptr; + *arr_ptr = g_realloc(*arr_ptr, sizeof(char *) * (gsize)(n + 2)); + (*arr_ptr)[n] = g_strndup(val, (gsize)val_len); + (*arr_ptr)[n + 1] = NULL; + *count_ptr = n + 1; + break; + } + } + } + } + } + + p = nl + 1; + } + + g_free(contents); + return TRUE; +} + +/* ── Save ────────────────────────────────────────────────────────── */ + +void +gsr_config_save(const GsrConfig *config) +{ + char *config_dir = gsr_config_get_dir(); + char *config_path = g_build_filename(config_dir, "config", NULL); + + /* Ensure directory exists */ + if (create_directory_recursive(config_dir) != 0) { + g_warning("Failed to create config directory: %s", config_dir); + g_free(config_dir); + g_free(config_path); + return; + } + g_free(config_dir); + + FILE *file = fopen(config_path, "wb"); + if (!file) { + g_warning("Failed to create config file: %s", config_path); + g_free(config_path); + return; + } + g_free(config_path); + + const char *base = (const char *)config; + + for (int i = 0; i < N_CONFIG_ENTRIES; i++) { + const CfgEntry *e = &config_entries[i]; + switch (e->type) { + case CFG_BOOL: { + const bool *ptr = (const bool *)(base + e->offset); + fprintf(file, "%s %s\n", e->key, *ptr ? "true" : "false"); + break; + } + case CFG_STRING: { + const char *const *ptr = (const char *const *)(const void *)(base + e->offset); + fprintf(file, "%s %s\n", e->key, *ptr ? *ptr : ""); + break; + } + case CFG_I32: { + const int32_t *ptr = (const int32_t *)(const void *)(base + e->offset); + fprintf(file, "%s %" PRIi32 "\n", e->key, *ptr); + break; + } + case CFG_HOTKEY: { + const GsrConfigHotkey *hk = (const GsrConfigHotkey *)(const void *)(base + e->offset); + fprintf(file, "%s %" PRIi64 " %" PRIu32 "\n", e->key, + hk->keysym, hk->modifiers); + break; + } + case CFG_STRING_ARRAY: { + char *const *const *arr_ptr = (char *const *const *)(const void *)(base + e->offset); + const int *count_ptr = (const int *)(const void *)(base + e->count_offset); + int n = *count_ptr; + char *const *arr = *arr_ptr; + for (int j = 0; j < n && arr && arr[j]; j++) { + fprintf(file, "%s %s\n", e->key, arr[j]); + } + break; + } + } + } + + fclose(file); +} + +/* ── Clear ───────────────────────────────────────────────────────── */ + +void +gsr_config_clear(GsrConfig *config) +{ + GsrMainConfig *m = &config->main_config; + g_free(m->record_area_option); + g_free(m->color_range); + g_free(m->quality); + g_free(m->codec); + g_free(m->audio_codec); + g_free(m->framerate_mode); + + if (m->audio_input) { + for (int i = 0; i < m->n_audio_input; i++) + g_free(m->audio_input[i]); + g_free(m->audio_input); + } + + GsrStreamingConfig *s = &config->streaming_config; + g_free(s->streaming_service); + g_free(s->youtube_stream_key); + g_free(s->twitch_stream_key); + g_free(s->custom_url); + g_free(s->custom_container); + + g_free(config->record_config.save_directory); + g_free(config->record_config.container); + + g_free(config->replay_config.save_directory); + g_free(config->replay_config.container); + + memset(config, 0, sizeof(*config)); +} + +/* ── Hotkey conversion utilities ─────────────────────────────────── */ + +/* + * The GTK3 code uses a custom bitmask for modifier keys: + * modkey_to_mask(keysym) = 1 << (keysym - XK_Shift_L) + * + * The relevant X keysyms (from <X11/keysym.h>): + * XK_Shift_L = 0xFFE1 → bit 0 + * XK_Shift_R = 0xFFE2 → bit 1 + * XK_Control_L = 0xFFE3 → bit 2 + * XK_Control_R = 0xFFE4 → bit 3 + * XK_Caps_Lock = 0xFFE5 → bit 4 (unused) + * XK_Shift_Lock= 0xFFE6 → bit 5 (unused) + * XK_Meta_L = 0xFFE7 → bit 6 + * XK_Meta_R = 0xFFE8 → bit 7 + * XK_Alt_L = 0xFFE9 → bit 8 + * XK_Alt_R = 0xFFEA → bit 9 + * XK_Super_L = 0xFFEB → bit 10 + * XK_Super_R = 0xFFEC → bit 11 + */ + +#define CUSTOM_MASK(ks) (1u << ((ks) - XK_Shift_L)) + +#define MASK_SHIFT (CUSTOM_MASK(XK_Shift_L) | CUSTOM_MASK(XK_Shift_R)) +#define MASK_CONTROL (CUSTOM_MASK(XK_Control_L) | CUSTOM_MASK(XK_Control_R)) +#define MASK_ALT (CUSTOM_MASK(XK_Alt_L) | CUSTOM_MASK(XK_Alt_R)) +#define MASK_META (CUSTOM_MASK(XK_Meta_L) | CUSTOM_MASK(XK_Meta_R)) +#define MASK_SUPER (CUSTOM_MASK(XK_Super_L) | CUSTOM_MASK(XK_Super_R)) + +char * +gsr_config_hotkey_to_accel(const GsrConfigHotkey *hk) +{ + if (!hk || hk->keysym == 0) + return NULL; + + /* Convert custom modifier bitmask to GdkModifierType */ + GdkModifierType mods = 0; + uint32_t m = hk->modifiers; + + if (m & MASK_SHIFT) mods |= GDK_SHIFT_MASK; + if (m & MASK_CONTROL) mods |= GDK_CONTROL_MASK; + if (m & MASK_ALT) mods |= GDK_ALT_MASK; + if (m & (MASK_META | MASK_SUPER)) + mods |= GDK_SUPER_MASK; + + return gtk_accelerator_name((guint)hk->keysym, mods); +} + +gboolean +gsr_config_hotkey_from_accel(GsrConfigHotkey *hk, const char *accel) +{ + if (!hk) + return FALSE; + + if (!accel || !accel[0]) { + hk->keysym = 0; + hk->modifiers = 0; + return TRUE; + } + + guint keyval = 0; + GdkModifierType mods = 0; + + if (!gtk_accelerator_parse(accel, &keyval, &mods)) + return FALSE; + + if (keyval == 0) { + hk->keysym = 0; + hk->modifiers = 0; + return TRUE; + } + + hk->keysym = (int64_t)keyval; + + /* Convert GdkModifierType back to custom bitmask. + * We use the _L variant for each modifier pair. */ + uint32_t m = 0; + if (mods & GDK_SHIFT_MASK) m |= CUSTOM_MASK(XK_Shift_L); + if (mods & GDK_CONTROL_MASK) m |= CUSTOM_MASK(XK_Control_L); + if (mods & GDK_ALT_MASK) m |= CUSTOM_MASK(XK_Alt_L); + if (mods & GDK_SUPER_MASK) m |= CUSTOM_MASK(XK_Super_L); + hk->modifiers = m; + + return TRUE; +} + +#ifdef HAVE_X11 +void +gsr_config_hotkey_to_x11(const GsrConfigHotkey *hk, + unsigned int *out_x11_modifiers, + uint64_t *out_keysym) +{ + if (!hk) { + if (out_x11_modifiers) *out_x11_modifiers = 0; + if (out_keysym) *out_keysym = 0; + return; + } + + if (out_keysym) + *out_keysym = (uint64_t)hk->keysym; + + if (!out_x11_modifiers) + return; + + /* Convert custom modifier bitmask to X11 mask. + * This matches key_mod_mask_to_x11_mask() from the GTK3 code. */ + uint32_t m = hk->modifiers; + unsigned int x11 = 0; + + if (m & MASK_CONTROL) x11 |= ControlMask; + if (m & MASK_ALT) x11 |= Mod1Mask; + if (m & MASK_SHIFT) x11 |= ShiftMask; + if (m & (MASK_META | MASK_SUPER)) x11 |= Mod4Mask; + + *out_x11_modifiers = x11; +} +#endif /* HAVE_X11 */ diff --git a/src/gsr-config.h b/src/gsr-config.h new file mode 100644 index 0000000..3ed0525 --- /dev/null +++ b/src/gsr-config.h @@ -0,0 +1,175 @@ +#pragma once + +#include <glib.h> +#include <stdbool.h> +#include <stdint.h> + +G_BEGIN_DECLS + +/* ── Hotkey ──────────────────────────────────────────────────────── */ + +typedef struct { + int64_t keysym; + uint32_t modifiers; +} GsrConfigHotkey; + +/* ── Config struct ───────────────────────────────────────────────── */ + +typedef struct { + /* Capture target */ + char *record_area_option; /* "window", "focused", "portal", or monitor name */ + int32_t record_area_width; + int32_t record_area_height; + int32_t video_width; + int32_t video_height; + + /* Video */ + int32_t fps; + int32_t video_bitrate; + char *color_range; /* "limited", "full" */ + char *quality; /* "custom", "medium", "high", "very_high", "ultra" */ + char *codec; /* "auto", "h264", "hevc", etc. */ + char *audio_codec; /* "opus", "aac" */ + char *framerate_mode; /* "auto", "cfr", "vfr" */ + bool overclock; + bool record_cursor; + + /* Audio */ + char **audio_input; /* NULL-terminated array, each "device:xxx" / "app:xxx" */ + int n_audio_input; + bool merge_audio_tracks; + bool record_app_audio_inverted; + + /* Resolution */ + bool change_video_resolution; + + /* Notifications */ + bool show_recording_started_notifications; + bool show_recording_stopped_notifications; + bool show_recording_saved_notifications; + + /* UI state */ + bool advanced_view; + bool hide_window_when_recording; + bool restore_portal_session; + + /* Warning flags (persisted, not shown in UI) */ + bool software_encoding_warning_shown; + bool steam_deck_warning_shown; + bool hevc_amd_bug_warning_shown; + bool av1_amd_bug_warning_shown; + + /* Misc */ + bool use_new_ui; + int32_t installed_gsr_global_hotkeys_version; +} GsrMainConfig; + +typedef struct { + char *streaming_service; /* "twitch", "youtube", "custom" */ + char *youtube_stream_key; + char *twitch_stream_key; + char *custom_url; + char *custom_container; /* "mp4", "flv", "matroska", etc. */ + + GsrConfigHotkey start_stop_hotkey; +} GsrStreamingConfig; + +typedef struct { + char *save_directory; + char *container; + + GsrConfigHotkey start_stop_hotkey; + GsrConfigHotkey pause_unpause_hotkey; +} GsrRecordConfig; + +typedef struct { + char *save_directory; + char *container; + int32_t replay_time; + + GsrConfigHotkey start_stop_hotkey; + GsrConfigHotkey save_hotkey; +} GsrReplayConfig; + +typedef struct { + GsrMainConfig main_config; + GsrStreamingConfig streaming_config; + GsrRecordConfig record_config; + GsrReplayConfig replay_config; +} GsrConfig; + +/* ── API ─────────────────────────────────────────────────────────── */ + +/** + * Initialize config to default values. + * Must be called before gsr_config_read() or manual population. + */ +void gsr_config_init_defaults(GsrConfig *config); + +/** + * Read config from the standard file location. + * Returns TRUE on success, FALSE if file not found or error. + * On failure, config retains its default values. + */ +gboolean gsr_config_read(GsrConfig *config); + +/** + * Save config to the standard file location. + * Creates config directory if needed. + */ +void gsr_config_save(const GsrConfig *config); + +/** + * Free all heap-allocated members (strings, arrays). + * Does NOT free the GsrConfig struct itself. + */ +void gsr_config_clear(GsrConfig *config); + +/** + * Get the config directory path (e.g., ~/.config/gpu-screen-recorder). + * Returns a newly allocated string; caller must g_free(). + */ +char *gsr_config_get_dir(void); + +/** + * Get the default videos directory. + * Returns a newly allocated string; caller must g_free(). + */ +char *gsr_config_get_videos_dir(void); + +/* ── Hotkey conversion utilities ─────────────────────────────────── */ + +/** + * Convert a GsrConfigHotkey (custom modifier bitmask + keysym) to a + * GTK accelerator string like "<Alt>1". + * Returns a newly allocated string, or NULL if the hotkey is empty. + */ +char *gsr_config_hotkey_to_accel(const GsrConfigHotkey *hk); + +/** + * Convert a GTK accelerator string like "<Alt>1" to a GsrConfigHotkey. + * Returns TRUE on success, FALSE if the accel string is invalid. + * If accel is NULL, the hotkey is cleared (keysym=0, modifiers=0). + */ +gboolean gsr_config_hotkey_from_accel(GsrConfigHotkey *hk, const char *accel); + +#ifdef HAVE_X11 +/** + * Convert a GsrConfigHotkey to an X11 modifier mask suitable for XGrabKey. + * The keysym is returned unchanged in *out_keysym. + */ +void gsr_config_hotkey_to_x11(const GsrConfigHotkey *hk, + unsigned int *out_x11_modifiers, + uint64_t *out_keysym); +#endif /* HAVE_X11 */ + +/** + * Check if a hotkey is empty (no key assigned). + */ +static inline gboolean +gsr_config_hotkey_is_empty(const GsrConfigHotkey *hk) +{ + return hk->keysym == 0; +} + +G_END_DECLS diff --git a/src/gsr-hotkeys.c b/src/gsr-hotkeys.c new file mode 100644 index 0000000..5c1b7f7 --- /dev/null +++ b/src/gsr-hotkeys.c @@ -0,0 +1,435 @@ +#include "gsr-hotkeys.h" +#include "gsr-window.h" +#include "gsr-config.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +/* + * On X11 we use gsr-x11-hotkeys.h for XGrabKey + GSource polling. + * On Wayland we use global_shortcuts.h for the D-Bus portal. + * + * Each backend is conditionally compiled via HAVE_X11 / HAVE_WAYLAND. + */ + +#ifdef HAVE_X11 +#include "gsr-x11-hotkeys.h" +#include <X11/keysym.h> +#ifdef GDK_WINDOWING_X11 +#include <gdk/x11/gdkx.h> +#endif +#endif /* HAVE_X11 */ + +#ifdef HAVE_WAYLAND +#include "global_shortcuts.h" +#endif + +/* ── Shortcut IDs (Wayland portal) ────────────────────────────────── */ + +#ifdef HAVE_WAYLAND +#define SHORTCUT_ID_START_STOP "gpu_screen_recorder_start_stop_recording" +#define SHORTCUT_ID_PAUSE_UNPAUSE "gpu_screen_recorder_pause_unpause_recording" +#define SHORTCUT_ID_SAVE_REPLAY "gpu_screen_recorder_save_replay" +#endif + +/* ── Internal state ──────────────────────────────────────────────── */ + +typedef enum { + HOTKEY_ACTION_START_STOP, + HOTKEY_ACTION_PAUSE_UNPAUSE, + HOTKEY_ACTION_SAVE_REPLAY, +} HotkeyAction; + +#ifdef HAVE_X11 +typedef struct { + unsigned int x11_modifiers; + KeySym keysym; + HotkeyAction action; +} GrabbedCombo; + +#define MAX_ACTIVE_COMBOS 4 +#endif /* HAVE_X11 */ + +struct _GsrHotkeys { + GsrDisplayServer display_server; + GsrWindow *window; /* NOT owned */ + +#ifdef HAVE_X11 + /* X11 backend */ + GsrX11Hotkeys *x11; + + /* Currently grabbed combos and what action they map to */ + GrabbedCombo active_combos[MAX_ACTIVE_COMBOS]; + int n_active_combos; +#endif /* HAVE_X11 */ + +#ifdef HAVE_WAYLAND + /* Wayland backend */ + gsr_global_shortcuts wayland; + bool wayland_initialized; + bool wayland_shortcuts_bound; + bool wayland_shortcuts_received; +#endif /* HAVE_WAYLAND */ +}; + +/* ── Forward declarations ────────────────────────────────────────── */ + +#ifdef HAVE_X11 +static void on_x11_hotkey(unsigned int modifiers, KeySym keysym, void *userdata); +#endif + +#ifdef HAVE_WAYLAND +static void on_wayland_init(bool success, void *userdata); +static void on_wayland_deactivated(const char *shortcut_id, void *userdata); +static void on_wayland_shortcut_changed(gsr_shortcut shortcut, void *userdata); +#endif + +/* ── Hotkey action dispatch (shared by both backends) ────────────── */ + +/** + * Get the name of the currently visible page in the AdwViewStack. + * Returns a borrowed string like "stream", "record", "replay", "config". + */ +static const char * +get_visible_page_name(GsrHotkeys *self) +{ + return gsr_window_get_visible_page_name(self->window); +} + +static void +dispatch_start_stop(GsrHotkeys *self) +{ + gsr_window_hotkey_start_stop(self->window); +} + +static void +dispatch_pause_unpause(GsrHotkeys *self) +{ + gsr_window_hotkey_pause_unpause(self->window); +} + +static void +dispatch_save_replay(GsrHotkeys *self) +{ + gsr_window_hotkey_save_replay(self->window); +} + +/* ── X11 callback ────────────────────────────────────────────────── */ + +#ifdef HAVE_X11 +static void +on_x11_hotkey(unsigned int modifiers, KeySym keysym, void *userdata) +{ + GsrHotkeys *self = userdata; + + /* Find which action this combo maps to */ + HotkeyAction action = HOTKEY_ACTION_START_STOP; + bool found = false; + + for (int i = 0; i < self->n_active_combos; i++) { + if (self->active_combos[i].keysym == keysym && + self->active_combos[i].x11_modifiers == modifiers) + { + action = self->active_combos[i].action; + found = true; + break; + } + } + + if (!found) { + /* Fallback: match by keysym only (modifier might have extra bits + * from NumLock/CapsLock that XGrabKey variants handle) */ + unsigned int base_mods = modifiers & (ControlMask | ShiftMask | Mod1Mask | Mod4Mask); + for (int i = 0; i < self->n_active_combos; i++) { + if (self->active_combos[i].keysym == keysym && + self->active_combos[i].x11_modifiers == base_mods) + { + action = self->active_combos[i].action; + found = true; + break; + } + } + } + + if (!found) + return; + + switch (action) { + case HOTKEY_ACTION_START_STOP: + dispatch_start_stop(self); + break; + case HOTKEY_ACTION_PAUSE_UNPAUSE: + dispatch_pause_unpause(self); + break; + case HOTKEY_ACTION_SAVE_REPLAY: + dispatch_save_replay(self); + break; + } +} +#endif /* HAVE_X11 */ + +/* ── Wayland callbacks ───────────────────────────────────────────── */ + +#ifdef HAVE_WAYLAND +static void +on_wayland_init(bool success, void *userdata) +{ + GsrHotkeys *self = userdata; + self->wayland_initialized = success; + if (success) { + g_debug("Wayland global shortcuts session created"); + gsr_global_shortcuts_subscribe_activated_signal( + &self->wayland, on_wayland_deactivated, + on_wayland_shortcut_changed, self); + } else { + fprintf(stderr, "gsr warning: Wayland global shortcuts init failed\n"); + } + + /* Notify the window so it can update the UI */ + gsr_window_on_wayland_hotkeys_init(self->window, success); +} + +static void +on_wayland_deactivated(const char *shortcut_id, void *userdata) +{ + GsrHotkeys *self = userdata; + + const char *page = get_visible_page_name(self); + if (!page) + return; + + /* The portal uses 3 shared IDs across all modes. + * Dispatch based on the currently visible page. */ + + if (strcmp(shortcut_id, SHORTCUT_ID_START_STOP) == 0) { + if (g_str_equal(page, "stream") || + g_str_equal(page, "record") || + g_str_equal(page, "replay")) + { + dispatch_start_stop(self); + } + } else if (strcmp(shortcut_id, SHORTCUT_ID_PAUSE_UNPAUSE) == 0) { + if (g_str_equal(page, "record")) + dispatch_pause_unpause(self); + } else if (strcmp(shortcut_id, SHORTCUT_ID_SAVE_REPLAY) == 0) { + if (g_str_equal(page, "replay")) + dispatch_save_replay(self); + } +} + +static void +on_wayland_shortcut_changed(gsr_shortcut shortcut, void *userdata) +{ + GsrHotkeys *self = userdata; + self->wayland_shortcuts_received = true; + g_debug("Wayland shortcut changed: id=%s trigger=%s", + shortcut.id, shortcut.trigger_description); +} +#endif /* HAVE_WAYLAND */ + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrHotkeys * +gsr_hotkeys_new(GsrDisplayServer display_server, GsrWindow *window) +{ + GsrHotkeys *self = calloc(1, sizeof(GsrHotkeys)); + if (!self) + return NULL; + + self->display_server = display_server; + self->window = window; + +#ifdef HAVE_X11 + if (display_server == GSR_DISPLAY_SERVER_X11) { + /* + * Get the X11 Display from GDK. + * We use gdk_x11_display_get_xdisplay() from <gdk/x11/gdkx.h>. + */ + GdkDisplay *gdk_dpy = gdk_display_get_default(); + if (!gdk_dpy) { + fprintf(stderr, "gsr warning: no default GDK display\n"); + free(self); + return NULL; + } + + /* GTK4 X11 backend */ + Display *xdisplay = NULL; + + /* Use the GDK X11 function if available */ +#ifdef GDK_WINDOWING_X11 + G_GNUC_BEGIN_IGNORE_DEPRECATIONS + xdisplay = gdk_x11_display_get_xdisplay(gdk_dpy); + G_GNUC_END_IGNORE_DEPRECATIONS +#endif + + if (!xdisplay) { + fprintf(stderr, "gsr warning: could not get X11 display from GDK\n"); + free(self); + return NULL; + } + + self->x11 = gsr_x11_hotkeys_new(xdisplay, on_x11_hotkey, self); + if (!self->x11) { + fprintf(stderr, "gsr warning: failed to create X11 hotkey watcher\n"); + free(self); + return NULL; + } + + g_debug("X11 hotkey manager initialized"); + + } +#endif /* HAVE_X11 */ + +#ifdef HAVE_WAYLAND + if (display_server == GSR_DISPLAY_SERVER_WAYLAND) { + /* TODO: Skip Hyprland due to portal crash bug */ + if (!gsr_global_shortcuts_init(&self->wayland, on_wayland_init, self)) { + fprintf(stderr, "gsr warning: failed to initialize Wayland global shortcuts\n"); + /* Non-fatal: hotkeys just won't work */ + } + } +#endif /* HAVE_WAYLAND */ + + return self; +} + +void +gsr_hotkeys_free(GsrHotkeys *self) +{ + if (!self) + return; + +#ifdef HAVE_X11 + if (self->x11) { + gsr_x11_hotkeys_free(self->x11); + self->x11 = NULL; + } +#endif + +#ifdef HAVE_WAYLAND + if (self->wayland_initialized) { + gsr_global_shortcuts_deinit(&self->wayland); + self->wayland_initialized = false; + } +#endif + + free(self); +} + +/* ── Helper: grab a hotkey from config and track its action ──────── */ + +#ifdef HAVE_X11 +static void +grab_hotkey_from_config(GsrHotkeys *self, + const GsrConfigHotkey *hk, + HotkeyAction action) +{ + if (gsr_config_hotkey_is_empty(hk)) + return; + + unsigned int x11_mods = 0; + uint64_t keysym = 0; + gsr_config_hotkey_to_x11(hk, &x11_mods, &keysym); + + if (keysym == 0) + return; + + GsrX11HotkeyCombo combo = { .modifiers = x11_mods, .keysym = (KeySym)keysym }; + if (!gsr_x11_hotkeys_grab(self->x11, combo)) { + g_warning("Failed to grab hotkey (keysym=0x%lx, mods=0x%x)", + (unsigned long)keysym, x11_mods); + return; + } + + if (self->n_active_combos < MAX_ACTIVE_COMBOS) { + self->active_combos[self->n_active_combos] = (GrabbedCombo){ + .x11_modifiers = x11_mods, + .keysym = (KeySym)keysym, + .action = action, + }; + self->n_active_combos++; + } +} + +void +gsr_hotkeys_regrab_for_visible_page(GsrHotkeys *self) +{ + if (!self || self->display_server != GSR_DISPLAY_SERVER_X11 || !self->x11) + return; + + /* Ungrab everything first */ + gsr_x11_hotkeys_ungrab_all(self->x11); + self->n_active_combos = 0; + + const char *page = get_visible_page_name(self); + if (!page) + return; + + const GsrConfig *config = gsr_window_get_config(self->window); + if (!config) + return; + + /* Only grab hotkeys for action pages (not config) */ + if (g_str_equal(page, "stream")) { + grab_hotkey_from_config(self, + &config->streaming_config.start_stop_hotkey, + HOTKEY_ACTION_START_STOP); + + } else if (g_str_equal(page, "record")) { + grab_hotkey_from_config(self, + &config->record_config.start_stop_hotkey, + HOTKEY_ACTION_START_STOP); + grab_hotkey_from_config(self, + &config->record_config.pause_unpause_hotkey, + HOTKEY_ACTION_PAUSE_UNPAUSE); + + } else if (g_str_equal(page, "replay")) { + grab_hotkey_from_config(self, + &config->replay_config.start_stop_hotkey, + HOTKEY_ACTION_START_STOP); + grab_hotkey_from_config(self, + &config->replay_config.save_hotkey, + HOTKEY_ACTION_SAVE_REPLAY); + } +} +#endif /* HAVE_X11 */ + +#ifdef HAVE_WAYLAND +void +gsr_hotkeys_register_wayland_shortcuts_once(GsrHotkeys *self) +{ + if (!self || self->display_server != GSR_DISPLAY_SERVER_WAYLAND) + return; + + if (!self->wayland_initialized) + return; + + /* On GNOME: bind once. On KDE: the user clicks a button (deferred to Phase 7). */ + if (self->wayland_shortcuts_bound || self->wayland_shortcuts_received) + return; + + self->wayland_shortcuts_bound = true; + + const gsr_bind_shortcut shortcuts[3] = { + { + .description = "Start/stop recording/replay/streaming", + .shortcut = { .id = SHORTCUT_ID_START_STOP, .trigger_description = "ALT+1" }, + }, + { + .description = "Pause/unpause recording", + .shortcut = { .id = SHORTCUT_ID_PAUSE_UNPAUSE, .trigger_description = "ALT+2" }, + }, + { + .description = "Save replay", + .shortcut = { .id = SHORTCUT_ID_SAVE_REPLAY, .trigger_description = "ALT+3" }, + }, + }; + + if (!gsr_global_shortcuts_bind_shortcuts(&self->wayland, shortcuts, 3, + on_wayland_shortcut_changed, self)) + { + fprintf(stderr, "gsr warning: failed to bind Wayland shortcuts\n"); + } +} +#endif /* HAVE_WAYLAND */ diff --git a/src/gsr-hotkeys.h b/src/gsr-hotkeys.h new file mode 100644 index 0000000..e95ad58 --- /dev/null +++ b/src/gsr-hotkeys.h @@ -0,0 +1,52 @@ +#pragma once + +/* + * gsr-hotkeys.h — Unified hotkey manager for X11 and Wayland. + * + * Detects the display server at runtime and uses either: + * - X11: XGrabKey + GSource polling (gsr-x11-hotkeys) + * - Wayland: XDG GlobalShortcuts portal (global_shortcuts) + * + * Dispatches hotkey actions to the appropriate page based on the + * currently visible tab (page-scoped dispatch). + */ + +#include <stdbool.h> +#include "gsr-info.h" + +/* Forward declare — the hotkeys manager calls back into the window */ +typedef struct _GsrWindow GsrWindow; + +typedef struct _GsrHotkeys GsrHotkeys; + +/** + * Create a hotkey manager for the given display server type. + * The window pointer is used for page-scoped dispatch. + * Returns NULL on failure. + */ +GsrHotkeys *gsr_hotkeys_new(GsrDisplayServer display_server, + GsrWindow *window); + +/** + * Destroy the hotkey manager and release all resources. + */ +void gsr_hotkeys_free(GsrHotkeys *self); + +/** + * Re-grab hotkeys for the visible page (X11 only). + * On Wayland this is a no-op since the portal handles page dispatch + * in the deactivated callback. + * + * Call this whenever the visible tab changes. + */ +#ifdef HAVE_X11 +void gsr_hotkeys_regrab_for_visible_page(GsrHotkeys *self); +#endif + +/** + * On Wayland (GNOME), bind shortcuts if not yet done. + * Should be called once when first navigating to an action page. + */ +#ifdef HAVE_WAYLAND +void gsr_hotkeys_register_wayland_shortcuts_once(GsrHotkeys *self); +#endif diff --git a/src/gsr-info.c b/src/gsr-info.c new file mode 100644 index 0000000..04da95c --- /dev/null +++ b/src/gsr-info.c @@ -0,0 +1,393 @@ +#include "gsr-info.h" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/wait.h> + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static char * +read_command_output(const char *cmd, int *exit_code) +{ + FILE *f = popen(cmd, "r"); + if (!f) { + if (exit_code) *exit_code = -1; + return NULL; + } + + size_t capacity = 8192; + size_t len = 0; + char *buf = g_malloc(capacity); + + for (;;) { + size_t n = fread(buf + len, 1, capacity - len - 1, f); + if (n == 0) break; + len += n; + if (len + 1 >= capacity) { + capacity *= 2; + buf = g_realloc(buf, capacity); + } + } + buf[len] = '\0'; + + int status = pclose(f); + if (exit_code) { + if (WIFEXITED(status)) + *exit_code = WEXITSTATUS(status); + else + *exit_code = -1; + } + return buf; +} + +typedef void (*LineCallback)(const char *line, size_t len, void *user_data); + +static void +for_each_line(const char *text, LineCallback cb, void *user_data) +{ + const char *p = text; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len > 0) + cb(p, len, user_data); + if (!nl) break; + p = nl + 1; + } +} + +static bool +str_eq(const char *s, size_t len, const char *literal) +{ + size_t llen = strlen(literal); + return len == llen && memcmp(s, literal, llen) == 0; +} + +static bool +str_starts_with(const char *s, size_t len, const char *prefix) +{ + size_t plen = strlen(prefix); + return len >= plen && memcmp(s, prefix, plen) == 0; +} + +/* ── Section parsing ─────────────────────────────────────────────── */ + +typedef enum { + SECTION_UNKNOWN, + SECTION_SYSTEM_INFO, + SECTION_GPU_INFO, + SECTION_VIDEO_CODECS, + SECTION_CAPTURE_OPTIONS, +} InfoSection; + +typedef struct { + GsrInfo *info; + InfoSection section; + /* temporary growable monitor array */ + GsrMonitor *monitors; + int n_monitors; + int monitors_capacity; +} ParseState; + +static void +parse_system_info(ParseState *st, const char *line, size_t len) +{ + const char *sep = memchr(line, '|', len); + if (!sep) return; + + size_t klen = (size_t)(sep - line); + const char *val = sep + 1; + size_t vlen = len - klen - 1; + + if (str_eq(line, klen, "display_server")) { + if (str_eq(val, vlen, "x11")) + st->info->system_info.display_server = GSR_DISPLAY_SERVER_X11; + else if (str_eq(val, vlen, "wayland")) + st->info->system_info.display_server = GSR_DISPLAY_SERVER_WAYLAND; + } else if (str_eq(line, klen, "is_steam_deck")) { + st->info->system_info.is_steam_deck = str_eq(val, vlen, "yes"); + } else if (str_eq(line, klen, "supports_app_audio")) { + st->info->system_info.supports_app_audio = str_eq(val, vlen, "yes"); + } +} + +static void +parse_gpu_info(ParseState *st, const char *line, size_t len) +{ + const char *sep = memchr(line, '|', len); + if (!sep) return; + + size_t klen = (size_t)(sep - line); + const char *val = sep + 1; + size_t vlen = len - klen - 1; + + if (str_eq(line, klen, "vendor")) { + if (str_eq(val, vlen, "amd")) + st->info->gpu_info.vendor = GSR_GPU_VENDOR_AMD; + else if (str_eq(val, vlen, "intel")) + st->info->gpu_info.vendor = GSR_GPU_VENDOR_INTEL; + else if (str_eq(val, vlen, "nvidia")) + st->info->gpu_info.vendor = GSR_GPU_VENDOR_NVIDIA; + else if (str_eq(val, vlen, "broadcom")) + st->info->gpu_info.vendor = GSR_GPU_VENDOR_BROADCOM; + } +} + +static void +parse_video_codecs(ParseState *st, const char *line, size_t len) +{ + GsrSupportedVideoCodecs *vc = &st->info->supported_video_codecs; + + if (str_eq(line, len, "h264")) vc->h264 = true; + else if (str_eq(line, len, "h264_software")) vc->h264_software = true; + else if (str_eq(line, len, "hevc")) vc->hevc = true; + else if (str_eq(line, len, "hevc_hdr")) vc->hevc_hdr = true; + else if (str_eq(line, len, "hevc_10bit")) vc->hevc_10bit = true; + else if (str_eq(line, len, "av1")) vc->av1 = true; + else if (str_eq(line, len, "av1_hdr")) vc->av1_hdr = true; + else if (str_eq(line, len, "av1_10bit")) vc->av1_10bit = true; + else if (str_eq(line, len, "vp8")) vc->vp8 = true; + else if (str_eq(line, len, "vp9")) vc->vp9 = true; +} + +static void +parse_capture_options(ParseState *st, const char *line, size_t len) +{ + if (str_eq(line, len, "window")) { + st->info->supported_capture_options.window = true; + } else if (str_eq(line, len, "focused")) { + st->info->supported_capture_options.focused = true; + } else if (str_eq(line, len, "portal")) { + st->info->supported_capture_options.portal = true; + } else if (str_eq(line, len, "region") || (len > 0 && line[0] == '/')) { + /* skip "region" and DRM card paths */ + } else { + /* monitor entry: name|WxH */ + if (st->n_monitors >= st->monitors_capacity) { + st->monitors_capacity = st->monitors_capacity ? st->monitors_capacity * 2 : 8; + st->monitors = g_realloc_n(st->monitors, (size_t)st->monitors_capacity, + sizeof(GsrMonitor)); + } + GsrMonitor *m = &st->monitors[st->n_monitors]; + memset(m, 0, sizeof(*m)); + + const char *sep = memchr(line, '|', len); + if (sep) { + size_t name_len = (size_t)(sep - line); + m->name = g_strndup(line, name_len); + sscanf(sep + 1, "%dx%d", &m->width, &m->height); + } else { + m->name = g_strndup(line, len); + } + st->n_monitors++; + } +} + +static void +info_line_cb(const char *line, size_t len, void *user_data) +{ + ParseState *st = user_data; + + if (str_starts_with(line, len, "section=")) { + const char *name = line + 8; + size_t nlen = len - 8; + if (str_eq(name, nlen, "system_info")) + st->section = SECTION_SYSTEM_INFO; + else if (str_eq(name, nlen, "gpu_info")) + st->section = SECTION_GPU_INFO; + else if (str_eq(name, nlen, "video_codecs")) + st->section = SECTION_VIDEO_CODECS; + else if (str_eq(name, nlen, "capture_options")) + st->section = SECTION_CAPTURE_OPTIONS; + else + st->section = SECTION_UNKNOWN; + return; + } + + switch (st->section) { + case SECTION_SYSTEM_INFO: parse_system_info(st, line, len); break; + case SECTION_GPU_INFO: parse_gpu_info(st, line, len); break; + case SECTION_VIDEO_CODECS: parse_video_codecs(st, line, len); break; + case SECTION_CAPTURE_OPTIONS: parse_capture_options(st, line, len); break; + case SECTION_UNKNOWN: break; + } +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrInfoExitStatus +gsr_info_load(GsrInfo *info) +{ + memset(info, 0, sizeof(*info)); + + int exit_code = -1; + char *output = read_command_output("gpu-screen-recorder --info", &exit_code); + if (!output) { + fprintf(stderr, "error: 'gpu-screen-recorder --info' failed to run\n"); + return GSR_INFO_EXIT_FAILED_TO_RUN; + } + + ParseState st = { + .info = info, + .section = SECTION_UNKNOWN, + }; + for_each_line(output, info_line_cb, &st); + g_free(output); + + /* Transfer monitors */ + info->supported_capture_options.monitors = st.monitors; + info->supported_capture_options.n_monitors = st.n_monitors; + + switch (exit_code) { + case 0: return GSR_INFO_EXIT_OK; + case 22: return GSR_INFO_EXIT_OPENGL_FAILED; + case 23: return GSR_INFO_EXIT_NO_DRM_CARD; + default: return GSR_INFO_EXIT_FAILED_TO_RUN; + } +} + +void +gsr_info_clear(GsrInfo *info) +{ + for (int i = 0; i < info->supported_capture_options.n_monitors; i++) + g_free(info->supported_capture_options.monitors[i].name); + g_free(info->supported_capture_options.monitors); + memset(info, 0, sizeof(*info)); +} + +bool +gsr_info_is_codec_supported(const GsrInfo *info, const char *codec_id) +{ + if (strcmp(codec_id, "auto") == 0) return true; + + const GsrSupportedVideoCodecs *vc = &info->supported_video_codecs; + if (strcmp(codec_id, "h264") == 0) return vc->h264; + if (strcmp(codec_id, "h264_software") == 0) return vc->h264_software; + if (strcmp(codec_id, "hevc") == 0) return vc->hevc; + if (strcmp(codec_id, "hevc_hdr") == 0) return vc->hevc_hdr; + if (strcmp(codec_id, "hevc_10bit") == 0) return vc->hevc_10bit; + if (strcmp(codec_id, "av1") == 0) return vc->av1; + if (strcmp(codec_id, "av1_hdr") == 0) return vc->av1_hdr; + if (strcmp(codec_id, "av1_10bit") == 0) return vc->av1_10bit; + if (strcmp(codec_id, "vp8") == 0) return vc->vp8; + if (strcmp(codec_id, "vp9") == 0) return vc->vp9; + return false; +} + +bool +gsr_info_is_capture_option_enabled(const GsrInfo *info, const char *option_id) +{ + if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) { + if (strcmp(option_id, "window") == 0 || strcmp(option_id, "focused") == 0) + return false; + } + if (strcmp(option_id, "portal") == 0) + return info->supported_capture_options.portal; + return true; +} + +const char * +gsr_info_get_first_usable_hw_video_codec(const GsrInfo *info) +{ + const GsrSupportedVideoCodecs *vc = &info->supported_video_codecs; + if (vc->h264) return "h264"; + if (vc->hevc) return "hevc"; + if (vc->av1) return "av1"; + if (vc->vp8) return "vp8"; + if (vc->vp9) return "vp9"; + return NULL; +} + +/* ── Audio device queries ────────────────────────────────────────── */ + +GsrAudioDevice * +gsr_audio_devices_get(int *n_devices) +{ + *n_devices = 0; + + int exit_code = -1; + char *output = read_command_output("gpu-screen-recorder --list-audio-devices", &exit_code); + if (!output) return NULL; + + /* Count lines */ + int capacity = 16; + GsrAudioDevice *devs = g_new0(GsrAudioDevice, capacity); + int count = 0; + + const char *p = output; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len > 0) { + const char *sep = memchr(p, '|', len); + if (sep) { + if (count >= capacity) { + capacity *= 2; + devs = g_realloc_n(devs, (size_t)capacity, sizeof(GsrAudioDevice)); + } + devs[count].name = g_strndup(p, (size_t)(sep - p)); + devs[count].description = g_strndup(sep + 1, len - (size_t)(sep - p) - 1); + count++; + } + } + if (!nl) break; + p = nl + 1; + } + + g_free(output); + *n_devices = count; + return devs; +} + +void +gsr_audio_devices_free(GsrAudioDevice *devices, int n_devices) +{ + for (int i = 0; i < n_devices; i++) { + g_free(devices[i].name); + g_free(devices[i].description); + } + g_free(devices); +} + +char ** +gsr_application_audio_get(int *n_apps) +{ + *n_apps = 0; + + int exit_code = -1; + char *output = read_command_output("gpu-screen-recorder --list-application-audio", &exit_code); + if (!output) return NULL; + + int capacity = 16; + char **apps = g_new0(char *, capacity + 1); + int count = 0; + + const char *p = output; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len > 0) { + if (count >= capacity) { + capacity *= 2; + apps = g_realloc_n(apps, (size_t)(capacity + 1), sizeof(char *)); + } + apps[count] = g_strndup(p, len); + count++; + } + if (!nl) break; + p = nl + 1; + } + apps[count] = NULL; + + g_free(output); + *n_apps = count; + return apps; +} + +void +gsr_application_audio_free(char **apps, int n_apps) +{ + for (int i = 0; i < n_apps; i++) + g_free(apps[i]); + g_free(apps); +} diff --git a/src/gsr-info.h b/src/gsr-info.h new file mode 100644 index 0000000..9764fee --- /dev/null +++ b/src/gsr-info.h @@ -0,0 +1,111 @@ +#pragma once + +#include <glib.h> +#include <stdbool.h> + +G_BEGIN_DECLS + +/* ── Enums ───────────────────────────────────────────────────────── */ + +typedef enum { + GSR_DISPLAY_SERVER_UNKNOWN, + GSR_DISPLAY_SERVER_X11, + GSR_DISPLAY_SERVER_WAYLAND, +} GsrDisplayServer; + +typedef enum { + GSR_GPU_VENDOR_UNKNOWN, + GSR_GPU_VENDOR_AMD, + GSR_GPU_VENDOR_INTEL, + GSR_GPU_VENDOR_NVIDIA, + GSR_GPU_VENDOR_BROADCOM, +} GsrGpuVendor; + +typedef enum { + GSR_INFO_EXIT_OK, + GSR_INFO_EXIT_FAILED_TO_RUN, + GSR_INFO_EXIT_OPENGL_FAILED, + GSR_INFO_EXIT_NO_DRM_CARD, +} GsrInfoExitStatus; + +/* ── Data structures ─────────────────────────────────────────────── */ + +typedef struct { + GsrDisplayServer display_server; + bool supports_app_audio; + bool is_steam_deck; +} GsrSystemInfo; + +typedef struct { + GsrGpuVendor vendor; +} GsrGpuInfo; + +typedef struct { + bool h264; + bool h264_software; + bool hevc; + bool hevc_hdr; + bool hevc_10bit; + bool av1; + bool av1_hdr; + bool av1_10bit; + bool vp8; + bool vp9; +} GsrSupportedVideoCodecs; + +typedef struct { + char *name; /* owned */ + int width; + int height; +} GsrMonitor; + +typedef struct { + bool window; + bool focused; + bool portal; + GsrMonitor *monitors; /* owned array */ + int n_monitors; +} GsrSupportedCaptureOptions; + +typedef struct { + GsrSystemInfo system_info; + GsrGpuInfo gpu_info; + GsrSupportedVideoCodecs supported_video_codecs; + GsrSupportedCaptureOptions supported_capture_options; +} GsrInfo; + +/* ── Audio devices ───────────────────────────────────────────────── */ + +typedef struct { + char *name; /* PulseAudio/PipeWire ID, owned */ + char *description; /* human-readable label, owned */ +} GsrAudioDevice; + +/* ── Functions ───────────────────────────────────────────────────── */ + +GsrInfoExitStatus gsr_info_load (GsrInfo *info); +void gsr_info_clear (GsrInfo *info); + +bool gsr_info_is_codec_supported + (const GsrInfo *info, + const char *codec_id); + +bool gsr_info_is_capture_option_enabled + (const GsrInfo *info, + const char *option_id); + +/** + * Returns the first usable HW video codec name (h264 > hevc > av1 > vp8 > vp9), + * or NULL if none supported. + */ +const char *gsr_info_get_first_usable_hw_video_codec(const GsrInfo *info); + +/* Audio device / application queries (separate commands) */ +GsrAudioDevice *gsr_audio_devices_get (int *n_devices); +void gsr_audio_devices_free (GsrAudioDevice *devices, + int n_devices); + +char **gsr_application_audio_get (int *n_apps); +void gsr_application_audio_free(char **apps, int n_apps); + +G_END_DECLS diff --git a/src/gsr-record-page.c b/src/gsr-record-page.c new file mode 100644 index 0000000..a9575c4 --- /dev/null +++ b/src/gsr-record-page.c @@ -0,0 +1,747 @@ +#include "gsr-record-page.h" +#ifdef HAVE_X11 +#include "gsr-shortcut-accel-dialog.h" +#endif +#include "gsr-window.h" +#include <glib/gi18n.h> +#include <signal.h> +#include <time.h> + +/* ═══════════════════════════════════════════════════════════════════ + * GsrRecordPage — "Record" tab + * + * Groups: Output · Action · Status + * ═══════════════════════════════════════════════════════════════════ */ + +struct _GsrRecordPage { + AdwPreferencesPage parent_instance; + + const GsrInfo *info; /* borrowed */ + + /* ── Hotkey group ─── */ + AdwPreferencesGroup *hotkey_group; +#ifdef HAVE_WAYLAND + GtkWidget *hotkey_not_supported_label; /* Wayland only */ + GtkWidget *hotkey_info_row; /* Wayland only */ +#endif + +#ifdef HAVE_X11 + /* X11 interactive hotkey rows */ + AdwActionRow *x11_start_stop_row; + GtkShortcutLabel *x11_start_stop_label; + char *x11_start_stop_accel; /* owned */ + AdwActionRow *x11_pause_row; + GtkShortcutLabel *x11_pause_label; + char *x11_pause_accel; /* owned */ +#endif + + /* ── Output group ─── */ + AdwPreferencesGroup *output_group; + AdwActionRow *save_dir_row; + char *save_directory; /* owned */ + AdwComboRow *container_row; + + /* ── Action group ─── */ + AdwPreferencesGroup *action_group; + GtkButton *start_button; + GtkButton *pause_button; + + /* ── Status group ─── */ + AdwPreferencesGroup *status_group; + GtkBox *status_box; + GtkImage *record_icon; + GtkLabel *timer_label; + + gboolean is_active; + gboolean is_paused; + double start_time; + double paused_time_offset; + double pause_start; + guint timer_source_id; +}; + +G_DEFINE_FINAL_TYPE(GsrRecordPage, gsr_record_page, ADW_TYPE_PREFERENCES_PAGE) + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static char * +get_default_videos_dir(void) +{ + const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS); + if (vdir) + return g_strdup(vdir); + return g_build_filename(g_get_home_dir(), "Videos", NULL); +} + +/* ── Timer helpers ───────────────────────────────────────────────── */ + +static double +clock_get_monotonic_seconds(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +static void +format_timer(double seconds, char *buf, size_t buf_size) +{ + int total = (int)seconds; + int h = total / 3600; + int m = (total % 3600) / 60; + int s = total % 60; + snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s); +} + +static gboolean +on_timer_tick(gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + double now = clock_get_monotonic_seconds(); + double elapsed = now - self->start_time - self->paused_time_offset; + /* If currently paused, subtract the ongoing pause duration */ + if (self->is_paused) + elapsed -= (now - self->pause_start); + char buf[32]; + format_timer(elapsed, buf, sizeof(buf)); + gtk_label_set_text(self->timer_label, buf); + return G_SOURCE_CONTINUE; +} + +/* ── File chooser ────────────────────────────────────────────────── */ + +static void +on_folder_dialog_finish(GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + GtkFileDialog *dialog = GTK_FILE_DIALOG(source); + GFile *folder = gtk_file_dialog_select_folder_finish(dialog, result, NULL); + if (!folder) return; + + g_free(self->save_directory); + self->save_directory = g_file_get_path(folder); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + g_object_unref(folder); +} + +static void +on_save_dir_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + GtkFileDialog *dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, "Select save directory"); + + if (self->save_directory) { + GFile *initial = g_file_new_for_path(self->save_directory); + gtk_file_dialog_set_initial_folder(dialog, initial); + g_object_unref(initial); + } + + GtkWindow *win = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(self))); + gtk_file_dialog_select_folder(dialog, win, NULL, + on_folder_dialog_finish, self); + g_object_unref(dialog); +} + +/* ── Callbacks ───────────────────────────────────────────────────── */ + +static void +on_start_recording_clicked(GtkButton *btn G_GNUC_UNUSED, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL; + + if (self->is_active) { + /* ── Stop ─── */ + if (window) { + gsr_window_stop_process(window, NULL); + gsr_window_set_recording_active(window, FALSE); + } + gsr_record_page_set_active(self, FALSE); + /* set_active(FALSE) resets is_active, is_paused, and stops timer */ + } else { + /* ── Start ─── */ + if (!window) return; + + gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_RECORD); + if (!ok) return; /* fork failed — toast already shown by window */ + + self->is_active = TRUE; + self->is_paused = FALSE; + self->paused_time_offset = 0.0; + self->pause_start = 0.0; + gsr_record_page_set_active(self, TRUE); + gsr_window_set_recording_active(window, TRUE); + + /* Start display timer */ + self->start_time = clock_get_monotonic_seconds(); + self->timer_source_id = g_timeout_add(500, on_timer_tick, self); + } +} + +static void +on_pause_recording_clicked(GtkButton *btn G_GNUC_UNUSED, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + if (!self->is_active) return; + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL; + + /* Send SIGUSR2 to toggle pause/unpause */ + if (window) + gsr_window_send_signal(window, SIGUSR2); + + self->is_paused = !self->is_paused; + gsr_record_page_set_paused(self, self->is_paused); + + if (self->is_paused) { + self->pause_start = clock_get_monotonic_seconds(); + } else { + /* Accumulate paused duration */ + self->paused_time_offset += clock_get_monotonic_seconds() - self->pause_start; + self->pause_start = 0.0; + } +} + +/* ── Build groups ────────────────────────────────────────────────── */ + +#ifdef HAVE_WAYLAND +static gboolean +is_kde_wayland(void) +{ + const char *desktop = g_getenv("XDG_CURRENT_DESKTOP"); + return desktop && g_strstr_len(desktop, -1, "KDE") != NULL; +} +#endif + +/* ── X11 hotkey row callbacks ────────────────────────────────────── */ + +#ifdef HAVE_X11 +static void +on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog); + + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = g_strdup(accel); + + if (self->x11_start_stop_label) + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + accel ? accel : ""); + + /* Save config & regrab hotkeys */ + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + if (root && GSR_IS_WINDOW(root)) + gsr_window_on_hotkey_changed(GSR_WINDOW(root)); +} + +static void +on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new( + "Start/Stop recording", self->x11_start_stop_accel); + g_signal_connect(dialog, "shortcut-set", + G_CALLBACK(on_x11_start_stop_shortcut_set), self); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self)); +} + +static void +on_x11_pause_shortcut_set(GsrShortcutAccelDialog *dialog, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog); + + g_free(self->x11_pause_accel); + self->x11_pause_accel = g_strdup(accel); + + if (self->x11_pause_label) + gtk_shortcut_label_set_accelerator(self->x11_pause_label, + accel ? accel : ""); + + /* Save config & regrab hotkeys */ + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + if (root && GSR_IS_WINDOW(root)) + gsr_window_on_hotkey_changed(GSR_WINDOW(root)); +} + +static void +on_x11_pause_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(user_data); + GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new( + "Pause/Unpause recording", self->x11_pause_accel); + g_signal_connect(dialog, "shortcut-set", + G_CALLBACK(on_x11_pause_shortcut_set), self); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self)); +} +#endif /* HAVE_X11 */ + +static void +build_hotkey_group(GsrRecordPage *self) +{ + self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->hotkey_group, "Hotkeys"); + + GsrDisplayServer ds = self->info->system_info.display_server; + +#ifdef HAVE_WAYLAND + if (ds == GSR_DISPLAY_SERVER_WAYLAND) { + /* "Not supported" label — initially hidden, shown if portal fails */ + self->hotkey_not_supported_label = gtk_label_new( + "Your Wayland compositor doesn't support global hotkeys.\n" + "Use X11 or KDE Plasma on Wayland if you want to use hotkeys."); + gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE); + gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label"); + gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6); + gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6); + gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE); + adw_preferences_group_add(self->hotkey_group, + self->hotkey_not_supported_label); + + /* Info row about Wayland hotkeys */ + AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new()); + self->hotkey_info_row = GTK_WIDGET(info_row); + if (is_kde_wayland()) { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by KDE Plasma"); + adw_action_row_set_subtitle(info_row, + "Click to configure hotkeys in system settings"); + GtkButton *change_btn = GTK_BUTTON( + gtk_button_new_with_label("Change hotkeys")); + gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER); + g_object_set_data(G_OBJECT(info_row), "change-button", change_btn); + adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn)); + } else { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by your compositor"); + adw_action_row_set_subtitle(info_row, + "Go to system settings to change hotkeys"); + } + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row)); + + /* Hotkey labels (shown after portal succeeds) */ + AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row), + "Start/Stop recording"); + adw_action_row_set_subtitle(start_row, ""); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row)); + + AdwActionRow *pause_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(pause_row), + "Pause/Unpause recording"); + adw_action_row_set_subtitle(pause_row, ""); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(pause_row)); + + /* Initially hidden on Wayland */ + gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE); + gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE); + gtk_widget_set_visible(GTK_WIDGET(pause_row), FALSE); + } +#endif /* HAVE_WAYLAND */ + +#ifdef HAVE_X11 + if (ds == GSR_DISPLAY_SERVER_X11) { + /* X11: interactive shortcut rows */ + self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row), + "Start/Stop recording"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE); + + self->x11_start_stop_label = GTK_SHORTCUT_LABEL( + gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : "")); + gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER); + adw_action_row_add_suffix(self->x11_start_stop_row, + GTK_WIDGET(self->x11_start_stop_label)); + GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label"); + adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1)); + + g_signal_connect(self->x11_start_stop_row, "activated", + G_CALLBACK(on_x11_start_stop_activated), self); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row)); + + /* Pause/Unpause row */ + self->x11_pause_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_pause_row), + "Pause/Unpause recording"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_pause_row), TRUE); + + self->x11_pause_label = GTK_SHORTCUT_LABEL( + gtk_shortcut_label_new(self->x11_pause_accel ? self->x11_pause_accel : "")); + gtk_widget_set_valign(GTK_WIDGET(self->x11_pause_label), GTK_ALIGN_CENTER); + adw_action_row_add_suffix(self->x11_pause_row, + GTK_WIDGET(self->x11_pause_label)); + GtkImage *arrow2 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(arrow2), "dim-label"); + adw_action_row_add_suffix(self->x11_pause_row, GTK_WIDGET(arrow2)); + + g_signal_connect(self->x11_pause_row, "activated", + G_CALLBACK(on_x11_pause_activated), self); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_pause_row)); + } +#endif /* HAVE_X11 */ + + (void)ds; + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group); +} + +static void +build_output_group(GsrRecordPage *self) +{ + self->output_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->output_group, "Output"); + + /* Save directory */ + self->save_directory = get_default_videos_dir(); + self->save_dir_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->save_dir_row), + "Save directory"); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->save_dir_row), TRUE); + + GtkImage *folder_icon = GTK_IMAGE( + gtk_image_new_from_icon_name("document-open-symbolic")); + adw_action_row_add_suffix(self->save_dir_row, GTK_WIDGET(folder_icon)); + + g_signal_connect(self->save_dir_row, "activated", + G_CALLBACK(on_save_dir_activated), self); + adw_preferences_group_add(self->output_group, + GTK_WIDGET(self->save_dir_row)); + + /* Container format */ + self->container_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row), + "Container"); + + GtkStringList *ct_model = gtk_string_list_new(NULL); + const char *containers[] = { + "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL + }; + for (int i = 0; containers[i]; i++) + gtk_string_list_append(ct_model, containers[i]); + + if (self->info->supported_video_codecs.vp8 || + self->info->supported_video_codecs.vp9) + gtk_string_list_append(ct_model, "webm"); + + adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model)); + adw_combo_row_set_selected(self->container_row, 0); /* default: mp4 */ + adw_preferences_group_add(self->output_group, + GTK_WIDGET(self->container_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->output_group); +} + +static void +build_action_group(GsrRecordPage *self) +{ + self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12)); + gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER); + gtk_widget_set_margin_top(GTK_WIDGET(box), 6); + gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6); + + self->start_button = GTK_BUTTON( + gtk_button_new_with_label("Start recording")); + gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + g_signal_connect(self->start_button, "clicked", + G_CALLBACK(on_start_recording_clicked), self); + gtk_box_append(box, GTK_WIDGET(self->start_button)); + + self->pause_button = GTK_BUTTON( + gtk_button_new_with_label("Pause recording")); + gtk_widget_set_hexpand(GTK_WIDGET(self->pause_button), TRUE); + gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), FALSE); + g_signal_connect(self->pause_button, "clicked", + G_CALLBACK(on_pause_recording_clicked), self); + gtk_box_append(box, GTK_WIDGET(self->pause_button)); + + adw_preferences_group_add(self->action_group, GTK_WIDGET(box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group); +} + +static void +build_status_group(GsrRecordPage *self) +{ + self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8)); + gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + + self->record_icon = GTK_IMAGE( + gtk_image_new_from_icon_name("media-record-symbolic")); + gtk_box_append(self->status_box, GTK_WIDGET(self->record_icon)); + + self->timer_label = GTK_LABEL(gtk_label_new("00:00:00")); + gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label)); + + adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group); +} + +/* ── GObject lifecycle ───────────────────────────────────────────── */ + +static void +gsr_record_page_finalize(GObject *object) +{ + GsrRecordPage *self = GSR_RECORD_PAGE(object); + g_free(self->save_directory); +#ifdef HAVE_X11 + g_free(self->x11_start_stop_accel); + g_free(self->x11_pause_accel); +#endif + G_OBJECT_CLASS(gsr_record_page_parent_class)->finalize(object); +} + +static void +gsr_record_page_init(GsrRecordPage *self) +{ + (void)self; +} + +static void +gsr_record_page_class_init(GsrRecordPageClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_record_page_finalize; +} + +/* ── Config apply/read ───────────────────────────────────────────── */ + +/* Helper: select a combo row item by matching string from model */ +static void +combo_row_select_string(AdwComboRow *row, const char *value) +{ + if (!value || !value[0]) return; + GListModel *model = adw_combo_row_get_model(row); + guint n = g_list_model_get_n_items(model); + for (guint i = 0; i < n; i++) { + GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i)); + const char *str = gtk_string_object_get_string(obj); + g_object_unref(obj); + if (g_str_equal(str, value)) { + adw_combo_row_set_selected(row, i); + return; + } + } +} + +static const char * +combo_row_get_selected_string(AdwComboRow *row) +{ + GtkStringObject *obj = GTK_STRING_OBJECT( + adw_combo_row_get_selected_item(row)); + return obj ? gtk_string_object_get_string(obj) : ""; +} + +/* Map config container ID to display name */ +static const char * +container_id_to_display(const char *id) +{ + if (!id) return "mp4"; + if (g_str_equal(id, "matroska")) return "mkv"; + if (g_str_equal(id, "mpegts")) return "ts"; + if (g_str_equal(id, "hls")) return "m3u8"; + return id; /* mp4, flv, mov, webm pass through */ +} + +static const char * +container_display_to_id(const char *display) +{ + if (!display) return "mp4"; + if (g_str_equal(display, "mkv")) return "matroska"; + if (g_str_equal(display, "ts")) return "mpegts"; + if (g_str_equal(display, "m3u8")) return "hls"; + return display; /* mp4, flv, mov, webm pass through */ +} + +void +gsr_record_page_apply_config(GsrRecordPage *self, const GsrConfig *config) +{ + const GsrRecordConfig *r = &config->record_config; + + /* Save directory */ + if (r->save_directory && r->save_directory[0]) { + g_free(self->save_directory); + self->save_directory = g_strdup(r->save_directory); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + } + + /* Container */ + combo_row_select_string(self->container_row, + container_id_to_display(r->container)); + + /* Hotkeys (X11 only) */ +#ifdef HAVE_X11 + if (self->x11_start_stop_label) { + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&r->start_stop_hotkey); + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + self->x11_start_stop_accel ? self->x11_start_stop_accel : ""); + } + if (self->x11_pause_label) { + g_free(self->x11_pause_accel); + self->x11_pause_accel = gsr_config_hotkey_to_accel(&r->pause_unpause_hotkey); + gtk_shortcut_label_set_accelerator(self->x11_pause_label, + self->x11_pause_accel ? self->x11_pause_accel : ""); + } +#endif +} + +void +gsr_record_page_read_config(GsrRecordPage *self, GsrConfig *config) +{ + GsrRecordConfig *r = &config->record_config; + + /* Save directory */ + g_free(r->save_directory); + r->save_directory = g_strdup(self->save_directory ? self->save_directory : ""); + + /* Container */ + g_free(r->container); + r->container = g_strdup(container_display_to_id( + combo_row_get_selected_string(self->container_row))); + + /* Hotkeys */ +#ifdef HAVE_X11 + gsr_config_hotkey_from_accel(&r->start_stop_hotkey, self->x11_start_stop_accel); + gsr_config_hotkey_from_accel(&r->pause_unpause_hotkey, self->x11_pause_accel); +#endif +} + +/* ── Process management API ──────────────────────────────────────── */ + +void +gsr_record_page_set_active(GsrRecordPage *self, gboolean active) +{ + if (active) { + gtk_button_set_label(self->start_button, "Stop recording"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), TRUE); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0); + gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused"); + } else { + gtk_button_set_label(self->start_button, "Start recording"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), FALSE); + gtk_button_set_label(self->pause_button, "Pause recording"); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + gtk_label_set_text(self->timer_label, "00:00:00"); + gtk_image_set_from_icon_name(self->record_icon, "media-record-symbolic"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused"); + + /* Reset internal state (handles external stop via handle_child_death) */ + self->is_active = FALSE; + self->is_paused = FALSE; + if (self->timer_source_id) { + g_source_remove(self->timer_source_id); + self->timer_source_id = 0; + } + } +} + +void +gsr_record_page_set_paused(GsrRecordPage *self, gboolean paused) +{ + if (paused) { + gtk_button_set_label(self->pause_button, "Unpause recording"); + gtk_image_set_from_icon_name(self->record_icon, "media-playback-pause-symbolic"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-paused"); + } else { + gtk_button_set_label(self->pause_button, "Pause recording"); + gtk_image_set_from_icon_name(self->record_icon, "media-record-symbolic"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused"); + gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + } +} + +void +gsr_record_page_update_timer(GsrRecordPage *self, const char *text) +{ + gtk_label_set_text(self->timer_label, text); +} + +const char * +gsr_record_page_get_save_dir(GsrRecordPage *self) +{ + return self->save_directory; +} + +char * +gsr_record_page_get_container(GsrRecordPage *self) +{ + return g_strdup(container_display_to_id( + combo_row_get_selected_string(self->container_row))); +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrRecordPage * +gsr_record_page_new(const GsrInfo *info) +{ + GsrRecordPage *self = g_object_new(GSR_TYPE_RECORD_PAGE, NULL); + self->info = info; + + adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Record"); + adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self), + "media-record-symbolic"); + + build_hotkey_group(self); + build_output_group(self); + build_action_group(self); + build_status_group(self); + + return self; +} + +void +gsr_record_page_activate_start_stop(GsrRecordPage *self) +{ + g_return_if_fail(GSR_IS_RECORD_PAGE(self)); + gtk_widget_activate(GTK_WIDGET(self->start_button)); +} + +void +gsr_record_page_activate_pause(GsrRecordPage *self) +{ + g_return_if_fail(GSR_IS_RECORD_PAGE(self)); + if (!self->is_active) return; + gtk_widget_activate(GTK_WIDGET(self->pause_button)); +} + +#ifdef HAVE_WAYLAND +void +gsr_record_page_set_wayland_hotkeys_supported(GsrRecordPage *self, + gboolean supported) +{ + g_return_if_fail(GSR_IS_RECORD_PAGE(self)); + + if (self->hotkey_not_supported_label) + gtk_widget_set_visible(self->hotkey_not_supported_label, !supported); + if (self->hotkey_info_row) + gtk_widget_set_visible(self->hotkey_info_row, supported); +} +#endif /* HAVE_WAYLAND */ diff --git a/src/gsr-record-page.h b/src/gsr-record-page.h new file mode 100644 index 0000000..94acaf4 --- /dev/null +++ b/src/gsr-record-page.h @@ -0,0 +1,44 @@ +#pragma once + +#include <adwaita.h> +#include "gsr-info.h" +#include "gsr-config.h" + +G_BEGIN_DECLS + +#define GSR_TYPE_RECORD_PAGE (gsr_record_page_get_type()) +G_DECLARE_FINAL_TYPE(GsrRecordPage, gsr_record_page, GSR, RECORD_PAGE, AdwPreferencesPage) + +GsrRecordPage *gsr_record_page_new (const GsrInfo *info); +void gsr_record_page_apply_config (GsrRecordPage *self, + const GsrConfig *config); +void gsr_record_page_read_config (GsrRecordPage *self, + GsrConfig *config); + +/* Process management API (Phase 5) */ +void gsr_record_page_set_active (GsrRecordPage *self, + gboolean active); +void gsr_record_page_set_paused (GsrRecordPage *self, + gboolean paused); +void gsr_record_page_update_timer (GsrRecordPage *self, + const char *text); + +/* Get the save directory. Borrowed pointer, do NOT free. */ +const char *gsr_record_page_get_save_dir (GsrRecordPage *self); + +/* Get the container ID for -c argument. Caller must g_free(). */ +char *gsr_record_page_get_container (GsrRecordPage *self); + +/* Hotkey: programmatically toggle start/stop. */ +void gsr_record_page_activate_start_stop(GsrRecordPage *self); + +/* Hotkey: programmatically toggle pause/unpause. */ +void gsr_record_page_activate_pause(GsrRecordPage *self); + +/* Wayland: show/hide hotkey-not-supported banner. */ +#ifdef HAVE_WAYLAND +void gsr_record_page_set_wayland_hotkeys_supported( + GsrRecordPage *self, gboolean supported); +#endif + +G_END_DECLS diff --git a/src/gsr-replay-page.c b/src/gsr-replay-page.c new file mode 100644 index 0000000..67c7c9a --- /dev/null +++ b/src/gsr-replay-page.c @@ -0,0 +1,726 @@ +#include "gsr-replay-page.h" +#ifdef HAVE_X11 +#include "gsr-shortcut-accel-dialog.h" +#endif +#include "gsr-window.h" +#include <signal.h> +#include <time.h> + +/* ═══════════════════════════════════════════════════════════════════ + * GsrReplayPage — "Replay" tab + * + * Groups: Output · Action · Status + * ═══════════════════════════════════════════════════════════════════ */ + +struct _GsrReplayPage { + AdwPreferencesPage parent_instance; + + const GsrInfo *info; /* borrowed */ + + /* ── Hotkey group ─── */ + AdwPreferencesGroup *hotkey_group; +#ifdef HAVE_WAYLAND + GtkWidget *hotkey_not_supported_label; /* Wayland only */ + GtkWidget *hotkey_info_row; /* Wayland only */ +#endif + +#ifdef HAVE_X11 + /* X11 interactive hotkey rows */ + AdwActionRow *x11_start_stop_row; + GtkShortcutLabel *x11_start_stop_label; + char *x11_start_stop_accel; /* owned */ + AdwActionRow *x11_save_row; + GtkShortcutLabel *x11_save_label; + char *x11_save_accel; /* owned */ +#endif + + /* ── Output group ─── */ + AdwPreferencesGroup *output_group; + AdwActionRow *save_dir_row; + char *save_directory; /* owned */ + AdwComboRow *container_row; + AdwSpinRow *replay_time_row; + + /* ── Action group ─── */ + AdwPreferencesGroup *action_group; + GtkButton *start_button; + GtkButton *save_button; + + /* ── Status group ─── */ + AdwPreferencesGroup *status_group; + GtkBox *status_box; + GtkImage *record_icon; + GtkLabel *timer_label; + + gboolean is_active; + double start_time; + guint timer_source_id; +}; + +G_DEFINE_FINAL_TYPE(GsrReplayPage, gsr_replay_page, ADW_TYPE_PREFERENCES_PAGE) + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static char * +get_default_videos_dir(void) +{ + const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS); + if (vdir) + return g_strdup(vdir); + return g_build_filename(g_get_home_dir(), "Videos", NULL); +} + +/* ── Timer helpers ───────────────────────────────────────────────── */ + +static double +clock_get_monotonic_seconds(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +static void +format_timer(double seconds, char *buf, size_t buf_size) +{ + int total = (int)seconds; + int h = total / 3600; + int m = (total % 3600) / 60; + int s = total % 60; + snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s); +} + +static gboolean +on_timer_tick(gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + double elapsed = clock_get_monotonic_seconds() - self->start_time; + char buf[32]; + format_timer(elapsed, buf, sizeof(buf)); + gtk_label_set_text(self->timer_label, buf); + return G_SOURCE_CONTINUE; +} + +/* ── File chooser ────────────────────────────────────────────────── */ + +static void +on_folder_dialog_finish(GObject *source, + GAsyncResult *result, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + GtkFileDialog *dialog = GTK_FILE_DIALOG(source); + GFile *folder = gtk_file_dialog_select_folder_finish(dialog, result, NULL); + if (!folder) return; + + g_free(self->save_directory); + self->save_directory = g_file_get_path(folder); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + g_object_unref(folder); +} + +static void +on_save_dir_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + GtkFileDialog *dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, "Select save directory"); + + if (self->save_directory) { + GFile *initial = g_file_new_for_path(self->save_directory); + gtk_file_dialog_set_initial_folder(dialog, initial); + g_object_unref(initial); + } + + GtkWindow *win = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(self))); + gtk_file_dialog_select_folder(dialog, win, NULL, + on_folder_dialog_finish, self); + g_object_unref(dialog); +} + +/* ── Callbacks ───────────────────────────────────────────────────── */ + +static void +on_start_replay_clicked(GtkButton *btn G_GNUC_UNUSED, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL; + + if (self->is_active) { + /* ── Stop ─── */ + if (window) { + gsr_window_stop_process(window, NULL); + gsr_window_set_recording_active(window, FALSE); + } + gsr_replay_page_set_active(self, FALSE); + /* set_active(FALSE) resets is_active and stops timer */ + } else { + /* ── Start ─── */ + if (!window) return; + + gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_REPLAY); + if (!ok) return; /* fork failed — toast already shown by window */ + + self->is_active = TRUE; + gsr_replay_page_set_active(self, TRUE); + gsr_window_set_recording_active(window, TRUE); + + /* Start display timer */ + self->start_time = clock_get_monotonic_seconds(); + self->timer_source_id = g_timeout_add(500, on_timer_tick, self); + } +} + +static void +on_save_replay_clicked(GtkButton *btn G_GNUC_UNUSED, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + if (!self->is_active) return; + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL; + + if (window) { + gsr_window_send_signal(window, SIGUSR1); + gsr_window_notify_replay_saved(window); + } +} + +/* ── Build groups ────────────────────────────────────────────────── */ + +#ifdef HAVE_WAYLAND +static gboolean +is_kde_wayland(void) +{ + const char *desktop = g_getenv("XDG_CURRENT_DESKTOP"); + return desktop && g_strstr_len(desktop, -1, "KDE") != NULL; +} +#endif + +/* ── X11 hotkey row callbacks ────────────────────────────────────── */ + +#ifdef HAVE_X11 +static void +on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog); + + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = g_strdup(accel); + + if (self->x11_start_stop_label) + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + accel ? accel : ""); + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + if (root && GSR_IS_WINDOW(root)) + gsr_window_on_hotkey_changed(GSR_WINDOW(root)); +} + +static void +on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new( + "Start/Stop replay", self->x11_start_stop_accel); + g_signal_connect(dialog, "shortcut-set", + G_CALLBACK(on_x11_start_stop_shortcut_set), self); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self)); +} + +static void +on_x11_save_shortcut_set(GsrShortcutAccelDialog *dialog, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog); + + g_free(self->x11_save_accel); + self->x11_save_accel = g_strdup(accel); + + if (self->x11_save_label) + gtk_shortcut_label_set_accelerator(self->x11_save_label, + accel ? accel : ""); + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + if (root && GSR_IS_WINDOW(root)) + gsr_window_on_hotkey_changed(GSR_WINDOW(root)); +} + +static void +on_x11_save_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(user_data); + GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new( + "Save replay", self->x11_save_accel); + g_signal_connect(dialog, "shortcut-set", + G_CALLBACK(on_x11_save_shortcut_set), self); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self)); +} +#endif /* HAVE_X11 */ + +static void +build_hotkey_group(GsrReplayPage *self) +{ + self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->hotkey_group, "Hotkeys"); + + GsrDisplayServer ds = self->info->system_info.display_server; + +#ifdef HAVE_WAYLAND + if (ds == GSR_DISPLAY_SERVER_WAYLAND) { + /* "Not supported" label — initially hidden, shown if portal fails */ + self->hotkey_not_supported_label = gtk_label_new( + "Your Wayland compositor doesn't support global hotkeys.\n" + "Use X11 or KDE Plasma on Wayland if you want to use hotkeys."); + gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE); + gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label"); + gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6); + gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6); + gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE); + adw_preferences_group_add(self->hotkey_group, + self->hotkey_not_supported_label); + + /* Info row about Wayland hotkeys */ + AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new()); + self->hotkey_info_row = GTK_WIDGET(info_row); + if (is_kde_wayland()) { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by KDE Plasma"); + adw_action_row_set_subtitle(info_row, + "Click to configure hotkeys in system settings"); + GtkButton *change_btn = GTK_BUTTON( + gtk_button_new_with_label("Change hotkeys")); + gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER); + g_object_set_data(G_OBJECT(info_row), "change-button", change_btn); + adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn)); + } else { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by your compositor"); + adw_action_row_set_subtitle(info_row, + "Go to system settings to change hotkeys"); + } + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row)); + + /* Hotkey labels (shown after portal succeeds) */ + AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row), + "Start/Stop replay"); + adw_action_row_set_subtitle(start_row, ""); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row)); + + AdwActionRow *save_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(save_row), + "Save replay"); + adw_action_row_set_subtitle(save_row, ""); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(save_row)); + + /* Initially hidden on Wayland */ + gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE); + gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE); + gtk_widget_set_visible(GTK_WIDGET(save_row), FALSE); + } +#endif /* HAVE_WAYLAND */ + +#ifdef HAVE_X11 + if (ds == GSR_DISPLAY_SERVER_X11) { + /* X11: interactive shortcut rows */ + self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row), + "Start/Stop replay"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE); + + self->x11_start_stop_label = GTK_SHORTCUT_LABEL( + gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : "")); + gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER); + adw_action_row_add_suffix(self->x11_start_stop_row, + GTK_WIDGET(self->x11_start_stop_label)); + GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label"); + adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1)); + + g_signal_connect(self->x11_start_stop_row, "activated", + G_CALLBACK(on_x11_start_stop_activated), self); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row)); + + /* Save replay row */ + self->x11_save_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_save_row), + "Save replay"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_save_row), TRUE); + + self->x11_save_label = GTK_SHORTCUT_LABEL( + gtk_shortcut_label_new(self->x11_save_accel ? self->x11_save_accel : "")); + gtk_widget_set_valign(GTK_WIDGET(self->x11_save_label), GTK_ALIGN_CENTER); + adw_action_row_add_suffix(self->x11_save_row, + GTK_WIDGET(self->x11_save_label)); + GtkImage *arrow2 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(arrow2), "dim-label"); + adw_action_row_add_suffix(self->x11_save_row, GTK_WIDGET(arrow2)); + + g_signal_connect(self->x11_save_row, "activated", + G_CALLBACK(on_x11_save_activated), self); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_save_row)); + } +#endif /* HAVE_X11 */ + + (void)ds; + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group); +} + +static void +build_output_group(GsrReplayPage *self) +{ + self->output_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->output_group, "Output"); + + /* Save directory */ + self->save_directory = get_default_videos_dir(); + self->save_dir_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->save_dir_row), + "Save directory"); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->save_dir_row), TRUE); + + GtkImage *folder_icon = GTK_IMAGE( + gtk_image_new_from_icon_name("document-open-symbolic")); + adw_action_row_add_suffix(self->save_dir_row, GTK_WIDGET(folder_icon)); + + g_signal_connect(self->save_dir_row, "activated", + G_CALLBACK(on_save_dir_activated), self); + adw_preferences_group_add(self->output_group, + GTK_WIDGET(self->save_dir_row)); + + /* Container format */ + self->container_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row), + "Container"); + + GtkStringList *ct_model = gtk_string_list_new(NULL); + const char *containers[] = { + "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL + }; + for (int i = 0; containers[i]; i++) + gtk_string_list_append(ct_model, containers[i]); + + if (self->info->supported_video_codecs.vp8 || + self->info->supported_video_codecs.vp9) + gtk_string_list_append(ct_model, "webm"); + + adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model)); + adw_combo_row_set_selected(self->container_row, 0); /* default: mp4 */ + adw_preferences_group_add(self->output_group, + GTK_WIDGET(self->container_row)); + + /* Replay time */ + self->replay_time_row = ADW_SPIN_ROW( + adw_spin_row_new_with_range(5, 1200, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->replay_time_row), + "Replay time (seconds)"); + adw_spin_row_set_value(self->replay_time_row, 30); + adw_preferences_group_add(self->output_group, + GTK_WIDGET(self->replay_time_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->output_group); +} + +static void +build_action_group(GsrReplayPage *self) +{ + self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12)); + gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER); + gtk_widget_set_margin_top(GTK_WIDGET(box), 6); + gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6); + + self->start_button = GTK_BUTTON( + gtk_button_new_with_label("Start replay")); + gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + g_signal_connect(self->start_button, "clicked", + G_CALLBACK(on_start_replay_clicked), self); + gtk_box_append(box, GTK_WIDGET(self->start_button)); + + self->save_button = GTK_BUTTON( + gtk_button_new_with_label("Save replay")); + gtk_widget_set_hexpand(GTK_WIDGET(self->save_button), TRUE); + gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), FALSE); + g_signal_connect(self->save_button, "clicked", + G_CALLBACK(on_save_replay_clicked), self); + gtk_box_append(box, GTK_WIDGET(self->save_button)); + + adw_preferences_group_add(self->action_group, GTK_WIDGET(box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group); +} + +static void +build_status_group(GsrReplayPage *self) +{ + self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8)); + gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + + self->record_icon = GTK_IMAGE( + gtk_image_new_from_icon_name("media-record-symbolic")); + gtk_box_append(self->status_box, GTK_WIDGET(self->record_icon)); + + self->timer_label = GTK_LABEL(gtk_label_new("00:00:00")); + gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label)); + + adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group); +} + +/* ── GObject lifecycle ───────────────────────────────────────────── */ + +static void +gsr_replay_page_finalize(GObject *object) +{ + GsrReplayPage *self = GSR_REPLAY_PAGE(object); + g_free(self->save_directory); +#ifdef HAVE_X11 + g_free(self->x11_start_stop_accel); + g_free(self->x11_save_accel); +#endif + G_OBJECT_CLASS(gsr_replay_page_parent_class)->finalize(object); +} + +static void +gsr_replay_page_init(GsrReplayPage *self) +{ + (void)self; +} + +static void +gsr_replay_page_class_init(GsrReplayPageClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_replay_page_finalize; +} + +/* ── Config apply/read ───────────────────────────────────────────── */ + +/* Helper: select a combo row item by matching string from model */ +static void +combo_row_select_string(AdwComboRow *row, const char *value) +{ + if (!value || !value[0]) return; + GListModel *model = adw_combo_row_get_model(row); + guint n = g_list_model_get_n_items(model); + for (guint i = 0; i < n; i++) { + GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i)); + const char *str = gtk_string_object_get_string(obj); + g_object_unref(obj); + if (g_str_equal(str, value)) { + adw_combo_row_set_selected(row, i); + return; + } + } +} + +static const char * +combo_row_get_selected_string(AdwComboRow *row) +{ + GtkStringObject *obj = GTK_STRING_OBJECT( + adw_combo_row_get_selected_item(row)); + return obj ? gtk_string_object_get_string(obj) : ""; +} + +/* Map config container ID to display name */ +static const char * +container_id_to_display(const char *id) +{ + if (!id) return "mp4"; + if (g_str_equal(id, "matroska")) return "mkv"; + if (g_str_equal(id, "mpegts")) return "ts"; + if (g_str_equal(id, "hls")) return "m3u8"; + return id; /* mp4, flv, mov, webm pass through */ +} + +static const char * +container_display_to_id(const char *display) +{ + if (!display) return "mp4"; + if (g_str_equal(display, "mkv")) return "matroska"; + if (g_str_equal(display, "ts")) return "mpegts"; + if (g_str_equal(display, "m3u8")) return "hls"; + return display; /* mp4, flv, mov, webm pass through */ +} + +void +gsr_replay_page_apply_config(GsrReplayPage *self, const GsrConfig *config) +{ + const GsrReplayConfig *rp = &config->replay_config; + + /* Save directory */ + if (rp->save_directory && rp->save_directory[0]) { + g_free(self->save_directory); + self->save_directory = g_strdup(rp->save_directory); + adw_action_row_set_subtitle(self->save_dir_row, self->save_directory); + } + + /* Container */ + combo_row_select_string(self->container_row, + container_id_to_display(rp->container)); + + /* Replay time */ + if (rp->replay_time > 0) + adw_spin_row_set_value(self->replay_time_row, rp->replay_time); + + /* Hotkeys (X11 only) */ +#ifdef HAVE_X11 + if (self->x11_start_stop_label) { + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&rp->start_stop_hotkey); + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + self->x11_start_stop_accel ? self->x11_start_stop_accel : ""); + } + if (self->x11_save_label) { + g_free(self->x11_save_accel); + self->x11_save_accel = gsr_config_hotkey_to_accel(&rp->save_hotkey); + gtk_shortcut_label_set_accelerator(self->x11_save_label, + self->x11_save_accel ? self->x11_save_accel : ""); + } +#endif +} + +void +gsr_replay_page_read_config(GsrReplayPage *self, GsrConfig *config) +{ + GsrReplayConfig *rp = &config->replay_config; + + /* Save directory */ + g_free(rp->save_directory); + rp->save_directory = g_strdup(self->save_directory ? self->save_directory : ""); + + /* Container */ + g_free(rp->container); + rp->container = g_strdup(container_display_to_id( + combo_row_get_selected_string(self->container_row))); + + /* Replay time */ + rp->replay_time = (int32_t)adw_spin_row_get_value(self->replay_time_row); + + /* Hotkeys */ +#ifdef HAVE_X11 + gsr_config_hotkey_from_accel(&rp->start_stop_hotkey, self->x11_start_stop_accel); + gsr_config_hotkey_from_accel(&rp->save_hotkey, self->x11_save_accel); +#endif +} + +/* ── Process management API ──────────────────────────────────────── */ + +void +gsr_replay_page_set_active(GsrReplayPage *self, gboolean active) +{ + if (active) { + gtk_button_set_label(self->start_button, "Stop replay"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), TRUE); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0); + gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + } else { + gtk_button_set_label(self->start_button, "Start replay"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), FALSE); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + gtk_label_set_text(self->timer_label, "00:00:00"); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + + /* Reset internal state (handles external stop via handle_child_death) */ + self->is_active = FALSE; + if (self->timer_source_id) { + g_source_remove(self->timer_source_id); + self->timer_source_id = 0; + } + } +} + +void +gsr_replay_page_update_timer(GsrReplayPage *self, const char *text) +{ + gtk_label_set_text(self->timer_label, text); +} + +const char * +gsr_replay_page_get_save_dir(GsrReplayPage *self) +{ + return self->save_directory; +} + +char * +gsr_replay_page_get_container(GsrReplayPage *self) +{ + return g_strdup(container_display_to_id( + combo_row_get_selected_string(self->container_row))); +} + +int +gsr_replay_page_get_time(GsrReplayPage *self) +{ + return (int)adw_spin_row_get_value(self->replay_time_row); +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrReplayPage * +gsr_replay_page_new(const GsrInfo *info) +{ + GsrReplayPage *self = g_object_new(GSR_TYPE_REPLAY_PAGE, NULL); + self->info = info; + + adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Replay"); + adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self), + "media-playlist-repeat-symbolic"); + + build_hotkey_group(self); + build_output_group(self); + build_action_group(self); + build_status_group(self); + + return self; +} + +void +gsr_replay_page_activate_start_stop(GsrReplayPage *self) +{ + g_return_if_fail(GSR_IS_REPLAY_PAGE(self)); + gtk_widget_activate(GTK_WIDGET(self->start_button)); +} + +void +gsr_replay_page_activate_save(GsrReplayPage *self) +{ + g_return_if_fail(GSR_IS_REPLAY_PAGE(self)); + if (!self->is_active) return; + gtk_widget_activate(GTK_WIDGET(self->save_button)); +} + +#ifdef HAVE_WAYLAND +void +gsr_replay_page_set_wayland_hotkeys_supported(GsrReplayPage *self, + gboolean supported) +{ + g_return_if_fail(GSR_IS_REPLAY_PAGE(self)); + + if (self->hotkey_not_supported_label) + gtk_widget_set_visible(self->hotkey_not_supported_label, !supported); + if (self->hotkey_info_row) + gtk_widget_set_visible(self->hotkey_info_row, supported); +} +#endif /* HAVE_WAYLAND */ diff --git a/src/gsr-replay-page.h b/src/gsr-replay-page.h new file mode 100644 index 0000000..697cba2 --- /dev/null +++ b/src/gsr-replay-page.h @@ -0,0 +1,45 @@ +#pragma once + +#include <adwaita.h> +#include "gsr-info.h" +#include "gsr-config.h" + +G_BEGIN_DECLS + +#define GSR_TYPE_REPLAY_PAGE (gsr_replay_page_get_type()) +G_DECLARE_FINAL_TYPE(GsrReplayPage, gsr_replay_page, GSR, REPLAY_PAGE, AdwPreferencesPage) + +GsrReplayPage *gsr_replay_page_new (const GsrInfo *info); +void gsr_replay_page_apply_config (GsrReplayPage *self, + const GsrConfig *config); +void gsr_replay_page_read_config (GsrReplayPage *self, + GsrConfig *config); + +/* Process management API (Phase 5) */ +void gsr_replay_page_set_active (GsrReplayPage *self, + gboolean active); +void gsr_replay_page_update_timer (GsrReplayPage *self, + const char *text); + +/* Get the save directory. Borrowed pointer, do NOT free. */ +const char *gsr_replay_page_get_save_dir (GsrReplayPage *self); + +/* Get the container ID for -c argument. Caller must g_free(). */ +char *gsr_replay_page_get_container (GsrReplayPage *self); + +/* Get the replay time in seconds. */ +int gsr_replay_page_get_time (GsrReplayPage *self); + +/* Hotkey: programmatically toggle start/stop. */ +void gsr_replay_page_activate_start_stop(GsrReplayPage *self); + +/* Hotkey: programmatically save replay. */ +void gsr_replay_page_activate_save(GsrReplayPage *self); + +/* Wayland: show/hide hotkey-not-supported banner. */ +#ifdef HAVE_WAYLAND +void gsr_replay_page_set_wayland_hotkeys_supported( + GsrReplayPage *self, gboolean supported); +#endif + +G_END_DECLS diff --git a/src/gsr-shortcut-accel-dialog.c b/src/gsr-shortcut-accel-dialog.c new file mode 100644 index 0000000..bf5b0e9 --- /dev/null +++ b/src/gsr-shortcut-accel-dialog.c @@ -0,0 +1,372 @@ +#include "gsr-shortcut-accel-dialog.h" + +#include <gdk/gdk.h> + +/* ═══════════════════════════════════════════════════════════════════ + * GsrShortcutAccelDialog — Ptyxis-style key capture dialog + * + * Opens in "editing" (capture) mode. A GtkEventControllerKey in + * capture phase intercepts all key events. + * + * - Escape (no modifiers) → cancel / close + * - Backspace (no modifiers) → clear shortcut, emit "shortcut-set" + * - Valid key combo → store, switch to display mode, user clicks "Set" + * + * The dialog emits "shortcut-set" with the accelerator string (or NULL + * if cleared). + * ═══════════════════════════════════════════════════════════════════ */ + +struct _GsrShortcutAccelDialog { + AdwDialog parent_instance; + + /* Title of the shortcut being edited (e.g. "Start/Stop streaming") */ + char *shortcut_title; + + /* Current accelerator from config (for Reset) */ + char *initial_accel; + + /* Captured accelerator */ + char *accelerator; + guint keyval; + GdkModifierType modifier; + + /* State */ + gboolean editing; /* TRUE = waiting for key press */ + + /* Widgets */ + GtkStack *stack; /* "capture" / "display" pages */ + GtkLabel *capture_label; + GtkLabel *capture_hint; + GtkShortcutLabel *display_label; + GtkButton *set_button; + GtkButton *cancel_button; +}; + +G_DEFINE_FINAL_TYPE(GsrShortcutAccelDialog, gsr_shortcut_accel_dialog, ADW_TYPE_DIALOG) + +/* ── Signals ─────────────────────────────────────────────────────── */ + +enum { + SIGNAL_SHORTCUT_SET, + N_SIGNALS +}; + +static guint signals[N_SIGNALS]; + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static void +update_display(GsrShortcutAccelDialog *self) +{ + if (self->editing) { + gtk_stack_set_visible_child_name(self->stack, "capture"); + gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), FALSE); + } else { + if (self->accelerator) { + gtk_shortcut_label_set_accelerator(self->display_label, + self->accelerator); + } else { + gtk_shortcut_label_set_accelerator(self->display_label, ""); + } + gtk_stack_set_visible_child_name(self->stack, "display"); + gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), TRUE); + } +} + +static void +set_accelerator(GsrShortcutAccelDialog *self, const char *accel) +{ + g_free(self->accelerator); + self->accelerator = g_strdup(accel); +} + +/** + * Sanitize modifier mask: only keep standard modifier bits. + */ +static GdkModifierType +sanitize_modifier_mask(GdkModifierType state) +{ + return state & gtk_accelerator_get_default_mod_mask(); +} + +/** + * Check if shift should be dropped (the key is the same with or without shift). + */ +static gboolean +should_drop_shift(guint keyval_lower, guint keyval) +{ + /* If lowering the key didn't change it, shift isn't meaningful for this key */ + return keyval_lower == keyval; +} + +/* ── Key event handlers ──────────────────────────────────────────── */ + +static gboolean +on_key_pressed(GtkEventControllerKey *controller, + guint keyval, + guint keycode G_GNUC_UNUSED, + GdkModifierType state, + gpointer user_data) +{ + GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data); + + if (!self->editing) + return GDK_EVENT_PROPAGATE; + + GdkEvent *event = gtk_event_controller_get_current_event( + GTK_EVENT_CONTROLLER(controller)); + + /* Ignore pure modifier key presses */ + if (gdk_key_event_is_modifier(event)) + return GDK_EVENT_PROPAGATE; + + GdkModifierType real_mask = sanitize_modifier_mask(state); + guint keyval_lower = gdk_keyval_to_lower(keyval); + + /* Normalize ISO_Left_Tab → Tab */ + if (keyval_lower == GDK_KEY_ISO_Left_Tab) + keyval_lower = GDK_KEY_Tab; + + /* Put shift back if it changed the case of the key */ + if (keyval_lower != keyval) + real_mask |= GDK_SHIFT_MASK; + + /* Avoid SysRq translation (keep Alt+Print) */ + if (keyval_lower == GDK_KEY_Sys_Req && (real_mask & GDK_ALT_MASK) != 0) + keyval_lower = GDK_KEY_Print; + + /* Escape with no modifiers = cancel */ + if (real_mask == 0 && keyval_lower == GDK_KEY_Escape) { + adw_dialog_close(ADW_DIALOG(self)); + return GDK_EVENT_STOP; + } + + /* Backspace with no modifiers = clear shortcut */ + if (real_mask == 0 && keyval_lower == GDK_KEY_BackSpace) { + set_accelerator(self, NULL); + self->keyval = 0; + self->modifier = 0; + g_signal_emit(self, signals[SIGNAL_SHORTCUT_SET], 0); + adw_dialog_close(ADW_DIALOG(self)); + return GDK_EVENT_STOP; + } + + /* Store the captured shortcut */ + self->keyval = gdk_keyval_to_lower(keyval); + self->modifier = sanitize_modifier_mask(state); + + /* Handle shift normalization */ + if ((state & GDK_SHIFT_MASK) != 0 && should_drop_shift(self->keyval, keyval)) + self->modifier &= ~GDK_SHIFT_MASK; + if ((state & GDK_LOCK_MASK) == 0 && self->keyval != keyval) + self->modifier |= GDK_SHIFT_MASK; + + /* Normalize Ctrl+ISO_Left_Tab → Ctrl+Shift+Tab */ + if (self->keyval == GDK_KEY_ISO_Left_Tab && + self->modifier == GDK_CONTROL_MASK) + { + self->keyval = GDK_KEY_Tab; + self->modifier = GDK_CONTROL_MASK | GDK_SHIFT_MASK; + } + + /* Build accelerator string */ + g_free(self->accelerator); + self->accelerator = gtk_accelerator_name(self->keyval, self->modifier); + + /* Switch to display mode */ + self->editing = FALSE; + update_display(self); + gtk_widget_grab_focus(GTK_WIDGET(self->set_button)); + + return GDK_EVENT_STOP; +} + +/* ── Button callbacks ────────────────────────────────────────────── */ + +static void +on_set_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data); + g_signal_emit(self, signals[SIGNAL_SHORTCUT_SET], 0); + adw_dialog_close(ADW_DIALOG(self)); +} + +static void +on_cancel_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data) +{ + GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data); + adw_dialog_close(ADW_DIALOG(self)); +} + +/* ── GObject lifecycle ───────────────────────────────────────────── */ + +static void +gsr_shortcut_accel_dialog_finalize(GObject *object) +{ + GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(object); + g_free(self->shortcut_title); + g_free(self->initial_accel); + g_free(self->accelerator); + G_OBJECT_CLASS(gsr_shortcut_accel_dialog_parent_class)->finalize(object); +} + +static void +gsr_shortcut_accel_dialog_init(GsrShortcutAccelDialog *self) +{ + (void)self; +} + +static void +gsr_shortcut_accel_dialog_class_init(GsrShortcutAccelDialogClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_shortcut_accel_dialog_finalize; + + /** + * GsrShortcutAccelDialog::shortcut-set: + * + * Emitted when the user confirms a new shortcut (Set button) + * or clears it (Backspace). Call get_accelerator() to retrieve + * the result. + */ + signals[SIGNAL_SHORTCUT_SET] = g_signal_new( + "shortcut-set", + G_TYPE_FROM_CLASS(klass), + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, NULL, + G_TYPE_NONE, 0); +} + +/* ── Build UI ────────────────────────────────────────────────────── */ + +static void +build_dialog_ui(GsrShortcutAccelDialog *self) +{ + adw_dialog_set_title(ADW_DIALOG(self), "Set Shortcut"); + adw_dialog_set_content_width(ADW_DIALOG(self), 400); + adw_dialog_set_content_height(ADW_DIALOG(self), 260); + + /* ── Header bar ─── */ + AdwHeaderBar *header = ADW_HEADER_BAR(adw_header_bar_new()); + + self->cancel_button = GTK_BUTTON(gtk_button_new_with_label("Cancel")); + g_signal_connect(self->cancel_button, "clicked", + G_CALLBACK(on_cancel_clicked), self); + adw_header_bar_pack_start(header, GTK_WIDGET(self->cancel_button)); + + self->set_button = GTK_BUTTON(gtk_button_new_with_label("Set")); + gtk_widget_add_css_class(GTK_WIDGET(self->set_button), "suggested-action"); + gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), FALSE); + g_signal_connect(self->set_button, "clicked", + G_CALLBACK(on_set_clicked), self); + adw_header_bar_pack_end(header, GTK_WIDGET(self->set_button)); + + /* ── Content stack ─── */ + self->stack = GTK_STACK(gtk_stack_new()); + gtk_stack_set_transition_type(self->stack, GTK_STACK_TRANSITION_TYPE_CROSSFADE); + + /* -- Capture page -- */ + GtkBox *capture_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 12)); + gtk_widget_set_halign(GTK_WIDGET(capture_box), GTK_ALIGN_CENTER); + gtk_widget_set_valign(GTK_WIDGET(capture_box), GTK_ALIGN_CENTER); + gtk_widget_set_margin_start(GTK_WIDGET(capture_box), 24); + gtk_widget_set_margin_end(GTK_WIDGET(capture_box), 24); + gtk_widget_set_margin_top(GTK_WIDGET(capture_box), 24); + gtk_widget_set_margin_bottom(GTK_WIDGET(capture_box), 24); + + GtkImage *keyboard_icon = GTK_IMAGE( + gtk_image_new_from_icon_name("input-keyboard-symbolic")); + gtk_image_set_pixel_size(keyboard_icon, 64); + gtk_widget_add_css_class(GTK_WIDGET(keyboard_icon), "dim-label"); + gtk_box_append(capture_box, GTK_WIDGET(keyboard_icon)); + + char *label_text = g_strdup_printf( + "Enter new shortcut for\n<b>%s</b>", + self->shortcut_title ? self->shortcut_title : "Shortcut"); + self->capture_label = GTK_LABEL(gtk_label_new(NULL)); + gtk_label_set_markup(self->capture_label, label_text); + gtk_label_set_justify(self->capture_label, GTK_JUSTIFY_CENTER); + gtk_label_set_wrap(self->capture_label, TRUE); + g_free(label_text); + gtk_box_append(capture_box, GTK_WIDGET(self->capture_label)); + + self->capture_hint = GTK_LABEL(gtk_label_new( + "Press Escape to cancel or Backspace to disable the shortcut.")); + gtk_widget_add_css_class(GTK_WIDGET(self->capture_hint), "dim-label"); + gtk_label_set_wrap(self->capture_hint, TRUE); + gtk_label_set_justify(self->capture_hint, GTK_JUSTIFY_CENTER); + gtk_box_append(capture_box, GTK_WIDGET(self->capture_hint)); + + gtk_stack_add_named(self->stack, GTK_WIDGET(capture_box), "capture"); + + /* -- Display page -- */ + GtkBox *display_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 12)); + gtk_widget_set_halign(GTK_WIDGET(display_box), GTK_ALIGN_CENTER); + gtk_widget_set_valign(GTK_WIDGET(display_box), GTK_ALIGN_CENTER); + gtk_widget_set_margin_start(GTK_WIDGET(display_box), 24); + gtk_widget_set_margin_end(GTK_WIDGET(display_box), 24); + gtk_widget_set_margin_top(GTK_WIDGET(display_box), 24); + gtk_widget_set_margin_bottom(GTK_WIDGET(display_box), 24); + + GtkLabel *display_title = GTK_LABEL(gtk_label_new(NULL)); + char *display_text = g_strdup_printf( + "Shortcut for <b>%s</b>", + self->shortcut_title ? self->shortcut_title : "Shortcut"); + gtk_label_set_markup(display_title, display_text); + gtk_label_set_justify(display_title, GTK_JUSTIFY_CENTER); + gtk_label_set_wrap(display_title, TRUE); + g_free(display_text); + gtk_box_append(display_box, GTK_WIDGET(display_title)); + + self->display_label = GTK_SHORTCUT_LABEL(gtk_shortcut_label_new("")); + gtk_widget_set_halign(GTK_WIDGET(self->display_label), GTK_ALIGN_CENTER); + gtk_box_append(display_box, GTK_WIDGET(self->display_label)); + + gtk_stack_add_named(self->stack, GTK_WIDGET(display_box), "display"); + + /* ── Key event controller (capture phase) ─── */ + GtkEventController *key_ctrl = gtk_event_controller_key_new(); + gtk_event_controller_set_propagation_phase(key_ctrl, GTK_PHASE_CAPTURE); + g_signal_connect(key_ctrl, "key-pressed", + G_CALLBACK(on_key_pressed), self); + gtk_widget_add_controller(GTK_WIDGET(self), key_ctrl); + + /* ── Layout: toolbar-view ─── */ + AdwToolbarView *toolbar_view = ADW_TOOLBAR_VIEW(adw_toolbar_view_new()); + adw_toolbar_view_add_top_bar(toolbar_view, GTK_WIDGET(header)); + adw_toolbar_view_set_content(toolbar_view, GTK_WIDGET(self->stack)); + + adw_dialog_set_child(ADW_DIALOG(self), GTK_WIDGET(toolbar_view)); + + /* Start in capture mode */ + self->editing = TRUE; + update_display(self); +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrShortcutAccelDialog * +gsr_shortcut_accel_dialog_new(const char *shortcut_title, + const char *current_accel) +{ + GsrShortcutAccelDialog *self = g_object_new( + GSR_TYPE_SHORTCUT_ACCEL_DIALOG, NULL); + + self->shortcut_title = g_strdup(shortcut_title); + self->initial_accel = g_strdup(current_accel); + self->accelerator = g_strdup(current_accel); + self->editing = TRUE; + self->keyval = 0; + self->modifier = 0; + + build_dialog_ui(self); + + return self; +} + +const char * +gsr_shortcut_accel_dialog_get_accelerator(GsrShortcutAccelDialog *self) +{ + g_return_val_if_fail(GSR_IS_SHORTCUT_ACCEL_DIALOG(self), NULL); + return self->accelerator; +} diff --git a/src/gsr-shortcut-accel-dialog.h b/src/gsr-shortcut-accel-dialog.h new file mode 100644 index 0000000..87c3bd3 --- /dev/null +++ b/src/gsr-shortcut-accel-dialog.h @@ -0,0 +1,35 @@ +#pragma once + +/* + * gsr-shortcut-accel-dialog.h — Key capture dialog for hotkey assignment. + * + * Ptyxis-style AdwDialog with GtkEventControllerKey in capture phase. + * Opens in immediate capture mode: user presses key combo, then confirms. + * + * Escape = cancel, Backspace = clear/disable shortcut. + */ + +#include <adwaita.h> + +G_BEGIN_DECLS + +#define GSR_TYPE_SHORTCUT_ACCEL_DIALOG (gsr_shortcut_accel_dialog_get_type()) +G_DECLARE_FINAL_TYPE(GsrShortcutAccelDialog, gsr_shortcut_accel_dialog, + GSR, SHORTCUT_ACCEL_DIALOG, AdwDialog) + +/** + * Create a new shortcut accel dialog. + * @shortcut_title: Human-readable name like "Start/Stop streaming" + * @current_accel: Current accelerator string (e.g. "<Alt>1") or NULL + */ +GsrShortcutAccelDialog *gsr_shortcut_accel_dialog_new(const char *shortcut_title, + const char *current_accel); + +/** + * Get the accelerator string that was captured. + * Returns NULL if the shortcut was cleared (Backspace). + * Returns a borrowed string, valid until the dialog is destroyed. + */ +const char *gsr_shortcut_accel_dialog_get_accelerator(GsrShortcutAccelDialog *self); + +G_END_DECLS diff --git a/src/gsr-stream-page.c b/src/gsr-stream-page.c new file mode 100644 index 0000000..fceef37 --- /dev/null +++ b/src/gsr-stream-page.c @@ -0,0 +1,691 @@ +#include "gsr-stream-page.h" +#ifdef HAVE_X11 +#include "gsr-shortcut-accel-dialog.h" +#endif +#include "gsr-window.h" +#include <time.h> + +/* ═══════════════════════════════════════════════════════════════════ + * GsrStreamPage — "Stream" tab + * + * Groups: Service · Action · Status + * ═══════════════════════════════════════════════════════════════════ */ + +typedef enum { + STREAM_SERVICE_TWITCH, + STREAM_SERVICE_YOUTUBE, + STREAM_SERVICE_CUSTOM, +} StreamService; + +struct _GsrStreamPage { + AdwPreferencesPage parent_instance; + + const GsrInfo *info; /* borrowed */ + + /* ── Hotkey group ─── */ + AdwPreferencesGroup *hotkey_group; +#ifdef HAVE_WAYLAND + GtkWidget *hotkey_not_supported_label; /* Wayland only */ + GtkWidget *hotkey_info_row; /* Wayland only */ +#endif + +#ifdef HAVE_X11 + /* X11 interactive hotkey rows */ + AdwActionRow *x11_start_stop_row; + GtkShortcutLabel *x11_start_stop_label; + char *x11_start_stop_accel; /* owned */ +#endif + + /* ── Service group ─── */ + AdwPreferencesGroup *service_group; + AdwComboRow *service_row; + AdwPasswordEntryRow *twitch_key_row; + AdwPasswordEntryRow *youtube_key_row; + AdwPasswordEntryRow *custom_url_row; + AdwComboRow *container_row; + + /* ── Action group ─── */ + AdwPreferencesGroup *action_group; + GtkButton *start_button; + + /* ── Status group ─── */ + AdwPreferencesGroup *status_group; + GtkBox *status_box; + GtkImage *record_icon; + GtkLabel *timer_label; + + gboolean is_active; + double start_time; + guint timer_source_id; +}; + +G_DEFINE_FINAL_TYPE(GsrStreamPage, gsr_stream_page, ADW_TYPE_PREFERENCES_PAGE) + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +static StreamService +get_selected_service(GsrStreamPage *self) +{ + guint idx = adw_combo_row_get_selected(self->service_row); + switch (idx) { + case 0: return STREAM_SERVICE_TWITCH; + case 1: return STREAM_SERVICE_YOUTUBE; + case 2: return STREAM_SERVICE_CUSTOM; + default: return STREAM_SERVICE_TWITCH; + } +} + +static void +update_service_visibility(GsrStreamPage *self) +{ + StreamService svc = get_selected_service(self); + gtk_widget_set_visible(GTK_WIDGET(self->twitch_key_row), + svc == STREAM_SERVICE_TWITCH); + gtk_widget_set_visible(GTK_WIDGET(self->youtube_key_row), + svc == STREAM_SERVICE_YOUTUBE); + gtk_widget_set_visible(GTK_WIDGET(self->custom_url_row), + svc == STREAM_SERVICE_CUSTOM); + gtk_widget_set_visible(GTK_WIDGET(self->container_row), + svc == STREAM_SERVICE_CUSTOM); +} + +/* ── Timer helpers ───────────────────────────────────────────────── */ + +static double +clock_get_monotonic_seconds(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +static void +format_timer(double seconds, char *buf, size_t buf_size) +{ + int total = (int)seconds; + int h = total / 3600; + int m = (total % 3600) / 60; + int s = total % 60; + snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s); +} + +static gboolean +on_timer_tick(gpointer user_data) +{ + GsrStreamPage *self = GSR_STREAM_PAGE(user_data); + double elapsed = clock_get_monotonic_seconds() - self->start_time; + char buf[32]; + format_timer(elapsed, buf, sizeof(buf)); + gtk_label_set_text(self->timer_label, buf); + return G_SOURCE_CONTINUE; +} + +/* ── Callbacks ───────────────────────────────────────────────────── */ + +static void +on_service_changed(GObject *obj G_GNUC_UNUSED, + GParamSpec *pspec G_GNUC_UNUSED, + gpointer user_data) +{ + update_service_visibility(GSR_STREAM_PAGE(user_data)); +} + +static void +on_start_streaming_clicked(GtkButton *btn G_GNUC_UNUSED, + gpointer user_data) +{ + GsrStreamPage *self = GSR_STREAM_PAGE(user_data); + + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL; + + if (self->is_active) { + /* ── Stop ─── */ + if (window) { + gsr_window_stop_process(window, NULL); + gsr_window_set_recording_active(window, FALSE); + } + gsr_stream_page_set_active(self, FALSE); + /* set_active(FALSE) resets is_active and stops timer */ + } else { + /* ── Start ─── */ + if (!window) return; + + gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_STREAM); + if (!ok) return; /* fork failed — toast already shown by window */ + + self->is_active = TRUE; + gsr_stream_page_set_active(self, TRUE); + gsr_window_set_recording_active(window, TRUE); + + /* Start display timer */ + self->start_time = clock_get_monotonic_seconds(); + self->timer_source_id = g_timeout_add(500, on_timer_tick, self); + } +} + +/* ── Build groups ────────────────────────────────────────────────── */ + +#ifdef HAVE_WAYLAND +static gboolean +is_kde_wayland(void) +{ + const char *desktop = g_getenv("XDG_CURRENT_DESKTOP"); + return desktop && g_strstr_len(desktop, -1, "KDE") != NULL; +} +#endif + +/* ── X11 hotkey row callbacks ────────────────────────────────────── */ + +#ifdef HAVE_X11 +static void +on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog, + gpointer user_data) +{ + GsrStreamPage *self = GSR_STREAM_PAGE(user_data); + const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog); + + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = g_strdup(accel); + + if (self->x11_start_stop_label) + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + accel ? accel : ""); + + /* Save config & regrab hotkeys */ + GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self)); + if (root && GSR_IS_WINDOW(root)) { + gsr_window_on_hotkey_changed(GSR_WINDOW(root)); + } +} + +static void +on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED, + gpointer user_data) +{ + GsrStreamPage *self = GSR_STREAM_PAGE(user_data); + GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new( + "Start/Stop streaming", self->x11_start_stop_accel); + g_signal_connect(dialog, "shortcut-set", + G_CALLBACK(on_x11_start_stop_shortcut_set), self); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self)); +} +#endif /* HAVE_X11 */ + +static void +build_hotkey_group(GsrStreamPage *self) +{ + self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->hotkey_group, "Hotkeys"); + + GsrDisplayServer ds = self->info->system_info.display_server; + +#ifdef HAVE_WAYLAND + if (ds == GSR_DISPLAY_SERVER_WAYLAND) { + /* "Not supported" label — initially hidden, shown if portal fails */ + self->hotkey_not_supported_label = gtk_label_new( + "Your Wayland compositor doesn't support global hotkeys.\n" + "Use X11 or KDE Plasma on Wayland if you want to use hotkeys."); + gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE); + gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label"); + gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6); + gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6); + gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE); + adw_preferences_group_add(self->hotkey_group, + self->hotkey_not_supported_label); + + /* Info row about Wayland hotkeys */ + AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new()); + self->hotkey_info_row = GTK_WIDGET(info_row); + if (is_kde_wayland()) { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by KDE Plasma"); + adw_action_row_set_subtitle(info_row, + "Click to configure hotkeys in system settings"); + GtkButton *change_btn = GTK_BUTTON( + gtk_button_new_with_label("Change hotkeys")); + gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER); + /* The button will be wired to gsr_hotkeys from the window */ + g_object_set_data(G_OBJECT(info_row), "change-button", change_btn); + adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn)); + } else { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row), + "Hotkeys are managed by your compositor"); + adw_action_row_set_subtitle(info_row, + "Go to system settings to change hotkeys"); + } + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row)); + + /* Hotkey labels (shown after portal succeeds) */ + AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row), + "Start/Stop streaming"); + adw_action_row_set_subtitle(start_row, ""); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row)); + + /* Initially hidden on Wayland */ + gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE); + gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE); + } +#endif /* HAVE_WAYLAND */ + +#ifdef HAVE_X11 + if (ds == GSR_DISPLAY_SERVER_X11) { + /* X11: interactive shortcut rows */ + self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row), + "Start/Stop streaming"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE); + + self->x11_start_stop_label = GTK_SHORTCUT_LABEL( + gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : "")); + gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER); + adw_action_row_add_suffix(self->x11_start_stop_row, + GTK_WIDGET(self->x11_start_stop_label)); + /* Arrow indicator */ + GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label"); + adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1)); + + g_signal_connect(self->x11_start_stop_row, "activated", + G_CALLBACK(on_x11_start_stop_activated), self); + adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row)); + } +#endif /* HAVE_X11 */ + + (void)ds; + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group); +} + +static void +build_service_group(GsrStreamPage *self) +{ + self->service_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + adw_preferences_group_set_title(self->service_group, "Streaming Service"); + + /* Service selector */ + self->service_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->service_row), + "Service"); + GtkStringList *svc_model = gtk_string_list_new( + (const char *const[]){ "Twitch", "YouTube", "Custom", NULL }); + adw_combo_row_set_model(self->service_row, G_LIST_MODEL(svc_model)); + adw_combo_row_set_selected(self->service_row, 0); + g_signal_connect(self->service_row, "notify::selected", + G_CALLBACK(on_service_changed), self); + adw_preferences_group_add(self->service_group, + GTK_WIDGET(self->service_row)); + + /* Twitch stream key */ + self->twitch_key_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->twitch_key_row), + "Stream key"); + adw_preferences_group_add(self->service_group, + GTK_WIDGET(self->twitch_key_row)); + + /* YouTube stream key */ + self->youtube_key_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->youtube_key_row), + "Stream key"); + gtk_widget_set_visible(GTK_WIDGET(self->youtube_key_row), FALSE); + adw_preferences_group_add(self->service_group, + GTK_WIDGET(self->youtube_key_row)); + + /* Custom URL */ + self->custom_url_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->custom_url_row), + "URL"); + gtk_widget_set_visible(GTK_WIDGET(self->custom_url_row), FALSE); + adw_preferences_group_add(self->service_group, + GTK_WIDGET(self->custom_url_row)); + + /* Container (custom service only) */ + self->container_row = ADW_COMBO_ROW(adw_combo_row_new()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row), + "Container"); + + GtkStringList *ct_model = gtk_string_list_new(NULL); + const char *containers[] = { + "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL + }; + for (int i = 0; containers[i]; i++) + gtk_string_list_append(ct_model, containers[i]); + + /* Add webm if VP8 or VP9 supported */ + if (self->info->supported_video_codecs.vp8 || + self->info->supported_video_codecs.vp9) + gtk_string_list_append(ct_model, "webm"); + + adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model)); + adw_combo_row_set_selected(self->container_row, 1); /* default: flv */ + gtk_widget_set_visible(GTK_WIDGET(self->container_row), FALSE); + adw_preferences_group_add(self->service_group, + GTK_WIDGET(self->container_row)); + + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->service_group); +} + +static void +build_action_group(GsrStreamPage *self) +{ + self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + /* Button box */ + GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12)); + gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER); + gtk_widget_set_margin_top(GTK_WIDGET(box), 6); + gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6); + + self->start_button = GTK_BUTTON(gtk_button_new_with_label("Start streaming")); + gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + g_signal_connect(self->start_button, "clicked", + G_CALLBACK(on_start_streaming_clicked), self); + gtk_box_append(box, GTK_WIDGET(self->start_button)); + + adw_preferences_group_add(self->action_group, GTK_WIDGET(box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group); +} + +static void +build_status_group(GsrStreamPage *self) +{ + self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new()); + + self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8)); + gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + + GtkImage *icon = GTK_IMAGE(gtk_image_new_from_icon_name("media-record-symbolic")); + self->record_icon = icon; + gtk_box_append(self->status_box, GTK_WIDGET(icon)); + + self->timer_label = GTK_LABEL(gtk_label_new("00:00:00")); + gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label)); + + adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box)); + adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group); +} + +/* ── GObject lifecycle ───────────────────────────────────────────── */ + +static void +gsr_stream_page_finalize(GObject *object) +{ +#ifdef HAVE_X11 + GsrStreamPage *self = GSR_STREAM_PAGE(object); + g_free(self->x11_start_stop_accel); +#endif + G_OBJECT_CLASS(gsr_stream_page_parent_class)->finalize(object); +} + +static void +gsr_stream_page_init(GsrStreamPage *self) +{ + (void)self; +} + +static void +gsr_stream_page_class_init(GsrStreamPageClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_stream_page_finalize; +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrStreamPage * +gsr_stream_page_new(const GsrInfo *info) +{ + GsrStreamPage *self = g_object_new(GSR_TYPE_STREAM_PAGE, NULL); + self->info = info; + + adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Stream"); + adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self), + "network-transmit-symbolic"); + + build_hotkey_group(self); + build_service_group(self); + build_action_group(self); + build_status_group(self); + + /* Set initial visibility */ + update_service_visibility(self); + + return self; +} + +/* ── Config apply/read ───────────────────────────────────────────── */ + +/* Helper: select a combo row item by matching string from model */ +static void +combo_row_select_string(AdwComboRow *row, const char *value) +{ + if (!value || !value[0]) return; + GListModel *model = adw_combo_row_get_model(row); + guint n = g_list_model_get_n_items(model); + for (guint i = 0; i < n; i++) { + GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i)); + const char *str = gtk_string_object_get_string(obj); + g_object_unref(obj); + if (g_str_equal(str, value)) { + adw_combo_row_set_selected(row, i); + return; + } + } +} + +static const char * +combo_row_get_selected_string(AdwComboRow *row) +{ + GtkStringObject *obj = GTK_STRING_OBJECT( + adw_combo_row_get_selected_item(row)); + return obj ? gtk_string_object_get_string(obj) : ""; +} + +/* Map config streaming service string to combo index */ +static guint +service_string_to_index(const char *svc) +{ + if (!svc) return 0; + if (g_str_equal(svc, "twitch")) return 0; + if (g_str_equal(svc, "youtube")) return 1; + if (g_str_equal(svc, "custom")) return 2; + return 0; +} + +static const char * +service_index_to_string(guint idx) +{ + switch (idx) { + case 0: return "twitch"; + case 1: return "youtube"; + case 2: return "custom"; + default: return "twitch"; + } +} + +/* Map config container ID to display name */ +static const char * +stream_container_id_to_display(const char *id) +{ + if (!id) return "flv"; + /* The config stores internal IDs like "matroska", but our combo shows "mkv" etc. */ + if (g_str_equal(id, "matroska")) return "mkv"; + if (g_str_equal(id, "mpegts")) return "ts"; + if (g_str_equal(id, "hls")) return "m3u8"; + return id; /* mp4, flv, mov, webm pass through */ +} + +static const char * +stream_container_display_to_id(const char *display) +{ + if (!display) return "flv"; + if (g_str_equal(display, "mkv")) return "matroska"; + if (g_str_equal(display, "ts")) return "mpegts"; + if (g_str_equal(display, "m3u8")) return "hls"; + return display; /* mp4, flv, mov, webm pass through */ +} + +void +gsr_stream_page_apply_config(GsrStreamPage *self, const GsrConfig *config) +{ + const GsrStreamingConfig *s = &config->streaming_config; + + adw_combo_row_set_selected(self->service_row, + service_string_to_index(s->streaming_service)); + + gtk_editable_set_text(GTK_EDITABLE(self->twitch_key_row), + s->twitch_stream_key ? s->twitch_stream_key : ""); + gtk_editable_set_text(GTK_EDITABLE(self->youtube_key_row), + s->youtube_stream_key ? s->youtube_stream_key : ""); + gtk_editable_set_text(GTK_EDITABLE(self->custom_url_row), + s->custom_url ? s->custom_url : ""); + + /* Container for custom service */ + combo_row_select_string(self->container_row, + stream_container_id_to_display(s->custom_container)); + + update_service_visibility(self); + + /* Hotkeys (X11 only) */ +#ifdef HAVE_X11 + if (self->x11_start_stop_label) { + g_free(self->x11_start_stop_accel); + self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&s->start_stop_hotkey); + gtk_shortcut_label_set_accelerator(self->x11_start_stop_label, + self->x11_start_stop_accel ? self->x11_start_stop_accel : ""); + } +#endif +} + +void +gsr_stream_page_read_config(GsrStreamPage *self, GsrConfig *config) +{ + GsrStreamingConfig *s = &config->streaming_config; + + g_free(s->streaming_service); + s->streaming_service = g_strdup(service_index_to_string( + adw_combo_row_get_selected(self->service_row))); + + g_free(s->twitch_stream_key); + s->twitch_stream_key = g_strdup( + gtk_editable_get_text(GTK_EDITABLE(self->twitch_key_row))); + + g_free(s->youtube_stream_key); + s->youtube_stream_key = g_strdup( + gtk_editable_get_text(GTK_EDITABLE(self->youtube_key_row))); + + g_free(s->custom_url); + s->custom_url = g_strdup( + gtk_editable_get_text(GTK_EDITABLE(self->custom_url_row))); + + g_free(s->custom_container); + s->custom_container = g_strdup(stream_container_display_to_id( + combo_row_get_selected_string(self->container_row))); + + /* Hotkeys */ +#ifdef HAVE_X11 + gsr_config_hotkey_from_accel(&s->start_stop_hotkey, self->x11_start_stop_accel); +#endif +} + +/* ── Process management API ──────────────────────────────────────── */ + +void +gsr_stream_page_set_active(GsrStreamPage *self, gboolean active) +{ + if (active) { + gtk_button_set_label(self->start_button, "Stop streaming"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0); + gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + } else { + gtk_button_set_label(self->start_button, "Start streaming"); + gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action"); + gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action"); + gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5); + gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active"); + gtk_label_set_text(self->timer_label, "00:00:00"); + + /* Reset internal state (handles external stop via handle_child_death) */ + self->is_active = FALSE; + if (self->timer_source_id) { + g_source_remove(self->timer_source_id); + self->timer_source_id = 0; + } + } +} + +void +gsr_stream_page_update_timer(GsrStreamPage *self, const char *text) +{ + gtk_label_set_text(self->timer_label, text); +} + +char * +gsr_stream_page_get_stream_url(GsrStreamPage *self) +{ + StreamService svc = get_selected_service(self); + + switch (svc) { + case STREAM_SERVICE_TWITCH: { + const char *key = gtk_editable_get_text(GTK_EDITABLE(self->twitch_key_row)); + return g_strdup_printf("rtmp://live.twitch.tv/app/%s", key ? key : ""); + } + case STREAM_SERVICE_YOUTUBE: { + const char *key = gtk_editable_get_text(GTK_EDITABLE(self->youtube_key_row)); + return g_strdup_printf("rtmp://a.rtmp.youtube.com/live2/%s", key ? key : ""); + } + case STREAM_SERVICE_CUSTOM: { + const char *url = gtk_editable_get_text(GTK_EDITABLE(self->custom_url_row)); + if (!url || !url[0]) + return g_strdup(""); + /* If no recognized scheme prefix, prepend rtmp:// */ + if (g_str_has_prefix(url, "rtmp://") || + g_str_has_prefix(url, "rtmps://") || + g_str_has_prefix(url, "rtsp://") || + g_str_has_prefix(url, "srt://") || + g_str_has_prefix(url, "http://") || + g_str_has_prefix(url, "https://") || + g_str_has_prefix(url, "tcp://") || + g_str_has_prefix(url, "udp://")) + return g_strdup(url); + return g_strdup_printf("rtmp://%s", url); + } + } + return g_strdup(""); +} + +char * +gsr_stream_page_get_container(GsrStreamPage *self) +{ + StreamService svc = get_selected_service(self); + if (svc != STREAM_SERVICE_CUSTOM) + return g_strdup("flv"); + return g_strdup(stream_container_display_to_id( + combo_row_get_selected_string(self->container_row))); +} + +void +gsr_stream_page_activate_start_stop(GsrStreamPage *self) +{ + g_return_if_fail(GSR_IS_STREAM_PAGE(self)); + gtk_widget_activate(GTK_WIDGET(self->start_button)); +} + +#ifdef HAVE_WAYLAND +void +gsr_stream_page_set_wayland_hotkeys_supported(GsrStreamPage *self, + gboolean supported) +{ + g_return_if_fail(GSR_IS_STREAM_PAGE(self)); + + if (self->hotkey_not_supported_label) + gtk_widget_set_visible(self->hotkey_not_supported_label, !supported); + if (self->hotkey_info_row) + gtk_widget_set_visible(self->hotkey_info_row, supported); +} +#endif /* HAVE_WAYLAND */ diff --git a/src/gsr-stream-page.h b/src/gsr-stream-page.h new file mode 100644 index 0000000..af63bc6 --- /dev/null +++ b/src/gsr-stream-page.h @@ -0,0 +1,39 @@ +#pragma once + +#include <adwaita.h> +#include "gsr-info.h" +#include "gsr-config.h" + +G_BEGIN_DECLS + +#define GSR_TYPE_STREAM_PAGE (gsr_stream_page_get_type()) +G_DECLARE_FINAL_TYPE(GsrStreamPage, gsr_stream_page, GSR, STREAM_PAGE, AdwPreferencesPage) + +GsrStreamPage *gsr_stream_page_new (const GsrInfo *info); +void gsr_stream_page_apply_config (GsrStreamPage *self, + const GsrConfig *config); +void gsr_stream_page_read_config (GsrStreamPage *self, + GsrConfig *config); + +/* Process management API (Phase 5) */ +void gsr_stream_page_set_active (GsrStreamPage *self, + gboolean active); +void gsr_stream_page_update_timer (GsrStreamPage *self, + const char *text); + +/* Get the stream URL for -o argument. Caller must g_free(). */ +char *gsr_stream_page_get_stream_url(GsrStreamPage *self); + +/* Get the container ID for -c argument. Caller must g_free(). */ +char *gsr_stream_page_get_container (GsrStreamPage *self); + +/* Hotkey: programmatically toggle start/stop. */ +void gsr_stream_page_activate_start_stop(GsrStreamPage *self); + +/* Wayland: show/hide hotkey-not-supported banner. */ +#ifdef HAVE_WAYLAND +void gsr_stream_page_set_wayland_hotkeys_supported( + GsrStreamPage *self, gboolean supported); +#endif + +G_END_DECLS diff --git a/src/gsr-window.c b/src/gsr-window.c new file mode 100644 index 0000000..d224aba --- /dev/null +++ b/src/gsr-window.c @@ -0,0 +1,1214 @@ +#include "gsr-window.h" +#include "gsr-info.h" +#include "gsr-config.h" +#include "gsr-config-page.h" +#include "gsr-stream-page.h" +#include "gsr-record-page.h" +#include "gsr-replay-page.h" +#include "gsr-hotkeys.h" + +#include <signal.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> +#include <errno.h> + +#ifdef __linux__ +#include <sys/prctl.h> +#endif + +struct _GsrWindow { + AdwApplicationWindow parent_instance; + + /* Header bar */ + AdwHeaderBar *header_bar; + AdwViewSwitcher *header_switcher; + GtkStack *header_title_stack; + GtkLabel *header_title_label; + GtkMenuButton *menu_button; + + /* View stack & bottom switcher */ + AdwViewStack *view_stack; + AdwViewSwitcherBar *view_switcher_bar; + + /* Toast overlay */ + AdwToastOverlay *toast_overlay; + + /* System info (owned, lifetime = window) */ + GsrInfo info; + + /* Config (owned, lifetime = window) */ + GsrConfig config; + + /* Pages */ + GsrConfigPage *config_page; + GsrStreamPage *stream_page; + GsrRecordPage *record_page; + GsrReplayPage *replay_page; + + /* ── Hotkeys ─── */ + GsrHotkeys *hotkeys; +#ifdef HAVE_WAYLAND + gboolean wayland_shortcuts_registered; +#endif + + /* ── Process management ─── */ + pid_t child_pid; /* -1 when idle */ + int prev_exit_status; + GsrActiveMode active_mode; + char *record_filename; /* owned, recording only */ + guint poll_timer_id; /* 500ms poll timer */ + + /* ── Desktop notifications ─── */ + gboolean showing_notification; + gboolean is_kde; /* KDE workaround */ + + /* ── Startup error state ─── */ + GsrInfoExitStatus info_status; /* cached for deferred dialog */ +}; + +G_DEFINE_FINAL_TYPE(GsrWindow, gsr_window, ADW_TYPE_APPLICATION_WINDOW) + +/* ── Forward declarations ────────────────────────────────────────── */ + +static void handle_child_death(GsrWindow *self, int exit_status); +static void send_notification(GsrWindow *self, const char *title, + const char *body, GNotificationPriority priority); + +/* ── Desktop notification helpers ────────────────────────────────── */ + +/** + * Withdraw the desktop notification if its timeout has elapsed. + * Called as a one-shot GLib timeout. + */ +static gboolean +on_notification_withdraw(gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + + if (self->showing_notification) { + GtkApplication *app = GTK_APPLICATION( + gtk_window_get_application(GTK_WINDOW(self))); + if (app) + g_application_withdraw_notification(G_APPLICATION(app), + "gpu-screen-recorder"); + self->showing_notification = FALSE; + } + + return G_SOURCE_REMOVE; /* one-shot */ +} + +/** + * Send a desktop notification AND an in-app toast. + * + * GNotification goes to the desktop notification daemon via GApplication; + * AdwToast shows inside our window. The desktop notification is auto-withdrawn + * after 3 s (normal) or 10 s (urgent). + * + * KDE workaround: when a capture is active, KDE suppresses normal-priority + * notifications because the app is "screen sharing". We force URGENT in that + * case so the user still sees them. + */ +static void +send_notification(GsrWindow *self, const char *title, + const char *body, GNotificationPriority priority) +{ + GtkApplication *app = GTK_APPLICATION( + gtk_window_get_application(GTK_WINDOW(self))); + if (!app) + return; + + /* ── Desktop notification ── */ + GNotification *notif = g_notification_new(title); + g_notification_set_body(notif, body); + + /* KDE workaround: force urgent while capturing */ + GNotificationPriority effective = priority; + if (self->is_kde && self->child_pid > 0 && + effective < G_NOTIFICATION_PRIORITY_URGENT) + effective = G_NOTIFICATION_PRIORITY_URGENT; + + g_notification_set_priority(notif, effective); + + /* Cancel any pending withdrawal timer, then send */ + if (self->showing_notification) { + g_application_withdraw_notification(G_APPLICATION(app), + "gpu-screen-recorder"); + } + g_application_send_notification(G_APPLICATION(app), + "gpu-screen-recorder", notif); + self->showing_notification = TRUE; + g_object_unref(notif); + + /* Auto-withdraw after timeout */ + guint timeout_ms = (effective >= G_NOTIFICATION_PRIORITY_URGENT) ? 10000 : 3000; + g_timeout_add(timeout_ms, on_notification_withdraw, self); + + /* ── In-app toast ── */ + AdwToast *toast = adw_toast_new(body); + adw_toast_set_timeout(toast, (effective >= G_NOTIFICATION_PRIORITY_URGENT) ? 10 : 3); + adw_toast_overlay_add_toast(self->toast_overlay, toast); +} + +/* ── Container compatibility fix ─────────────────────────────────── */ + +static const char * +fix_container_for_codec(const char *container, const char *codec) +{ + gboolean is_vp = g_str_equal(codec, "vp8") || g_str_equal(codec, "vp9"); + + if (is_vp) { + /* VP8/VP9 needs webm or matroska */ + if (!g_str_equal(container, "webm") && !g_str_equal(container, "matroska")) + return "webm"; + } else { + /* Non-VP codec in webm container → force mp4 */ + if (g_str_equal(container, "webm")) + return "mp4"; + } + return container; +} + +/* ── Resolve codec for "auto" ────────────────────────────────────── */ + +static void +resolve_codec_and_encoder(GsrWindow *self, const char **out_codec, + gboolean *out_use_software) +{ + const char *selected = gsr_config_page_get_video_codec_id(self->config_page); + *out_use_software = FALSE; + + if (g_str_equal(selected, "h264_software")) { + *out_codec = "h264"; + *out_use_software = TRUE; + return; + } + + if (g_str_equal(selected, "auto")) { + const char *hw = gsr_info_get_first_usable_hw_video_codec(&self->info); + if (hw) { + *out_codec = hw; + } else { + /* Fallback to h264 software */ + *out_codec = "h264"; + *out_use_software = TRUE; + } + return; + } + + *out_codec = selected; +} + +/* ── Build recording filename ────────────────────────────────────── */ + +static char * +build_record_filename(const char *dir, const char *container_display) +{ + time_t now = time(NULL); + struct tm *tm = localtime(&now); + char date_buf[64]; + strftime(date_buf, sizeof(date_buf), "%Y-%m-%d_%H-%M-%S", tm); + return g_strdup_printf("%s/Video_%s.%s", dir, date_buf, container_display); +} + +/* Map internal container ID to display extension for filename */ +static const char * +container_id_to_extension(const char *id) +{ + if (g_str_equal(id, "matroska")) return "mkv"; + if (g_str_equal(id, "mpegts")) return "ts"; + if (g_str_equal(id, "hls")) return "m3u8"; + return id; /* mp4, flv, mov, webm pass through */ +} + +/* ── Build command-line args ─────────────────────────────────────── */ + +static GPtrArray * +build_command_args(GsrWindow *self, GsrActiveMode mode) +{ + GPtrArray *args = g_ptr_array_new_with_free_func(g_free); + + g_ptr_array_add(args, g_strdup("gpu-screen-recorder")); + + /* ── Record area / window ─── */ + const char *area_id = gsr_config_page_get_record_area_id(self->config_page); + g_ptr_array_add(args, g_strdup("-w")); + + if (g_str_equal(area_id, "focused")) { + int aw = gsr_config_page_get_area_width(self->config_page); + int ah = gsr_config_page_get_area_height(self->config_page); + g_ptr_array_add(args, g_strdup_printf("focused:%dx%d", aw, ah)); + } else if (g_str_equal(area_id, "portal")) { + g_ptr_array_add(args, g_strdup("portal")); + } else if (g_str_equal(area_id, "window")) { + unsigned long wid = gsr_config_page_get_selected_window(self->config_page); + if (wid == 0) { + g_ptr_array_unref(args); + return NULL; + } + g_ptr_array_add(args, g_strdup_printf("%lu", wid)); + } else { + /* Monitor name */ + g_ptr_array_add(args, g_strdup(area_id)); + } + + /* ── Codec & encoder ─── */ + const char *codec = NULL; + gboolean use_software = FALSE; + resolve_codec_and_encoder(self, &codec, &use_software); + + /* ── Container (mode-specific, with compat fix) ─── */ + char *container_owned = NULL; + const char *container = NULL; + + switch (mode) { + case GSR_ACTIVE_MODE_STREAM: + container_owned = gsr_stream_page_get_container(self->stream_page); + container = container_owned; + break; + case GSR_ACTIVE_MODE_RECORD: + container_owned = gsr_record_page_get_container(self->record_page); + container = container_owned; + break; + case GSR_ACTIVE_MODE_REPLAY: + container_owned = gsr_replay_page_get_container(self->replay_page); + container = container_owned; + break; + default: + container = "mp4"; + break; + } + + container = fix_container_for_codec(container, codec); + + g_ptr_array_add(args, g_strdup("-c")); + g_ptr_array_add(args, g_strdup(container)); + + g_ptr_array_add(args, g_strdup("-k")); + g_ptr_array_add(args, g_strdup(codec)); + + /* Audio codec */ + g_ptr_array_add(args, g_strdup("-ac")); + g_ptr_array_add(args, g_strdup(gsr_config_page_get_audio_codec_id(self->config_page))); + + /* FPS */ + g_ptr_array_add(args, g_strdup("-f")); + g_ptr_array_add(args, g_strdup_printf("%d", gsr_config_page_get_fps(self->config_page))); + + /* Cursor */ + g_ptr_array_add(args, g_strdup("-cursor")); + g_ptr_array_add(args, g_strdup( + gsr_config_page_get_record_cursor(self->config_page) ? "yes" : "no")); + + /* Restore portal session */ + g_ptr_array_add(args, g_strdup("-restore-portal-session")); + g_ptr_array_add(args, g_strdup( + gsr_config_page_get_restore_portal_session(self->config_page) ? "yes" : "no")); + + /* Color range */ + g_ptr_array_add(args, g_strdup("-cr")); + g_ptr_array_add(args, g_strdup(gsr_config_page_get_color_range_id(self->config_page))); + + /* Encoder */ + g_ptr_array_add(args, g_strdup("-encoder")); + g_ptr_array_add(args, g_strdup(use_software ? "cpu" : "gpu")); + + /* ── Quality args ─── */ + const char *quality = gsr_config_page_get_quality_id(self->config_page); + if (g_str_equal(quality, "custom")) { + g_ptr_array_add(args, g_strdup("-bm")); + g_ptr_array_add(args, g_strdup("cbr")); + g_ptr_array_add(args, g_strdup("-q")); + g_ptr_array_add(args, g_strdup_printf("%d", + gsr_config_page_get_video_bitrate(self->config_page))); + } else { + g_ptr_array_add(args, g_strdup("-q")); + g_ptr_array_add(args, g_strdup(quality)); + } + + /* ── Framerate mode ─── */ + const char *fm = gsr_config_page_get_framerate_mode_id(self->config_page); + if (!g_str_equal(fm, "auto")) { + g_ptr_array_add(args, g_strdup("-fm")); + g_ptr_array_add(args, g_strdup(fm)); + } + + /* ── Resolution ─── */ + if (gsr_config_page_get_change_video_resolution(self->config_page) && + !g_str_equal(area_id, "focused")) + { + int vw = gsr_config_page_get_video_width(self->config_page); + int vh = gsr_config_page_get_video_height(self->config_page); + g_ptr_array_add(args, g_strdup("-s")); + g_ptr_array_add(args, g_strdup_printf("%dx%d", vw, vh)); + } + + /* ── Overclock ─── */ + if (gsr_config_page_get_overclock(self->config_page)) { + g_ptr_array_add(args, g_strdup("-oc")); + g_ptr_array_add(args, g_strdup("yes")); + } + + /* ── Audio args ─── */ + gboolean split = gsr_config_page_get_split_audio(self->config_page); + gboolean merge = !split; + GPtrArray *audio_tracks = gsr_config_page_build_audio_args(self->config_page, merge); + for (guint i = 0; i < audio_tracks->len; i++) { + g_ptr_array_add(args, g_strdup("-a")); + g_ptr_array_add(args, g_strdup(g_ptr_array_index(audio_tracks, i))); + } + g_ptr_array_unref(audio_tracks); + + /* ── Mode-specific: output (-o) and extra flags ─── */ + switch (mode) { + case GSR_ACTIVE_MODE_REPLAY: { + int replay_time = gsr_replay_page_get_time(self->replay_page); + g_ptr_array_add(args, g_strdup("-r")); + g_ptr_array_add(args, g_strdup_printf("%d", replay_time)); + + const char *save_dir = gsr_replay_page_get_save_dir(self->replay_page); + g_ptr_array_add(args, g_strdup("-o")); + g_ptr_array_add(args, g_strdup(save_dir ? save_dir : "/tmp")); + break; + } + case GSR_ACTIVE_MODE_RECORD: { + const char *save_dir = gsr_record_page_get_save_dir(self->record_page); + const char *ext = container_id_to_extension(container); + char *filename = build_record_filename( + save_dir ? save_dir : "/tmp", ext); + + g_free(self->record_filename); + self->record_filename = g_strdup(filename); + + g_ptr_array_add(args, g_strdup("-o")); + g_ptr_array_add(args, filename); /* transfers ownership */ + break; + } + case GSR_ACTIVE_MODE_STREAM: { + char *url = gsr_stream_page_get_stream_url(self->stream_page); + g_ptr_array_add(args, g_strdup("-o")); + g_ptr_array_add(args, url); /* transfers ownership */ + break; + } + default: + break; + } + + g_free(container_owned); + + /* NULL-terminate for execvp */ + g_ptr_array_add(args, NULL); + + return args; +} + +/* ── fork/exec ───────────────────────────────────────────────────── */ + +static gboolean +start_child_process(GsrWindow *self, GPtrArray *args) +{ + pid_t pid = fork(); + if (pid == -1) { + g_warning("fork() failed: %s", g_strerror(errno)); + return FALSE; + } + + if (pid == 0) { + /* Child process */ +#ifdef __linux__ + prctl(PR_SET_PDEATHSIG, SIGTERM); +#endif + execvp(g_ptr_array_index(args, 0), (char **)args->pdata); + /* If execvp returns, it failed */ + _exit(127); + } + + /* Parent */ + self->child_pid = pid; + + /* Log the command line for debugging */ + g_autofree char *cmdline = g_strjoinv(" ", (char **)args->pdata); + g_debug("Started gpu-screen-recorder (pid=%d): %s", pid, cmdline); + + return TRUE; +} + +/* ── Kill helper (matches kill_gpu_screen_recorder_get_result) ───── */ + +static gboolean +kill_and_wait(pid_t pid, gboolean *already_dead) +{ + if (already_dead) *already_dead = FALSE; + + if (pid <= 0) { + if (already_dead) *already_dead = TRUE; + return TRUE; + } + + /* Check if already dead */ + int status = 0; + pid_t ret = waitpid(pid, &status, WNOHANG); + if (ret == pid) { + /* Already exited */ + if (already_dead) *already_dead = TRUE; + if (WIFEXITED(status)) + return WEXITSTATUS(status) == 0; + return FALSE; + } + + /* Still running — send SIGINT and block until exit */ + kill(pid, SIGINT); + ret = waitpid(pid, &status, 0); + if (ret == pid && WIFEXITED(status)) + return WEXITSTATUS(status) == 0; + return FALSE; +} + +/* ── 500ms poll timer ────────────────────────────────────────────── */ + +static gboolean +on_poll_timer(gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + + if (self->child_pid <= 0) + return G_SOURCE_CONTINUE; + + /* Check if child died */ + int status = 0; + pid_t ret = waitpid(self->child_pid, &status, WNOHANG); + if (ret == self->child_pid) { + /* Child died */ + int exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + self->child_pid = -1; + handle_child_death(self, exit_status); + } + + return G_SOURCE_CONTINUE; +} + +/* ── Handle unexpected child death ───────────────────────────────── */ + +static void +handle_child_death(GsrWindow *self, int exit_status) +{ + self->prev_exit_status = exit_status; + GsrActiveMode mode = self->active_mode; + + g_debug("Child died with exit_status=%d, mode=%d", exit_status, mode); + + /* Stop the poll timer */ + if (self->poll_timer_id) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } + + /* Enter the "stopped" state on the appropriate page */ + switch (mode) { + case GSR_ACTIVE_MODE_STREAM: + gsr_stream_page_set_active(self->stream_page, FALSE); + break; + case GSR_ACTIVE_MODE_RECORD: + gsr_record_page_set_active(self->record_page, FALSE); + break; + case GSR_ACTIVE_MODE_REPLAY: + gsr_replay_page_set_active(self->replay_page, FALSE); + break; + default: + break; + } + + self->active_mode = GSR_ACTIVE_MODE_NONE; + gsr_window_set_recording_active(self, FALSE); + + /* Show notification based on exit code */ + if (exit_status == 60) { + /* Canceled by user — silent */ + } else if (exit_status == 0) { + /* Success */ + gboolean notify = gsr_config_page_get_notify_stopped(self->config_page); + if (notify) { + if (mode == GSR_ACTIVE_MODE_RECORD && self->record_filename) { + char *msg = g_strdup_printf("Recording saved to %s", + self->record_filename); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_NORMAL); + g_free(msg); + } else { + const char *mode_str = + mode == GSR_ACTIVE_MODE_STREAM ? "streaming" : + mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "recording"; + char *msg = g_strdup_printf("Stopped %s", mode_str); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_NORMAL); + g_free(msg); + } + } + } else { + /* Error — always notify regardless of user prefs */ + char *msg = NULL; + if (exit_status == 10) + msg = g_strdup_printf("You need to have pkexec installed and have " + "a polkit agent running to record your monitor"); + else if (exit_status == 50) + msg = g_strdup_printf("Desktop portal capture failed. Either you " + "canceled the desktop portal or your Wayland compositor " + "doesn't support desktop portal capture or it's incorrectly " + "setup on your system"); + else + msg = g_strdup_printf("Failed to save video. Either your graphics " + "card doesn't support GPU Screen Recorder with the settings " + "you used or you don't have enough disk space. " + "Start GPU Screen Recorder from the terminal for more info"); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_URGENT); + g_free(msg); + } +} + +/* ── Actions ─────────────────────────────────────────────────────── */ + +static void +save_config(GsrWindow *self) +{ + /* Read current widget state into config struct */ + gsr_config_page_read_config(self->config_page, &self->config); + gsr_stream_page_read_config(self->stream_page, &self->config); + gsr_record_page_read_config(self->record_page, &self->config); + gsr_replay_page_read_config(self->replay_page, &self->config); + + /* Persist view-mode */ + GAction *action = g_action_map_lookup_action(G_ACTION_MAP(self), "view-mode"); + if (action) { + GVariant *state = g_action_get_state(action); + const char *mode = g_variant_get_string(state, NULL); + self->config.main_config.advanced_view = g_str_equal(mode, "advanced"); + g_variant_unref(state); + } + + gsr_config_save(&self->config); +} + +static void +on_view_mode_change(GSimpleAction *action, + GVariant *parameter, + gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + const char *mode = g_variant_get_string(parameter, NULL); + g_simple_action_set_state(action, g_variant_new_string(mode)); + + gboolean advanced = g_str_equal(mode, "advanced"); + gsr_config_page_set_advanced(self->config_page, advanced); + + /* Persist the change immediately */ + save_config(self); + + g_debug("View mode changed to: %s", mode); +} + +/* ── Close request — save config while widgets are still alive ──── */ + +static gboolean +on_close_request(GtkWindow *window, gpointer user_data G_GNUC_UNUSED) +{ + GsrWindow *self = GSR_WINDOW(window); + + /* If child is running, kill it before exiting */ + if (self->child_pid > 0) { + g_debug("Window closing — killing child pid %d", self->child_pid); + kill(self->child_pid, SIGINT); + waitpid(self->child_pid, NULL, 0); + self->child_pid = -1; + } + + /* Stop poll timer */ + if (self->poll_timer_id) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } + + /* Free hotkeys before the window is destroyed */ + if (self->hotkeys) { + gsr_hotkeys_free(self->hotkeys); + self->hotkeys = NULL; + } + + /* Withdraw any pending desktop notification */ + if (self->showing_notification) { + GtkApplication *app = GTK_APPLICATION( + gtk_window_get_application(GTK_WINDOW(self))); + if (app) + g_application_withdraw_notification(G_APPLICATION(app), + "gpu-screen-recorder"); + self->showing_notification = FALSE; + } + + save_config(self); + /* Return FALSE to let the default handler proceed */ + return FALSE; +} + +/* ── Hamburger menu ──────────────────────────────────────────────── */ + +static GMenuModel * +create_primary_menu(void) +{ + GMenu *menu = g_menu_new(); + + /* View mode section (with "View" header label) */ + GMenu *view_section = g_menu_new(); + GMenuItem *simple_item = g_menu_item_new("Simple", NULL); + g_menu_item_set_action_and_target_value(simple_item, + "win.view-mode", g_variant_new_string("simple")); + g_menu_append_item(view_section, simple_item); + g_object_unref(simple_item); + + GMenuItem *advanced_item = g_menu_item_new("Advanced", NULL); + g_menu_item_set_action_and_target_value(advanced_item, + "win.view-mode", g_variant_new_string("advanced")); + g_menu_append_item(view_section, advanced_item); + g_object_unref(advanced_item); + + g_menu_append_section(menu, "View", G_MENU_MODEL(view_section)); + g_object_unref(view_section); + + /* About section */ + GMenu *about_section = g_menu_new(); + g_menu_append(about_section, "Keyboard Shortcuts", "app.shortcuts"); + g_menu_append(about_section, "About", "app.about"); + g_menu_append_section(menu, NULL, G_MENU_MODEL(about_section)); + g_object_unref(about_section); + + return G_MENU_MODEL(menu); +} + +/* ── Hotkey: page changed → regrab + register Wayland shortcuts ─── */ + +static void +on_visible_page_changed(GObject *object G_GNUC_UNUSED, + GParamSpec *pspec G_GNUC_UNUSED, + gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + + /* X11: re-grab hotkeys for the now-visible page */ +#ifdef HAVE_X11 + if (self->hotkeys) + gsr_hotkeys_regrab_for_visible_page(self->hotkeys); +#endif + + /* Wayland: register shortcuts once when first visiting an action page */ +#ifdef HAVE_WAYLAND + if (self->hotkeys && !self->wayland_shortcuts_registered) { + const char *page = adw_view_stack_get_visible_child_name(self->view_stack); + if (page && (!g_str_equal(page, "config"))) { + self->wayland_shortcuts_registered = TRUE; + gsr_hotkeys_register_wayland_shortcuts_once(self->hotkeys); + } + } +#endif +} + +/* ── Startup error dialogs (AdwAlertDialog) ──────────────────────── */ + +static void +on_fatal_error_response(AdwAlertDialog *dialog G_GNUC_UNUSED, + const char *response G_GNUC_UNUSED, + gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + GtkApplication *app = GTK_APPLICATION( + gtk_window_get_application(GTK_WINDOW(self))); + if (app) + g_application_quit(G_APPLICATION(app)); +} + +static void +show_fatal_error(GsrWindow *self, const char *heading, const char *body) +{ + AdwAlertDialog *dlg = ADW_ALERT_DIALOG(adw_alert_dialog_new(heading, body)); + adw_alert_dialog_add_response(dlg, "ok", "OK"); + adw_alert_dialog_set_default_response(dlg, "ok"); + adw_alert_dialog_set_close_response(dlg, "ok"); + adw_alert_dialog_set_body_use_markup(dlg, TRUE); + g_signal_connect(dlg, "response", + G_CALLBACK(on_fatal_error_response), self); + adw_dialog_present(ADW_DIALOG(dlg), GTK_WIDGET(self)); +} + +static gboolean +check_startup_errors_idle(gpointer user_data) +{ + GsrWindow *self = GSR_WINDOW(user_data); + + switch (self->info_status) { + case GSR_INFO_EXIT_FAILED_TO_RUN: + show_fatal_error(self, + "Failed to run gpu-screen-recorder", + "Failed to run the <tt>gpu-screen-recorder</tt> command.\n\n" + "Make sure <tt>gpu-screen-recorder</tt> is installed and " + "accessible in your PATH."); + return G_SOURCE_REMOVE; + + case GSR_INFO_EXIT_OPENGL_FAILED: + show_fatal_error(self, + "OpenGL initialization failed", + "Failed to get OpenGL information.\n\n" + "Make sure your GPU drivers are properly installed. " + "You may need to install the Vulkan or Mesa drivers for your GPU."); + return G_SOURCE_REMOVE; + + case GSR_INFO_EXIT_NO_DRM_CARD: + show_fatal_error(self, + "No DRM card found", + "Failed to find a valid DRM card for your GPU.\n\n" + "If you are running in a VM, make sure GPU passthrough is " + "enabled and properly configured."); + return G_SOURCE_REMOVE; + + case GSR_INFO_EXIT_OK: + break; + } + + /* Check display server */ + if (self->info.system_info.display_server == GSR_DISPLAY_SERVER_UNKNOWN) { + show_fatal_error(self, + "No display server detected", + "Neither X11 nor Wayland is running.\n\n" + "GPU Screen Recorder requires either X11 or Wayland."); + return G_SOURCE_REMOVE; + } + + /* Check for monitors (Wayland without portal needs monitors) */ + if (self->info.supported_capture_options.n_monitors == 0 && + self->info.system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND && + !self->info.supported_capture_options.portal) + { + show_fatal_error(self, + "No monitors found", + "No monitors to record were found.\n\n" + "Make sure GPU Screen Recorder is running on the same GPU " + "that your monitors are connected to. You can use the " + "<tt>DRI_PRIME</tt> environment variable to choose a GPU."); + return G_SOURCE_REMOVE; + } + + return G_SOURCE_REMOVE; +} + +/* ── GObject boilerplate ─────────────────────────────────────────── */ + +static void +gsr_window_init(GsrWindow *self) +{ + /* ── Init process state ─── */ + self->child_pid = -1; + self->prev_exit_status = 0; + self->active_mode = GSR_ACTIVE_MODE_NONE; + self->record_filename = NULL; + self->poll_timer_id = 0; + + /* ── Init notification state ─── */ + self->showing_notification = FALSE; + const char *desktop = g_getenv("XDG_CURRENT_DESKTOP"); + self->is_kde = desktop && (g_strstr_len(desktop, -1, "KDE") != NULL); + + /* ── Window properties ─── */ + gtk_window_set_title(GTK_WINDOW(self), "GPU Screen Recorder"); + gtk_window_set_default_size(GTK_WINDOW(self), 580, 600); + gtk_widget_set_size_request(GTK_WIDGET(self), 430, 300); + + /* ── Load system info ─── */ + GsrInfoExitStatus info_status = gsr_info_load(&self->info); + if (info_status != GSR_INFO_EXIT_OK) + g_warning("gsr_info_load returned status %d", info_status); + + /* Store for deferred error dialog */ + self->info_status = info_status; + + /* ── Load config ─── */ + gsr_config_init_defaults(&self->config); + gsr_config_read(&self->config); + + /* ── View stack ─── */ + self->view_stack = ADW_VIEW_STACK(adw_view_stack_new()); + + self->config_page = gsr_config_page_new(&self->info); + self->stream_page = gsr_stream_page_new(&self->info); + self->record_page = gsr_record_page_new(&self->info); + self->replay_page = gsr_replay_page_new(&self->info); + + adw_view_stack_add_titled_with_icon(self->view_stack, + GTK_WIDGET(self->config_page), "config", "Config", "preferences-system-symbolic"); + adw_view_stack_add_titled_with_icon(self->view_stack, + GTK_WIDGET(self->stream_page), "stream", "Stream", "network-transmit-symbolic"); + adw_view_stack_add_titled_with_icon(self->view_stack, + GTK_WIDGET(self->record_page), "record", "Record", "media-record-symbolic"); + adw_view_stack_add_titled_with_icon(self->view_stack, + GTK_WIDGET(self->replay_page), "replay", "Replay", "media-playlist-repeat-symbolic"); + + /* ── Header bar with view switcher / title stack ─── */ + self->header_switcher = ADW_VIEW_SWITCHER(adw_view_switcher_new()); + adw_view_switcher_set_stack(self->header_switcher, self->view_stack); + adw_view_switcher_set_policy(self->header_switcher, ADW_VIEW_SWITCHER_POLICY_WIDE); + + self->header_title_label = GTK_LABEL(gtk_label_new("GPU Screen Recorder")); + gtk_widget_add_css_class(GTK_WIDGET(self->header_title_label), "title"); + + /* Stack to hold both the switcher (wide) and title label (narrow) */ + self->header_title_stack = GTK_STACK(gtk_stack_new()); + gtk_stack_set_transition_type(self->header_title_stack, GTK_STACK_TRANSITION_TYPE_CROSSFADE); + gtk_stack_add_named(self->header_title_stack, + GTK_WIDGET(self->header_switcher), "switcher"); + gtk_stack_add_named(self->header_title_stack, + GTK_WIDGET(self->header_title_label), "title"); + + self->header_bar = ADW_HEADER_BAR(adw_header_bar_new()); + adw_header_bar_set_centering_policy(self->header_bar, + ADW_CENTERING_POLICY_STRICT); + adw_header_bar_set_title_widget(self->header_bar, + GTK_WIDGET(self->header_title_stack)); + + /* Hamburger menu button */ + self->menu_button = GTK_MENU_BUTTON(gtk_menu_button_new()); + gtk_menu_button_set_icon_name(self->menu_button, "open-menu-symbolic"); + gtk_menu_button_set_menu_model(self->menu_button, create_primary_menu()); + adw_header_bar_pack_end(self->header_bar, GTK_WIDGET(self->menu_button)); + + /* ── Bottom view switcher bar (narrow mode fallback) ─── */ + self->view_switcher_bar = ADW_VIEW_SWITCHER_BAR(adw_view_switcher_bar_new()); + adw_view_switcher_bar_set_stack(self->view_switcher_bar, self->view_stack); + + /* ── Toast overlay wrapping the view stack ─── */ + self->toast_overlay = ADW_TOAST_OVERLAY(adw_toast_overlay_new()); + adw_toast_overlay_set_child(self->toast_overlay, GTK_WIDGET(self->view_stack)); + + /* ── Layout: toolbar-view wrapping the toast overlay ─── */ + AdwToolbarView *toolbar_view = ADW_TOOLBAR_VIEW(adw_toolbar_view_new()); + adw_toolbar_view_add_top_bar(toolbar_view, GTK_WIDGET(self->header_bar)); + adw_toolbar_view_set_content(toolbar_view, GTK_WIDGET(self->toast_overlay)); + adw_toolbar_view_add_bottom_bar(toolbar_view, GTK_WIDGET(self->view_switcher_bar)); + + adw_application_window_set_content(ADW_APPLICATION_WINDOW(self), + GTK_WIDGET(toolbar_view)); + + /* ── Breakpoint: narrow window → show title label in header, show bottom bar ─── */ + AdwBreakpoint *bp = adw_breakpoint_new( + adw_breakpoint_condition_parse("max-width: 550sp")); + adw_breakpoint_add_setters(bp, + G_OBJECT(self->header_title_stack), "visible-child-name", "title", + G_OBJECT(self->view_switcher_bar), "reveal", TRUE, + NULL); + adw_application_window_add_breakpoint(ADW_APPLICATION_WINDOW(self), bp); + + /* ── Save config on close ─── */ + g_signal_connect(self, "close-request", + G_CALLBACK(on_close_request), NULL); + + /* ── Window actions ─── */ + const char *initial_mode = self->config.main_config.advanced_view + ? "'advanced'" : "'simple'"; + GActionEntry win_actions[] = { + { .name = "view-mode", .activate = on_view_mode_change, + .parameter_type = "s", .state = initial_mode }, + }; + g_action_map_add_action_entries(G_ACTION_MAP(self), + win_actions, G_N_ELEMENTS(win_actions), self); + + /* ── Apply config to all pages ─── */ + gsr_config_page_apply_config(self->config_page, &self->config); + gsr_stream_page_apply_config(self->stream_page, &self->config); + gsr_record_page_apply_config(self->record_page, &self->config); + gsr_replay_page_apply_config(self->replay_page, &self->config); + + /* Apply advanced view mode */ + gsr_config_page_set_advanced(self->config_page, + self->config.main_config.advanced_view); + + /* ── Hotkeys ─── */ + self->hotkeys = gsr_hotkeys_new(self->info.system_info.display_server, self); +#ifdef HAVE_WAYLAND + self->wayland_shortcuts_registered = FALSE; +#endif + + /* Re-grab hotkeys whenever the visible page changes */ + g_signal_connect(self->view_stack, "notify::visible-child-name", + G_CALLBACK(on_visible_page_changed), self); + + /* Initial grab for the default visible page */ +#ifdef HAVE_X11 + if (self->hotkeys) + gsr_hotkeys_regrab_for_visible_page(self->hotkeys); +#endif + + /* ── Deferred startup error check ─── */ + g_idle_add(check_startup_errors_idle, self); +} + +static void +gsr_window_finalize(GObject *object) +{ + GsrWindow *self = GSR_WINDOW(object); + + if (self->poll_timer_id) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } + + if (self->hotkeys) { + gsr_hotkeys_free(self->hotkeys); + self->hotkeys = NULL; + } + + g_free(self->record_filename); + gsr_config_clear(&self->config); + gsr_info_clear(&self->info); + G_OBJECT_CLASS(gsr_window_parent_class)->finalize(object); +} + +static void +gsr_window_class_init(GsrWindowClass *klass) +{ + GObjectClass *obj_class = G_OBJECT_CLASS(klass); + obj_class->finalize = gsr_window_finalize; +} + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrWindow * +gsr_window_new(AdwApplication *app) +{ + return g_object_new(GSR_TYPE_WINDOW, + "application", app, + NULL); +} + +void +gsr_window_set_recording_active(GsrWindow *self, gboolean active) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + gtk_widget_set_sensitive(GTK_WIDGET(self->header_switcher), !active); + gtk_widget_set_sensitive(GTK_WIDGET(self->view_switcher_bar), !active); +} + +gboolean +gsr_window_start_process(GsrWindow *self, GsrActiveMode mode) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE); + g_return_val_if_fail(self->child_pid <= 0, FALSE); + + /* Validate window selection if in "window" mode */ + if (!gsr_config_page_has_valid_window_selection(self->config_page)) { + send_notification(self, "GPU Screen Recorder", + "No window selected! Please select a window first.", + G_NOTIFICATION_PRIORITY_URGENT); + return FALSE; + } + + /* Build command line */ + GPtrArray *args = build_command_args(self, mode); + if (!args) { + send_notification(self, "GPU Screen Recorder", + "Failed to build command (no window selected)", + G_NOTIFICATION_PRIORITY_URGENT); + return FALSE; + } + + /* Fork/exec */ + gboolean ok = start_child_process(self, args); + g_ptr_array_unref(args); + + if (!ok) { + const char *mode_str = + mode == GSR_ACTIVE_MODE_STREAM ? "streaming" : + mode == GSR_ACTIVE_MODE_RECORD ? "recording" : + mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown"; + char *msg = g_strdup_printf("Failed to start %s (failed to fork)", mode_str); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_URGENT); + g_free(msg); + return FALSE; + } + + self->active_mode = mode; + + /* Start 500ms poll timer */ + if (self->poll_timer_id == 0) + self->poll_timer_id = g_timeout_add(500, on_poll_timer, self); + + /* Show "started" notification */ + if (gsr_config_page_get_notify_started(self->config_page)) { + const char *mode_str = + mode == GSR_ACTIVE_MODE_STREAM ? "streaming" : + mode == GSR_ACTIVE_MODE_RECORD ? "recording" : + mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown"; + char *msg = g_strdup_printf("Started %s", mode_str); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_NORMAL); + g_free(msg); + } + + return TRUE; +} + +gboolean +gsr_window_stop_process(GsrWindow *self, gboolean *already_dead) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE); + + if (self->child_pid <= 0) { + if (already_dead) *already_dead = TRUE; + return TRUE; + } + + pid_t pid = self->child_pid; + self->child_pid = -1; + GsrActiveMode mode = self->active_mode; + + gboolean success = kill_and_wait(pid, already_dead); + + /* Stop poll timer */ + if (self->poll_timer_id) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } + + self->active_mode = GSR_ACTIVE_MODE_NONE; + + /* Show stop notification (respecting user prefs) */ + if (gsr_config_page_get_notify_stopped(self->config_page)) { + if (success && mode == GSR_ACTIVE_MODE_RECORD && self->record_filename) { + char *msg = g_strdup_printf("Recording saved to %s", + self->record_filename); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_NORMAL); + g_free(msg); + } else { + const char *mode_str = + mode == GSR_ACTIVE_MODE_STREAM ? "streaming" : + mode == GSR_ACTIVE_MODE_RECORD ? "recording" : + mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown"; + char *msg = g_strdup_printf("Stopped %s", mode_str); + send_notification(self, "GPU Screen Recorder", msg, + G_NOTIFICATION_PRIORITY_NORMAL); + g_free(msg); + } + } + + return success; +} + +void +gsr_window_send_signal(GsrWindow *self, int sig) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + if (self->child_pid > 0) + kill(self->child_pid, sig); +} + +void +gsr_window_notify_replay_saved(GsrWindow *self) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + if (gsr_config_page_get_notify_saved(self->config_page)) + send_notification(self, "GPU Screen Recorder", "Saved replay", + G_NOTIFICATION_PRIORITY_NORMAL); +} + +void +gsr_window_show_toast(GsrWindow *self, const char *message) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + AdwToast *toast = adw_toast_new(message); + adw_toast_set_timeout(toast, 3); + adw_toast_overlay_add_toast(self->toast_overlay, toast); +} + +gboolean +gsr_window_is_process_running(GsrWindow *self) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE); + return self->child_pid > 0; +} + +GsrActiveMode +gsr_window_get_active_mode(GsrWindow *self) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), GSR_ACTIVE_MODE_NONE); + return self->active_mode; +} + +/* ── Hotkey dispatch (called from gsr-hotkeys) ───────────────────── */ + +const char * +gsr_window_get_visible_page_name(GsrWindow *self) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), NULL); + return adw_view_stack_get_visible_child_name(self->view_stack); +} + +void +gsr_window_hotkey_start_stop(GsrWindow *self) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + + const char *page = adw_view_stack_get_visible_child_name(self->view_stack); + if (!page) + return; + + if (g_str_equal(page, "stream")) + gsr_stream_page_activate_start_stop(self->stream_page); + else if (g_str_equal(page, "record")) + gsr_record_page_activate_start_stop(self->record_page); + else if (g_str_equal(page, "replay")) + gsr_replay_page_activate_start_stop(self->replay_page); +} + +void +gsr_window_hotkey_pause_unpause(GsrWindow *self) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + gsr_record_page_activate_pause(self->record_page); +} + +void +gsr_window_hotkey_save_replay(GsrWindow *self) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + gsr_replay_page_activate_save(self->replay_page); +} + +#ifdef HAVE_WAYLAND +void +gsr_window_on_wayland_hotkeys_init(GsrWindow *self, gboolean success) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + + gsr_stream_page_set_wayland_hotkeys_supported(self->stream_page, success); + gsr_record_page_set_wayland_hotkeys_supported(self->record_page, success); + gsr_replay_page_set_wayland_hotkeys_supported(self->replay_page, success); +} +#endif /* HAVE_WAYLAND */ + +void +gsr_window_on_hotkey_changed(GsrWindow *self) +{ + g_return_if_fail(GSR_IS_WINDOW(self)); + + /* Save config so the new hotkey bindings are persisted */ + save_config(self); + + /* Re-grab X11 hotkeys with the updated bindings */ +#ifdef HAVE_X11 + if (self->hotkeys) + gsr_hotkeys_regrab_for_visible_page(self->hotkeys); +#endif +} + +const GsrConfig * +gsr_window_get_config(GsrWindow *self) +{ + g_return_val_if_fail(GSR_IS_WINDOW(self), NULL); + return &self->config; +} diff --git a/src/gsr-window.h b/src/gsr-window.h new file mode 100644 index 0000000..7b42c9e --- /dev/null +++ b/src/gsr-window.h @@ -0,0 +1,113 @@ +#pragma once + +#include <adwaita.h> +#include "gsr-config.h" + +G_BEGIN_DECLS + +#define GSR_TYPE_WINDOW (gsr_window_get_type()) +G_DECLARE_FINAL_TYPE(GsrWindow, gsr_window, GSR, WINDOW, AdwApplicationWindow) + +typedef enum { + GSR_ACTIVE_MODE_NONE, + GSR_ACTIVE_MODE_STREAM, + GSR_ACTIVE_MODE_RECORD, + GSR_ACTIVE_MODE_REPLAY, +} GsrActiveMode; + +GsrWindow *gsr_window_new(AdwApplication *app); + +void gsr_window_set_recording_active(GsrWindow *self, gboolean active); + +/* ── Process management (called from action pages) ───────────────── */ + +/** + * Start gpu-screen-recorder for the given mode. + * Returns TRUE on success, FALSE on fork failure. + */ +gboolean gsr_window_start_process(GsrWindow *self, + GsrActiveMode mode); + +/** + * Stop the running gpu-screen-recorder process (SIGINT + waitpid). + * Returns TRUE if the child exited successfully. + * Sets *already_dead if the child was already gone. + */ +gboolean gsr_window_stop_process (GsrWindow *self, + gboolean *already_dead); + +/** + * Send a signal to the running child process. + * Used for SIGUSR1 (save replay) and SIGUSR2 (pause/unpause). + */ +void gsr_window_send_signal (GsrWindow *self, int sig); + +/** + * Notify replay saved (respects notify_saved config pref). + */ +void gsr_window_notify_replay_saved(GsrWindow *self); + +/** + * Show a toast notification in the window. + */ +void gsr_window_show_toast (GsrWindow *self, + const char *message); + +/** + * Check if a process is currently running. + */ +gboolean gsr_window_is_process_running(GsrWindow *self); + +/** + * Get the active mode. + */ +GsrActiveMode gsr_window_get_active_mode(GsrWindow *self); + +/* ── Hotkey dispatch (called from gsr-hotkeys) ───────────────────── */ + +/** + * Get the name of the currently visible page ("config", "stream", + * "record", "replay"). Returns a borrowed string. + */ +const char *gsr_window_get_visible_page_name(GsrWindow *self); + +/** + * Hotkey: Start/Stop the active mode on the visible page. + * Programmatically activates the start/stop button on the visible page. + */ +void gsr_window_hotkey_start_stop (GsrWindow *self); + +/** + * Hotkey: Pause/Unpause recording (record page only). + */ +void gsr_window_hotkey_pause_unpause(GsrWindow *self); + +/** + * Hotkey: Save replay (replay page only). + */ +void gsr_window_hotkey_save_replay (GsrWindow *self); + +/** + * Called by gsr-hotkeys when Wayland global shortcuts init completes. + * Updates hotkey UI on all action pages. + */ +#ifdef HAVE_WAYLAND +void gsr_window_on_wayland_hotkeys_init(GsrWindow *self, + gboolean success); +#endif + +/* ── Hotkey config changes (called from action pages) ────────────── */ + +/** + * Called when the user changes a hotkey binding on an action page. + * Saves config to disk and re-grabs X11 hotkeys. + */ +void gsr_window_on_hotkey_changed(GsrWindow *self); + +/** + * Get a read-only pointer to the window's config struct. + * Used by gsr-hotkeys to read dynamic hotkey bindings. + */ +const GsrConfig *gsr_window_get_config(GsrWindow *self); + +G_END_DECLS diff --git a/src/gsr-x11-hotkeys.c b/src/gsr-x11-hotkeys.c new file mode 100644 index 0000000..a338441 --- /dev/null +++ b/src/gsr-x11-hotkeys.c @@ -0,0 +1,233 @@ +#include "gsr-x11-hotkeys.h" + +#include <stdlib.h> +#include <string.h> +#include <X11/Xlib.h> +#include <X11/Xutil.h> + +#include <glib.h> +#include <glib-unix.h> + +/* ── Internal types ──────────────────────────────────────────────── */ + +#define MAX_GRABBED_COMBOS 16 + +struct _GsrX11Hotkeys { + Display *display; + Window root; + unsigned int numlockmask; + + GsrX11HotkeyCombo combos[MAX_GRABBED_COMBOS]; + int num_combos; + + GsrX11HotkeyCallback callback; + void *userdata; + + GSource *source; /* GLib source watching the X fd */ + guint source_id; +}; + +/* ── NumLock detection ───────────────────────────────────────────── */ + +static unsigned int +detect_numlock_mask(Display *display) +{ + unsigned int mask = 0; + KeyCode numlock_keycode = XKeysymToKeycode(display, XK_Num_Lock); + XModifierKeymap *modmap = XGetModifierMapping(display); + if (modmap) { + for (int i = 0; i < 8; i++) { + for (int j = 0; j < modmap->max_keypermod; j++) { + if (modmap->modifiermap[i * modmap->max_keypermod + j] == numlock_keycode) + mask = (1u << i); + } + } + XFreeModifiermap(modmap); + } + return mask; +} + +/* ── X error handling ────────────────────────────────────────────── */ + +static bool x_grab_failed = false; + +static int +xerror_grab(Display *dpy G_GNUC_UNUSED, XErrorEvent *ev G_GNUC_UNUSED) +{ + x_grab_failed = true; + return 0; +} + +/* ── Strip NumLock/CapsLock from key state ───────────────────────── */ + +static unsigned int +key_state_without_locks(unsigned int state) +{ + return state & ~(Mod2Mask | LockMask); +} + +/* ── GSource callbacks for X fd polling ──────────────────────────── */ + +static gboolean +x11_source_prepare(GSource *source G_GNUC_UNUSED, gint *timeout) +{ + *timeout = -1; + return FALSE; +} + +static gboolean +x11_source_check(GSource *source G_GNUC_UNUSED) +{ + /* We just wake up; dispatch will check for events */ + return TRUE; +} + +static gboolean +x11_source_dispatch(GSource *source G_GNUC_UNUSED, + GSourceFunc callback G_GNUC_UNUSED, + gpointer user_data) +{ + GsrX11Hotkeys *self = user_data; + + while (XPending(self->display)) { + XEvent ev; + XNextEvent(self->display, &ev); + + if (ev.type != KeyRelease) + continue; + + KeySym keysym = XLookupKeysym(&ev.xkey, 0); + unsigned int state = key_state_without_locks(ev.xkey.state); + + for (int i = 0; i < self->num_combos; i++) { + if (keysym == self->combos[i].keysym && + state == self->combos[i].modifiers) + { + self->callback(self->combos[i].modifiers, + self->combos[i].keysym, + self->userdata); + break; + } + } + } + + return G_SOURCE_CONTINUE; +} + +static GSourceFuncs x11_source_funcs = { + .prepare = x11_source_prepare, + .check = x11_source_check, + .dispatch = x11_source_dispatch, + .finalize = NULL, +}; + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrX11Hotkeys * +gsr_x11_hotkeys_new(Display *display, + GsrX11HotkeyCallback callback, + void *userdata) +{ + if (!display || !callback) + return NULL; + + GsrX11Hotkeys *self = calloc(1, sizeof(GsrX11Hotkeys)); + if (!self) + return NULL; + + self->display = display; + self->root = DefaultRootWindow(display); + self->numlockmask = detect_numlock_mask(display); + self->callback = callback; + self->userdata = userdata; + self->num_combos = 0; + + /* Create a GSource that polls the X connection fd */ + int x_fd = ConnectionNumber(display); + self->source = g_source_new(&x11_source_funcs, sizeof(GSource)); + g_source_set_callback(self->source, NULL, self, NULL); + g_source_add_unix_fd(self->source, x_fd, G_IO_IN | G_IO_HUP | G_IO_ERR); + self->source_id = g_source_attach(self->source, NULL); + + return self; +} + +void +gsr_x11_hotkeys_free(GsrX11Hotkeys *self) +{ + if (!self) + return; + + gsr_x11_hotkeys_ungrab_all(self); + + if (self->source) { + g_source_destroy(self->source); + g_source_unref(self->source); + self->source = NULL; + } + + free(self); +} + +void +gsr_x11_hotkeys_ungrab_all(GsrX11Hotkeys *self) +{ + if (!self) + return; + + unsigned int modifiers[] = { 0, LockMask, self->numlockmask, + self->numlockmask | LockMask }; + + for (int i = 0; i < self->num_combos; i++) { + KeyCode kc = XKeysymToKeycode(self->display, self->combos[i].keysym); + for (int m = 0; m < 4; m++) { + XUngrabKey(self->display, kc, + self->combos[i].modifiers | modifiers[m], + self->root); + } + } + XSync(self->display, False); + self->num_combos = 0; +} + +bool +gsr_x11_hotkeys_grab(GsrX11Hotkeys *self, GsrX11HotkeyCombo combo) +{ + if (!self || self->num_combos >= MAX_GRABBED_COMBOS) + return false; + + if (combo.keysym == None && combo.modifiers == 0) + return true; /* nothing to grab */ + + unsigned int modifiers[] = { 0, LockMask, self->numlockmask, + self->numlockmask | LockMask }; + + XSync(self->display, False); + x_grab_failed = false; + XErrorHandler prev = XSetErrorHandler(xerror_grab); + + KeyCode kc = XKeysymToKeycode(self->display, combo.keysym); + for (int m = 0; m < 4; m++) { + XGrabKey(self->display, kc, + combo.modifiers | modifiers[m], + self->root, False, + GrabModeAsync, GrabModeAsync); + } + + XSync(self->display, False); + XSetErrorHandler(prev); + + if (x_grab_failed) { + /* Undo partial grabs */ + for (int m = 0; m < 4; m++) { + XUngrabKey(self->display, kc, + combo.modifiers | modifiers[m], + self->root); + } + XSync(self->display, False); + return false; + } + + self->combos[self->num_combos++] = combo; + return true; +} diff --git a/src/gsr-x11-hotkeys.h b/src/gsr-x11-hotkeys.h new file mode 100644 index 0000000..8c528af --- /dev/null +++ b/src/gsr-x11-hotkeys.h @@ -0,0 +1,46 @@ +#pragma once + +/* + * gsr-x11-hotkeys.h — X11 global hotkey grabbing for GTK4. + * + * GTK4 removed gdk_window_add_filter(), so we poll the X connection fd + * via a GLib GSource to receive KeyRelease events for grabbed combos. + */ + +#include <stdbool.h> +#include <X11/Xlib.h> + +typedef void (*GsrX11HotkeyCallback)(unsigned int modifiers, KeySym keysym, void *userdata); + +typedef struct { + unsigned int modifiers; /* X11 modifier mask (ControlMask, Mod1Mask, etc.) */ + KeySym keysym; /* e.g. XK_1, XK_2 */ +} GsrX11HotkeyCombo; + +typedef struct _GsrX11Hotkeys GsrX11Hotkeys; + +/** + * Create a new X11 hotkey watcher on the given display. + * The callback fires on KeyRelease matching any grabbed combo. + * Returns NULL on failure. + */ +GsrX11Hotkeys *gsr_x11_hotkeys_new(Display *display, + GsrX11HotkeyCallback callback, + void *userdata); + +/** + * Destroy the hotkey watcher and ungrab all keys. + */ +void gsr_x11_hotkeys_free(GsrX11Hotkeys *self); + +/** + * Ungrab all currently grabbed keys. + */ +void gsr_x11_hotkeys_ungrab_all(GsrX11Hotkeys *self); + +/** + * Grab a key combo. The combo is added to the internal list and + * grabbed with NumLock/CapsLock variants. + * Returns true on success, false if XGrabKey failed. + */ +bool gsr_x11_hotkeys_grab(GsrX11Hotkeys *self, GsrX11HotkeyCombo combo); diff --git a/src/gsr-x11-window-picker.c b/src/gsr-x11-window-picker.c new file mode 100644 index 0000000..83f1aa2 --- /dev/null +++ b/src/gsr-x11-window-picker.c @@ -0,0 +1,329 @@ +#include "gsr-x11-window-picker.h" + +#include <stdlib.h> +#include <string.h> +#include <X11/Xutil.h> +#include <X11/cursorfont.h> +#include <X11/keysym.h> +#include <X11/Xatom.h> + +#include <glib.h> +#include <glib-unix.h> + +/* ── Internal struct ─────────────────────────────────────────────── */ + +struct _GsrX11WindowPicker { + Display *display; /* own connection */ + Window root; + Cursor crosshair; + GsrX11WindowPickCallback callback; + void *userdata; + GSource *source; + guint source_id; + bool finished; /* prevents double callback */ +}; + +/* ── X11 tree walker ─────────────────────────────────────────────── */ + +/** + * Check if a window has a given atom property. + */ +static bool +window_has_atom(Display *display, Window window, Atom atom) +{ + Atom type_ret; + int format_ret; + unsigned long nitems, bytes_after; + unsigned char *data = NULL; + + int rc = XGetWindowProperty(display, window, atom, + 0, 0, False, AnyPropertyType, + &type_ret, &format_ret, + &nitems, &bytes_after, &data); + if (data) + XFree(data); + + return rc == Success && type_ret != None; +} + +/** + * Walk the window tree looking for the first child with _NET_WM_STATE, + * which is the "real" toplevel window managed by the WM. + */ +static Window +find_toplevel_window(Display *display, Window window) +{ + if (window == None) + return None; + + Atom wm_state = XInternAtom(display, "_NET_WM_STATE", False); + if (!wm_state) + return None; + + if (window_has_atom(display, window, wm_state)) + return window; + + Window root, parent; + Window *children = NULL; + unsigned int n_children = 0; + + if (!XQueryTree(display, window, &root, &parent, &children, &n_children) + || !children) + return None; + + Window found = None; + + /* First pass: direct children with _NET_WM_STATE */ + for (int i = (int)n_children - 1; i >= 0; i--) { + if (children[i] && window_has_atom(display, children[i], wm_state)) { + found = children[i]; + goto done; + } + } + /* Second pass: recurse */ + for (int i = (int)n_children - 1; i >= 0; i--) { + if (children[i]) { + Window w = find_toplevel_window(display, children[i]); + if (w != None) { + found = w; + goto done; + } + } + } + +done: + XFree(children); + return found; +} + +/** + * Get the WM_NAME of a window. Returns a g_malloc'd string, or NULL. + */ +static char * +get_window_name(Display *display, Window window) +{ + if (window == None) + return NULL; + + /* Try _NET_WM_NAME first (UTF-8) */ + Atom net_wm_name = XInternAtom(display, "_NET_WM_NAME", False); + Atom utf8_string = XInternAtom(display, "UTF8_STRING", False); + + if (net_wm_name && utf8_string) { + Atom type_ret; + int format_ret; + unsigned long nitems, bytes_after; + unsigned char *data = NULL; + + int rc = XGetWindowProperty(display, window, net_wm_name, + 0, 1024, False, utf8_string, + &type_ret, &format_ret, + &nitems, &bytes_after, &data); + if (rc == Success && data && nitems > 0) { + char *name = g_strdup((const char *)data); + XFree(data); + return name; + } + if (data) + XFree(data); + } + + /* Fallback: XGetWMName */ + XTextProperty wm_name; + if (XGetWMName(display, window, &wm_name) && wm_name.nitems > 0) { + char **list = NULL; + int count = 0; + char *result = NULL; + + if (Xutf8TextPropertyToTextList(display, &wm_name, &list, &count) >= 0 + && list && count > 0 && list[0]) + { + result = g_strdup(list[0]); + } else { + /* Last resort: just copy the raw value */ + result = g_strndup((const char *)wm_name.value, wm_name.nitems); + } + + if (list) + XFreeStringList(list); + if (wm_name.value) + XFree(wm_name.value); + + return result; + } + + return NULL; +} + +/* ── Finish the pick ─────────────────────────────────────────────── */ + +static void +finish_pick(GsrX11WindowPicker *self, Window window, char *name) +{ + if (self->finished) + return; + self->finished = true; + + XUngrabPointer(self->display, CurrentTime); + XUngrabKeyboard(self->display, CurrentTime); + XSync(self->display, False); + + GsrX11WindowPickResult result = { + .window = window, + .name = name, + }; + self->callback(&result, self->userdata); + + /* Self-destroy after callback */ + gsr_x11_window_picker_free(self); +} + +/* ── GSource dispatch — poll X events ────────────────────────────── */ + +static gboolean +picker_source_prepare(GSource *source G_GNUC_UNUSED, gint *timeout) +{ + *timeout = -1; + return FALSE; +} + +static gboolean +picker_source_check(GSource *source G_GNUC_UNUSED) +{ + return TRUE; +} + +static gboolean +picker_source_dispatch(GSource *source G_GNUC_UNUSED, + GSourceFunc callback G_GNUC_UNUSED, + gpointer user_data) +{ + GsrX11WindowPicker *self = user_data; + + if (self->finished) + return G_SOURCE_REMOVE; + + while (XPending(self->display)) { + XEvent ev; + XNextEvent(self->display, &ev); + + if (ev.type == ButtonPress) { + Window clicked = ev.xbutton.subwindow; + if (clicked == None) + clicked = ev.xbutton.window; + + /* Walk tree to find the real toplevel */ + Window toplevel = find_toplevel_window(self->display, clicked); + if (toplevel != None) + clicked = toplevel; + + if (clicked == None || clicked == self->root) { + /* Clicked root / empty space — cancel */ + finish_pick(self, None, NULL); + return G_SOURCE_REMOVE; + } + + char *name = get_window_name(self->display, clicked); + if (!name) + name = g_strdup("(no name)"); + + finish_pick(self, clicked, name); + return G_SOURCE_REMOVE; + } + + if (ev.type == KeyPress) { + KeySym ks = XLookupKeysym(&ev.xkey, 0); + if (ks == XK_Escape) { + finish_pick(self, None, NULL); + return G_SOURCE_REMOVE; + } + } + } + + return G_SOURCE_CONTINUE; +} + +static GSourceFuncs picker_source_funcs = { + .prepare = picker_source_prepare, + .check = picker_source_check, + .dispatch = picker_source_dispatch, + .finalize = NULL, +}; + +/* ── Public API ──────────────────────────────────────────────────── */ + +GsrX11WindowPicker * +gsr_x11_window_picker_new(GsrX11WindowPickCallback callback, + void *userdata) +{ + if (!callback) + return NULL; + + /* Open our own X connection so we don't conflict with GDK */ + Display *dpy = XOpenDisplay(NULL); + if (!dpy) { + g_warning("gsr_x11_window_picker: failed to open X display"); + return NULL; + } + + GsrX11WindowPicker *self = g_new0(GsrX11WindowPicker, 1); + self->display = dpy; + self->root = DefaultRootWindow(dpy); + self->callback = callback; + self->userdata = userdata; + self->finished = false; + + self->crosshair = XCreateFontCursor(dpy, XC_crosshair); + + /* Grab pointer with crosshair */ + int status = XGrabPointer(dpy, self->root, False, + ButtonPressMask | ButtonReleaseMask, + GrabModeAsync, GrabModeAsync, + self->root, self->crosshair, CurrentTime); + if (status != GrabSuccess) { + g_warning("gsr_x11_window_picker: XGrabPointer failed (%d)", status); + XFreeCursor(dpy, self->crosshair); + XCloseDisplay(dpy); + g_free(self); + return NULL; + } + + /* Also grab keyboard so we can detect Escape */ + XGrabKeyboard(dpy, self->root, False, + GrabModeAsync, GrabModeAsync, CurrentTime); + + XSync(dpy, False); + + /* Set up GSource to poll X events */ + int x_fd = ConnectionNumber(dpy); + self->source = g_source_new(&picker_source_funcs, sizeof(GSource)); + g_source_set_callback(self->source, NULL, self, NULL); + g_source_add_unix_fd(self->source, x_fd, G_IO_IN | G_IO_HUP | G_IO_ERR); + self->source_id = g_source_attach(self->source, NULL); + + return self; +} + +void +gsr_x11_window_picker_free(GsrX11WindowPicker *self) +{ + if (!self) + return; + + if (self->source) { + g_source_destroy(self->source); + g_source_unref(self->source); + self->source = NULL; + } + + if (self->display) { + XUngrabPointer(self->display, CurrentTime); + XUngrabKeyboard(self->display, CurrentTime); + if (self->crosshair) + XFreeCursor(self->display, self->crosshair); + XCloseDisplay(self->display); + self->display = NULL; + } + + g_free(self); +} diff --git a/src/gsr-x11-window-picker.h b/src/gsr-x11-window-picker.h new file mode 100644 index 0000000..05a7111 --- /dev/null +++ b/src/gsr-x11-window-picker.h @@ -0,0 +1,56 @@ +#pragma once + +/* + * gsr-x11-window-picker.h — Interactive X11 window picker. + * + * Grabs the pointer with a crosshair cursor, waits for a click, + * walks the X11 window tree to find the real toplevel, and reports + * the result via callback. + * + * Uses its own X11 display connection + GLib GSource to avoid + * interfering with GDK's event loop. + * + * X11 only — do NOT use on Wayland. + */ + +#include <stdbool.h> +#include <X11/Xlib.h> + +typedef struct _GsrX11WindowPicker GsrX11WindowPicker; + +/** + * Result of a window pick. If window == None, the pick was cancelled + * (Escape pressed or no valid window found). + */ +typedef struct { + Window window; /* X11 window ID, or None on cancel */ + char *name; /* Window name (heap-allocated), or NULL on cancel. + Caller must g_free() / free(). */ +} GsrX11WindowPickResult; + +/** + * Callback fired when the user has clicked a window or cancelled. + * Called from the GLib main loop context. + */ +typedef void (*GsrX11WindowPickCallback)(const GsrX11WindowPickResult *result, + void *userdata); + +/** + * Create a window picker. Returns NULL on failure (e.g. cannot open + * X display or grab pointer). + * + * The picker grabs the pointer immediately. When the user clicks or + * presses Escape, @callback is invoked and the picker is automatically + * destroyed (do NOT call _free after the callback fires). + * + * @callback: result callback + * @userdata: passed to callback + */ +GsrX11WindowPicker *gsr_x11_window_picker_new(GsrX11WindowPickCallback callback, + void *userdata); + +/** + * Cancel and destroy an in-progress pick. + * Safe to call if picker is NULL. + */ +void gsr_x11_window_picker_free(GsrX11WindowPicker *self); diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..cd90ea8 --- /dev/null +++ b/src/main.c @@ -0,0 +1,138 @@ +#include <adwaita.h> +#include "gsr-window.h" + +/* ── About dialog ────────────────────────────────────────────────── */ + +static void +on_about_action(GSimpleAction *action, + GVariant *parameter, + gpointer user_data) +{ + (void)action; + (void)parameter; + + GtkApplication *app = GTK_APPLICATION(user_data); + GtkWindow *win = gtk_application_get_active_window(app); + + AdwAboutDialog *about = ADW_ABOUT_DIALOG(adw_about_dialog_new()); + adw_about_dialog_set_application_name(about, "GPU Screen Recorder"); + adw_about_dialog_set_version(about, GSR_VERSION); + adw_about_dialog_set_developer_name(about, "Trung Lê"); + adw_about_dialog_set_application_icon(about, + "com.dec05eba.gpu_screen_recorder"); + adw_about_dialog_set_license_type(about, GTK_LICENSE_GPL_3_0); + adw_about_dialog_set_website(about, + "https://github.com/runlevel5/gpu-screen-recorder-adwaita"); + adw_about_dialog_set_issue_url(about, + "https://github.com/runlevel5/gpu-screen-recorder-adwaita/issues"); + + const char *developers[] = { "Trung Lê", NULL }; + adw_about_dialog_set_developers(about, developers); + + adw_about_dialog_add_credit_section(about, "Original GTK3 Version", + (const char *[]){ "dec05eba", NULL }); + + adw_dialog_present(ADW_DIALOG(about), GTK_WIDGET(win)); +} + +/* ── Keyboard shortcuts dialog ───────────────────────────────────── */ + +static void +on_shortcuts_action(GSimpleAction *action, + GVariant *parameter, + gpointer user_data) +{ + (void)action; + (void)parameter; + + GtkApplication *app = GTK_APPLICATION(user_data); + GtkWindow *win = gtk_application_get_active_window(app); + + AdwShortcutsDialog *dialog = ADW_SHORTCUTS_DIALOG( + adw_shortcuts_dialog_new()); + + /* ── Stream section ─── */ + AdwShortcutsSection *stream_sec = adw_shortcuts_section_new("Stream"); + adw_shortcuts_section_add(stream_sec, + adw_shortcuts_item_new("Start / Stop streaming", "<Alt>1")); + adw_shortcuts_dialog_add(dialog, stream_sec); + + /* ── Record section ─── */ + AdwShortcutsSection *record_sec = adw_shortcuts_section_new("Record"); + adw_shortcuts_section_add(record_sec, + adw_shortcuts_item_new("Start / Stop recording", "<Alt>1")); + adw_shortcuts_section_add(record_sec, + adw_shortcuts_item_new("Pause / Unpause recording", "<Alt>2")); + adw_shortcuts_dialog_add(dialog, record_sec); + + /* ── Replay section ─── */ + AdwShortcutsSection *replay_sec = adw_shortcuts_section_new("Replay"); + adw_shortcuts_section_add(replay_sec, + adw_shortcuts_item_new("Start / Stop replay", "<Alt>1")); + adw_shortcuts_section_add(replay_sec, + adw_shortcuts_item_new("Save replay", "<Alt>2")); + adw_shortcuts_dialog_add(dialog, replay_sec); + + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(win)); +} + +/* ── Application activate ────────────────────────────────────────── */ + +static void +load_custom_css(void) +{ + GtkCssProvider *provider = gtk_css_provider_new(); + gtk_css_provider_load_from_string(provider, + ".recording-active { color: @error_color; }\n" + ".recording-paused { color: @warning_color; }\n"); + gtk_style_context_add_provider_for_display( + gdk_display_get_default(), + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); +} + +static void +on_activate(GtkApplication *app, gpointer user_data) +{ + (void)user_data; + + static gboolean css_loaded = FALSE; + if (!css_loaded) { + load_custom_css(); + css_loaded = TRUE; + } + + GtkWindow *win = gtk_application_get_active_window(app); + if (win == NULL) { + win = GTK_WINDOW(gsr_window_new(ADW_APPLICATION(app))); + } + gtk_window_present(win); +} + +/* ── Main ────────────────────────────────────────────────────────── */ + +int +main(int argc, char *argv[]) +{ + AdwApplication *app = adw_application_new( + "com.dec05eba.gpu_screen_recorder", + G_APPLICATION_DEFAULT_FLAGS); + + static const GActionEntry app_actions[] = { + { .name = "shortcuts", .activate = on_shortcuts_action }, + { .name = "about", .activate = on_about_action }, + }; + g_action_map_add_action_entries(G_ACTION_MAP(app), + app_actions, G_N_ELEMENTS(app_actions), app); + + gtk_application_set_accels_for_action(GTK_APPLICATION(app), + "app.shortcuts", + (const char *[]){ "<Ctrl>question", NULL }); + + g_signal_connect(app, "activate", G_CALLBACK(on_activate), NULL); + + int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + return status; +} |
