aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/gsr-config-page.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/gsr-config-page.c')
-rw-r--r--src/gsr-config-page.c1403
1 files changed, 1403 insertions, 0 deletions
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
+}