Files
transui/src/main.cpp
T
2026-08-14 12:49:58 +02:00

646 lines
25 KiB
C++

#include <gtk/gtk.h>
#include <gst/gst.h>
#include <curl/curl.h>
#include <libsecret/secret.h>
#include <json-glib/json-glib.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <glib/gstdio.h>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace {
constexpr const char* kAppId = "io.github.transui";
constexpr const char* kConfigGroup = "transui";
const SecretSchema kSecretSchema = {
"io.github.transui.credentials", SECRET_SCHEMA_NONE,
{{"scope", SECRET_SCHEMA_ATTRIBUTE_STRING}, {nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING}}};
struct Config {
std::string hosted_url;
std::string local_url = "http://localhost:8080";
std::string prompt;
bool hosted_first = true;
bool clipboard = false;
};
struct App {
GtkApplication* gtk_app = nullptr;
Config config;
std::string hosted_key;
std::string local_key;
GtkWidget* settings = nullptr;
GtkWidget* recorder = nullptr;
GtkWidget* processing = nullptr;
GtkWidget* elapsed_label = nullptr;
guint elapsed_source = 0;
guint elapsed_seconds = 0;
GstElement* pipeline = nullptr;
std::string recording_path;
std::atomic<bool> cancelled{false};
};
struct SettingsFields {
GtkWidget* hosted_url;
GtkWidget* hosted_key;
GtkWidget* local_url;
GtkWidget* local_key;
GtkWidget* prompt;
GtkWidget* hosted_first;
GtkWidget* destination;
};
struct Job {
App* app;
std::string file;
Config config;
std::string hosted_key;
std::string local_key;
bool ok = false;
std::string text;
std::string error;
};
std::string config_path() {
return std::string(g_get_user_config_dir()) + "/transui/settings.ini";
}
std::string endpoint_url(std::string url) {
while (!url.empty() && url.back() == '/') url.pop_back();
if (url.find("/v1/audio/transcriptions") != std::string::npos) return url;
return url + "/v1/audio/transcriptions";
}
void show_error(GtkWindow* parent, const std::string& message) {
GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL,
GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", message.c_str());
gtk_dialog_run(GTK_DIALOG(dialog));
gtk_widget_destroy(dialog);
}
void show_info(GtkWindow* parent, const std::string& message) {
GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL,
GTK_MESSAGE_INFO, GTK_BUTTONS_CLOSE, "%s", message.c_str());
gtk_dialog_run(GTK_DIALOG(dialog));
gtk_widget_destroy(dialog);
}
std::string secret_for(const char* scope) {
GError* error = nullptr;
gchar* value = secret_password_lookup_sync(&kSecretSchema, nullptr, &error,
"scope", scope, nullptr);
if (error) {
g_error_free(error);
return {};
}
std::string result = value ? value : "";
secret_password_free(value);
return result;
}
bool save_secret(const char* scope, const std::string& value, std::string* err) {
GError* error = nullptr;
gboolean ok = secret_password_store_sync(&kSecretSchema, SECRET_COLLECTION_DEFAULT,
scope, value.c_str(), nullptr, &error, "scope", scope, nullptr);
if (!ok && error) {
*err = error->message;
g_error_free(error);
}
return ok;
}
void load_config(App* app) {
GKeyFile* file = g_key_file_new();
GError* error = nullptr;
if (g_key_file_load_from_file(file, config_path().c_str(), G_KEY_FILE_NONE, &error)) {
gchar* value = nullptr;
value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr);
if (value) { app->config.hosted_url = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr);
if (value) { app->config.local_url = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr);
if (value) { app->config.prompt = value; g_free(value); }
if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr))
app->config.hosted_first = g_key_file_get_boolean(file, kConfigGroup, "hosted_first", nullptr);
if (g_key_file_has_key(file, kConfigGroup, "clipboard", nullptr))
app->config.clipboard = g_key_file_get_boolean(file, kConfigGroup, "clipboard", nullptr);
}
if (error) g_error_free(error);
g_key_file_unref(file);
app->hosted_key = secret_for("hosted");
app->local_key = secret_for("local");
}
bool save_config(App* app, const Config& config, const std::string& hosted_key,
const std::string& local_key, std::string* err) {
GKeyFile* file = g_key_file_new();
g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str());
g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str());
g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str());
g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first);
g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard);
gsize length = 0;
gchar* contents = g_key_file_to_data(file, &length, nullptr);
gchar* folder = g_path_get_dirname(config_path().c_str());
g_mkdir_with_parents(folder, 0700);
GError* error = nullptr;
const bool saved = g_file_set_contents(config_path().c_str(), contents, length, &error);
g_free(folder);
g_free(contents);
g_key_file_unref(file);
if (!saved) {
*err = error ? error->message : "Could not save settings";
if (error) g_error_free(error);
return false;
}
if (!save_secret("hosted", hosted_key, err) || !save_secret("local", local_key, err)) return false;
app->config = config;
app->hosted_key = hosted_key;
app->local_key = local_key;
return true;
}
GtkWidget* labelled(GtkWidget* grid, const char* text, GtkWidget* control, int row) {
GtkWidget* label = gtk_label_new(text);
gtk_label_set_xalign(GTK_LABEL(label), 0);
gtk_widget_set_valign(label, GTK_ALIGN_CENTER);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
gtk_widget_set_hexpand(control, true);
gtk_grid_attach(GTK_GRID(grid), control, 1, row, 1, 1);
return control;
}
void settings_closed(GtkWidget*, gpointer data) {
static_cast<App*>(data)->settings = nullptr;
}
void save_clicked(GtkButton*, gpointer data) {
auto* fields = static_cast<SettingsFields*>(data);
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(fields->hosted_url), "app"));
Config config;
config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url));
config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url));
config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt));
config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first));
config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1;
std::string error;
if (!save_config(app, config, gtk_entry_get_text(GTK_ENTRY(fields->hosted_key)),
gtk_entry_get_text(GTK_ENTRY(fields->local_key)), &error)) {
show_error(GTK_WINDOW(app->settings), error);
return;
}
gtk_widget_destroy(app->settings);
}
void open_settings(App* app) {
if (app->settings) {
gtk_window_present(GTK_WINDOW(app->settings));
return;
}
GtkWidget* window = gtk_application_window_new(app->gtk_app);
app->settings = window;
gtk_window_set_title(GTK_WINDOW(window), "Transui settings");
gtk_window_set_default_size(GTK_WINDOW(window), 590, 0);
gtk_window_set_resizable(GTK_WINDOW(window), false);
GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), root);
GtkWidget* header = gtk_header_bar_new();
gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), true);
gtk_header_bar_set_title(GTK_HEADER_BAR(header), "Transui");
gtk_window_set_titlebar(GTK_WINDOW(window), header);
GtkWidget* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 12);
gtk_grid_set_column_spacing(GTK_GRID(grid), 18);
gtk_widget_set_margin_start(grid, 28);
gtk_widget_set_margin_end(grid, 28);
gtk_widget_set_margin_top(grid, 26);
gtk_widget_set_margin_bottom(grid, 18);
gtk_box_pack_start(GTK_BOX(root), grid, true, true, 0);
auto* fields = new SettingsFields();
fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0);
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 1);
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 2);
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 3);
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 4);
fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback");
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 5, 1, 1);
fields->destination = gtk_combo_box_text_new();
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard");
labelled(grid, "Output", fields->destination, 6);
gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false);
gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false);
gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str());
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first);
gtk_combo_box_set_active(GTK_COMBO_BOX(fields->destination), app->config.clipboard ? 1 : 0);
g_object_set_data(G_OBJECT(fields->hosted_url), "app", app);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_margin_start(actions, 28);
gtk_widget_set_margin_end(actions, 28);
gtk_widget_set_margin_bottom(actions, 24);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget* save = gtk_button_new_with_label("Save");
gtk_style_context_add_class(gtk_widget_get_style_context(save), "suggested-action");
gtk_box_pack_end(GTK_BOX(actions), save, false, false, 0);
gtk_box_pack_start(GTK_BOX(root), actions, false, false, 0);
g_signal_connect(save, "clicked", G_CALLBACK(save_clicked), fields);
g_signal_connect_data(window, "destroy", G_CALLBACK(settings_closed), app,
nullptr, G_CONNECT_AFTER);
g_signal_connect_data(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer p) {
delete static_cast<SettingsFields*>(p);
}), fields, nullptr, G_CONNECT_AFTER);
gtk_widget_show_all(window);
}
gboolean tick_elapsed(gpointer data) {
auto* app = static_cast<App*>(data);
app->elapsed_seconds++;
const unsigned minutes = app->elapsed_seconds / 60;
const unsigned seconds = app->elapsed_seconds % 60;
gchar* text = g_strdup_printf("%02u:%02u", minutes, seconds);
gtk_label_set_text(GTK_LABEL(app->elapsed_label), text);
g_free(text);
return G_SOURCE_CONTINUE;
}
void remove_recording(App* app) {
if (!app->recording_path.empty()) {
g_remove(app->recording_path.c_str());
app->recording_path.clear();
}
}
void close_recorder(App* app) {
if (app->elapsed_source) {
g_source_remove(app->elapsed_source);
app->elapsed_source = 0;
}
if (app->pipeline) {
gst_element_set_state(app->pipeline, GST_STATE_NULL);
gst_object_unref(app->pipeline);
app->pipeline = nullptr;
}
GtkWidget* window = app->recorder;
app->recorder = nullptr;
if (window) gtk_widget_destroy(window);
}
size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) {
auto* output = static_cast<std::string*>(data);
output->append(buffer, size * nmemb);
return size * nmemb;
}
bool transcription_request(const std::string& url, const std::string& key,
const std::string& path, const std::string& prompt,
std::string* transcript, std::string* error) {
CURL* curl = curl_easy_init();
if (!curl) { *error = "Could not initialize HTTP client"; return false; }
std::string body;
curl_mime* mime = curl_mime_init(curl);
curl_mimepart* part = curl_mime_addpart(mime);
curl_mime_name(part, "response_format");
curl_mime_data(part, "json", CURL_ZERO_TERMINATED);
part = curl_mime_addpart(mime);
curl_mime_name(part, "file");
curl_mime_filedata(part, path.c_str());
if (!prompt.empty()) {
part = curl_mime_addpart(mime);
curl_mime_name(part, "prompt");
curl_mime_data(part, prompt.c_str(), CURL_ZERO_TERMINATED);
}
struct curl_slist* headers = nullptr;
if (!key.empty()) {
headers = curl_slist_append(headers, ("Authorization: Bearer " + key).c_str());
}
curl_easy_setopt(curl, CURLOPT_URL, endpoint_url(url).c_str());
curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 12L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
CURLcode result = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_mime_free(mime);
curl_easy_cleanup(curl);
if (result != CURLE_OK) { *error = curl_easy_strerror(result); return false; }
if (status < 200 || status >= 300) {
*error = "Server returned HTTP " + std::to_string(status);
return false;
}
JsonParser* parser = json_parser_new();
GError* parse_error = nullptr;
bool parsed = json_parser_load_from_data(parser, body.c_str(), body.length(), &parse_error);
if (!parsed) {
*error = "Invalid JSON response";
if (parse_error) g_error_free(parse_error);
g_object_unref(parser);
return false;
}
JsonNode* root = json_parser_get_root(parser);
if (!JSON_NODE_HOLDS_OBJECT(root)) {
*error = "Response does not contain a transcript";
g_object_unref(parser);
return false;
}
JsonObject* object = json_node_get_object(root);
if (!json_object_has_member(object, "text")) {
*error = "Response does not contain a text field";
g_object_unref(parser);
return false;
}
*transcript = json_object_get_string_member(object, "text");
g_object_unref(parser);
return true;
}
void set_clipboard(const std::string& text) {
GdkDisplay* display = gdk_display_get_default();
GtkClipboard* clipboard = gtk_clipboard_get_for_display(display, GDK_SELECTION_CLIPBOARD);
gtk_clipboard_set_text(clipboard, text.c_str(), text.size());
gtk_clipboard_store(clipboard);
}
bool type_text(const std::string& text, std::string* error) {
const bool wayland = g_getenv("WAYLAND_DISPLAY") && *g_getenv("WAYLAND_DISPLAY");
gchar* program = g_find_program_in_path(wayland ? "wtype" : "xdotool");
if (!program) {
*error = wayland ? "wtype is not installed; copied to clipboard." :
"xdotool is not installed; copied to clipboard.";
return false;
}
std::vector<gchar*> argv;
argv.push_back(program);
if (!wayland) {
argv.push_back(g_strdup("type"));
argv.push_back(g_strdup("--delay"));
argv.push_back(g_strdup("0"));
argv.push_back(g_strdup("--"));
}
argv.push_back(g_strdup(text.c_str()));
argv.push_back(nullptr);
GError* spawn_error = nullptr;
gboolean ok = g_spawn_async(nullptr, argv.data(), nullptr, G_SPAWN_SEARCH_PATH,
nullptr, nullptr, nullptr, &spawn_error);
for (size_t i = 0; i + 1 < argv.size(); ++i) g_free(argv[i]);
if (!ok) {
*error = spawn_error ? spawn_error->message : "Could not type text";
if (spawn_error) g_error_free(spawn_error);
}
return ok;
}
gboolean finish_job(gpointer data) {
std::unique_ptr<Job> job(static_cast<Job*>(data));
App* app = job->app;
if (app->processing) {
GtkWidget* window = app->processing;
app->processing = nullptr;
gtk_widget_destroy(window);
}
remove_recording(app);
if (app->cancelled.load()) return G_SOURCE_REMOVE;
if (!job->ok) {
show_error(nullptr, job->error);
return G_SOURCE_REMOVE;
}
if (job->config.clipboard) {
set_clipboard(job->text);
show_info(nullptr, "Transcript copied to clipboard.");
return G_SOURCE_REMOVE;
}
std::string error;
if (!type_text(job->text, &error)) {
set_clipboard(job->text);
show_error(nullptr, error);
}
return G_SOURCE_REMOVE;
}
gpointer job_thread(gpointer data) {
auto* job = static_cast<Job*>(data);
std::string hosted_error;
if (job->config.hosted_first && !job->config.hosted_url.empty()) {
job->ok = transcription_request(job->config.hosted_url, job->hosted_key, job->file,
job->config.prompt, &job->text, &hosted_error);
if (job->ok) {
g_main_context_invoke(nullptr, finish_job, job);
return nullptr;
}
}
job->ok = transcription_request(job->config.local_url, job->local_key, job->file,
job->config.prompt, &job->text, &job->error);
if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error;
g_main_context_invoke(nullptr, finish_job, job);
return nullptr;
}
void processing_closed(GtkWidget*, gpointer data) {
auto* app = static_cast<App*>(data);
app->processing = nullptr;
}
void cancel_processing(GtkButton* button, gpointer data) {
auto* app = static_cast<App*>(data);
app->cancelled.store(true);
gtk_button_set_label(button, "Cancelling");
gtk_widget_set_sensitive(GTK_WIDGET(button), false);
}
void show_processing(App* app) {
GtkWidget* window = gtk_application_window_new(app->gtk_app);
app->processing = window;
gtk_window_set_title(GTK_WINDOW(window), "Transui");
gtk_window_set_default_size(GTK_WINDOW(window), 310, 190);
gtk_window_set_resizable(GTK_WINDOW(window), false);
gtk_window_set_deletable(GTK_WINDOW(window), false);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_container_add(GTK_CONTAINER(window), box);
GtkWidget* spinner = gtk_spinner_new();
gtk_widget_set_size_request(spinner, 36, 36);
gtk_spinner_start(GTK_SPINNER(spinner));
gtk_box_pack_start(GTK_BOX(box), spinner, false, false, 0);
GtkWidget* label = gtk_label_new("Transcribing");
gtk_style_context_add_class(gtk_widget_get_style_context(label), "state-title");
gtk_box_pack_start(GTK_BOX(box), label, false, false, 0);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
gtk_box_pack_start(GTK_BOX(box), cancel, false, false, 8);
g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_processing), app);
g_signal_connect(window, "destroy", G_CALLBACK(processing_closed), app);
gtk_widget_show_all(window);
}
void stop_recording(GtkButton*, gpointer data) {
auto* app = static_cast<App*>(data);
if (!app->pipeline) return;
if (app->elapsed_source) {
g_source_remove(app->elapsed_source);
app->elapsed_source = 0;
}
gst_element_send_event(app->pipeline, gst_event_new_eos());
GstBus* bus = gst_element_get_bus(app->pipeline);
GstMessage* message = gst_bus_timed_pop_filtered(bus, 3 * GST_SECOND,
static_cast<GstMessageType>(GST_MESSAGE_EOS | GST_MESSAGE_ERROR));
if (message) gst_message_unref(message);
gst_object_unref(bus);
gst_element_set_state(app->pipeline, GST_STATE_NULL);
gst_object_unref(app->pipeline);
app->pipeline = nullptr;
if (app->recorder) gtk_widget_destroy(app->recorder);
app->recorder = nullptr;
app->cancelled.store(false);
show_processing(app);
auto* job = new Job{app, app->recording_path, app->config, app->hosted_key, app->local_key};
g_thread_unref(g_thread_new("transcription", job_thread, job));
}
void cancel_recording(GtkButton*, gpointer data) {
auto* app = static_cast<App*>(data);
close_recorder(app);
remove_recording(app);
}
void recorder_closed(GtkWidget*, gpointer data) {
auto* app = static_cast<App*>(data);
app->recorder = nullptr;
if (app->elapsed_source) {
g_source_remove(app->elapsed_source);
app->elapsed_source = 0;
}
if (app->pipeline) {
gst_element_set_state(app->pipeline, GST_STATE_NULL);
gst_object_unref(app->pipeline);
app->pipeline = nullptr;
remove_recording(app);
}
}
void start_recording(App* app) {
if (app->recorder) { gtk_window_present(GTK_WINDOW(app->recorder)); return; }
if (app->config.local_url.empty() && (!app->config.hosted_first || app->config.hosted_url.empty())) {
open_settings(app);
show_error(GTK_WINDOW(app->settings), "Set an endpoint first.");
return;
}
gchar* template_path = g_build_filename(g_get_tmp_dir(), "transui-XXXXXX", nullptr);
int fd = g_mkstemp(template_path);
if (fd < 0) { show_error(nullptr, "Could not create a recording file."); g_free(template_path); return; }
close(fd);
app->recording_path = template_path;
g_free(template_path);
gchar* pipeline_text = g_strdup_printf("autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"%s\"", app->recording_path.c_str());
GError* error = nullptr;
app->pipeline = gst_parse_launch(pipeline_text, &error);
g_free(pipeline_text);
if (!app->pipeline) {
show_error(nullptr, error ? error->message : "Could not start recording.");
if (error) g_error_free(error);
remove_recording(app);
return;
}
if (gst_element_set_state(app->pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
show_error(nullptr, "Could not access an audio input.");
close_recorder(app);
remove_recording(app);
return;
}
GtkWidget* window = gtk_application_window_new(app->gtk_app);
app->recorder = window;
gtk_window_set_title(GTK_WINDOW(window), "Transui");
gtk_window_set_default_size(GTK_WINDOW(window), 330, 250);
gtk_window_set_resizable(GTK_WINDOW(window), false);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_container_add(GTK_CONTAINER(window), box);
GtkWidget* dot = gtk_label_new("●");
gtk_style_context_add_class(gtk_widget_get_style_context(dot), "record-dot");
gtk_box_pack_start(GTK_BOX(box), dot, false, false, 0);
GtkWidget* state = gtk_label_new("RECORDING");
gtk_style_context_add_class(gtk_widget_get_style_context(state), "record-state");
gtk_box_pack_start(GTK_BOX(box), state, false, false, 0);
app->elapsed_label = gtk_label_new("00:00");
gtk_style_context_add_class(gtk_widget_get_style_context(app->elapsed_label), "elapsed");
gtk_box_pack_start(GTK_BOX(box), app->elapsed_label, false, false, 0);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_pack_start(GTK_BOX(box), actions, false, false, 8);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
GtkWidget* stop = gtk_button_new_with_label("Stop and transcribe");
gtk_style_context_add_class(gtk_widget_get_style_context(stop), "suggested-action");
gtk_box_pack_start(GTK_BOX(actions), cancel, false, false, 0);
gtk_box_pack_start(GTK_BOX(actions), stop, false, false, 0);
app->elapsed_seconds = 0;
app->elapsed_source = g_timeout_add_seconds(1, tick_elapsed, app);
g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_recording), app);
g_signal_connect(stop, "clicked", G_CALLBACK(stop_recording), app);
g_signal_connect(window, "destroy", G_CALLBACK(recorder_closed), app);
gtk_widget_show_all(window);
}
void install_css() {
const char* css = R"CSS(
window { background-color: #111827; color: #edf1f7; }
entry, combobox button { background: #1e293b; color: #edf1f7; border: 1px solid #334155; border-radius: 5px; }
label { color: #cbd5e1; }
.record-dot { color: #fb7185; font-size: 30px; }
.record-state { color: #fb7185; font-weight: 800; letter-spacing: 2px; font-size: 12px; }
.elapsed { color: #f8fafc; font-family: monospace; font-size: 38px; font-weight: 700; }
.state-title { color: #f8fafc; font-size: 18px; font-weight: 700; }
button.suggested-action { background: #f97316; color: #111827; border-color: #f97316; font-weight: 700; }
button.suggested-action:hover { background: #fb923c; }
)CSS";
GtkCssProvider* provider = gtk_css_provider_new();
gtk_css_provider_load_from_data(provider, css, -1, nullptr);
gtk_style_context_add_provider_for_screen(gdk_screen_get_default(),
GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(provider);
}
void activate(GtkApplication*, gpointer data) { start_recording(static_cast<App*>(data)); }
} // namespace
int main(int argc, char** argv) {
const bool settings = argc == 2 && std::strcmp(argv[1], "settings") == 0;
if (argc > 1 && !settings && std::strcmp(argv[1], "record") != 0) {
std::fprintf(stderr, "Usage: transui [record|settings]\n");
return 2;
}
gst_init(&argc, &argv);
curl_global_init(CURL_GLOBAL_DEFAULT);
App app;
load_config(&app);
app.gtk_app = gtk_application_new(kAppId, G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app.gtk_app, "startup", G_CALLBACK(+[](GtkApplication*, gpointer) {
install_css();
}), nullptr);
if (settings) g_signal_connect_swapped(app.gtk_app, "activate", G_CALLBACK(open_settings), &app);
else g_signal_connect(app.gtk_app, "activate", G_CALLBACK(activate), &app);
const int result = g_application_run(G_APPLICATION(app.gtk_app), argc, argv);
if (app.pipeline) close_recorder(&app);
remove_recording(&app);
g_object_unref(app.gtk_app);
curl_global_cleanup();
return result;
}