#include "obs.h" #include #include #include #include "external/imgui/imgui.h" #include "games/io.h" #include "overlay/overlay.h" #include "overlay/imgui/extensions.h" using namespace std::chrono; // OBS WebSocket protocol/worker thread lives in obs_websocket.cpp; this file // owns the ImGui control window and the connection lifecycle. namespace { // status text colors const ImVec4 COL_GREEN(0.40f, 0.85f, 0.40f, 1.0f); const ImVec4 COL_RED(0.90f, 0.30f, 0.30f, 1.0f); const ImVec4 COL_YELLOW(0.95f, 0.80f, 0.30f, 1.0f); const ImVec4 COL_GREY(0.60f, 0.60f, 0.60f, 1.0f); // muted action-button fills (start = green, stop = red, pause = yellow); the // hovered/active shades are derived by brightening the base const ImVec4 COL_BTN_GREEN(0.20f, 0.45f, 0.24f, 1.0f); const ImVec4 COL_BTN_RED(0.52f, 0.20f, 0.20f, 1.0f); const ImVec4 COL_BTN_YELLOW(0.52f, 0.42f, 0.16f, 1.0f); // an in-flight request lingers for at most this long before the button frees // itself, so a dropped state event can never wedge a control permanently const int64_t PENDING_TIMEOUT_MS = 5000; int64_t now_tick_ms() { return duration_cast(steady_clock::now().time_since_epoch()).count(); } std::string format_duration(int64_t ms) { if (ms < 0) { ms = 0; } const int64_t total_seconds = ms / 1000; const int64_t hours = total_seconds / 3600; const int64_t minutes = (total_seconds % 3600) / 60; const int64_t seconds = total_seconds % 60; char buf[16]; snprintf(buf, sizeof(buf), "%02lld:%02lld:%02lld", static_cast(hours), static_cast(minutes), static_cast(seconds)); return buf; } } namespace overlay::windows { OBSControl::OBSControl(SpiceOverlay *overlay) : Window(overlay) { this->title = "OBS Control"; this->flags |= ImGuiWindowFlags_AlwaysAutoResize; this->init_pos = overlay::apply_scaling_to_vector(120, 120); this->toggle_button = games::OverlayButtons::ToggleOBSControl; this->worker_running.store(true); this->worker_thread = std::thread(&OBSControl::worker_main, this); } OBSControl::~OBSControl() { // signal stop and wake any in-progress interruptible_sleep at once; the // lock around the store pairs with the wait predicate to avoid a lost wakeup { std::lock_guard lock(this->worker_mutex); this->worker_running.store(false); } this->worker_cv.notify_all(); if (this->worker_thread.joinable()) { // note: if the worker is mid-connect, WebSocket::from_url performs a // blocking getaddrinfo/connect that does not observe worker_running, // so this join can stall for the OS connect timeout. the default // 127.0.0.1 host fails fast (connection refused); only a misconfigured // unreachable remote OBS_CONTROL_HOST would delay shutdown here. this->worker_thread.join(); } } OBSStatus OBSControl::get_status() { std::lock_guard lock(this->status_mutex); return this->status; } int64_t OBSControl::live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking) { if (!ticking) { return (std::max)(base_ms, 0); } const int64_t now = duration_cast(steady_clock::now().time_since_epoch()).count(); // clamp so a stale base tick / clock hiccup can never yield a negative // duration; callers (FPS rows, build_content) format this directly return (std::max)(base_ms + (now - base_tick), 0); } void OBSControl::build_content() { const OBSStatus s = this->get_status(); // label + colored value on a single line const auto status_line = [](const char *label, const ImVec4 &col, const char *value) { ImGui::Text("%s", label); ImGui::SameLine(); ImGui::TextColored(col, "%s", value); }; if (!s.connected) { if (s.disabled) { ImGui::TextColored(COL_GREY, "%s", "OBS Control is disabled"); return; } if (s.identifying) { status_line("OBS WebSocket:", COL_YELLOW, "Connecting..."); } else { status_line("OBS WebSocket:", COL_GREY, "Not connected"); } const std::string url = "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT); status_line("Address:", COL_GREY, url.c_str()); if (!s.connection_error.empty()) { ImGui::TextColored(COL_RED, "%s", s.connection_error.c_str()); } return; } status_line("OBS WebSocket:", COL_GREEN, "Connected"); // one fixed content width drives the whole panel so it never resizes as // the scene name or button labels change; every row is sized to fit it const float spacing = ImGui::GetStyle().ItemSpacing.x; const float row_w = overlay::apply_scaling(240); if (s.current_scene.empty()) { status_line("Scene:", COL_GREY, "(unknown)"); } else { ImGui::Text("Scene:"); ImGui::SameLine(); // truncate to the remaining row width so "Scene:" + value together // never overflow and push the window wider const float label_w = ImGui::CalcTextSize("Scene:").x; ImGui::PushStyleColor(ImGuiCol_Text, COL_GREY); ImGui::TextTruncated(s.current_scene, row_w - label_w - spacing); ImGui::PopStyleColor(); } ImGui::Separator(); const int64_t now = now_tick_ms(); // every button shares one fixed size; two side-by-side fill the row width, // single buttons keep that same size rather than stretching to fill const ImVec2 btn((row_w - spacing) * 0.5f, 0); // has OBS reached the state a pending action was waiting for? const auto reached = [&](OBSAction a) { switch (a) { case OBSAction::StreamStart: return s.streaming; case OBSAction::StreamStop: return !s.streaming; case OBSAction::RecordStart: return s.recording; case OBSAction::RecordStop: return !s.recording; case OBSAction::RecordPause: return s.record_paused; case OBSAction::RecordResume: return !s.record_paused; default: return true; } }; // drop a pending action once OBS confirms the new state, or once the // safety deadline lapses (so a dropped event can't wedge the button) const auto settle = [&](OBSAction &slot, int64_t deadline) { if (slot != OBSAction::None && (reached(slot) || now >= deadline)) { slot = OBSAction::None; } }; settle(this->stream_pending, this->stream_pending_deadline); settle(this->record_pending, this->record_pending_deadline); // a colored button that fires a request and marks the output busy on click const auto action_button = [&](const char *label, const ImVec4 &color, const char *request, OBSAction &slot, int64_t &deadline, OBSAction action) { if (ImGui::ColoredButton(label, color, btn)) { enqueue_request(request); slot = action; deadline = now + PENDING_TIMEOUT_MS; } }; // streaming { const bool pending = this->stream_pending != OBSAction::None; if (s.streaming) { const int64_t ms = live_duration_ms( s.stream_duration_ms, s.stream_duration_base_tick, true); status_line("Streaming:", COL_RED, ("LIVE " + format_duration(ms)).c_str()); } else { status_line("Streaming:", COL_GREY, pending ? "Starting..." : "Idle"); } ImGui::BeginDisabled(pending); if (s.streaming) { action_button( pending ? "Stopping...##stream" : "Stop Streaming##stream", COL_BTN_RED, "StopStream", this->stream_pending, this->stream_pending_deadline, OBSAction::StreamStop); } else { action_button( pending ? "Starting...##stream" : "Start Streaming##stream", COL_BTN_GREEN, "StartStream", this->stream_pending, this->stream_pending_deadline, OBSAction::StreamStart); } ImGui::EndDisabled(); } ImGui::Separator(); // recording { const bool pending = this->record_pending != OBSAction::None; if (!s.recording) { status_line("Recording:", COL_GREY, pending ? "Starting..." : "Idle"); ImGui::BeginDisabled(pending); action_button( pending ? "Starting...##record" : "Start Recording##record", COL_BTN_GREEN, "StartRecord", this->record_pending, this->record_pending_deadline, OBSAction::RecordStart); ImGui::EndDisabled(); return; } const int64_t ms = live_duration_ms( s.record_duration_ms, s.record_duration_base_tick, !s.record_paused); if (s.record_paused) { status_line("Recording:", COL_YELLOW, ("PAUSED " + format_duration(ms)).c_str()); } else { status_line("Recording:", COL_RED, ("REC " + format_duration(ms)).c_str()); } ImGui::BeginDisabled(pending); action_button( this->record_pending == OBSAction::RecordStop ? "Stopping...##record" : "Stop Recording##record", COL_BTN_RED, "StopRecord", this->record_pending, this->record_pending_deadline, OBSAction::RecordStop); ImGui::SameLine(); if (s.record_paused) { action_button( this->record_pending == OBSAction::RecordResume ? "Resuming...##record_toggle" : "Resume##record_toggle", COL_BTN_GREEN, "ResumeRecord", this->record_pending, this->record_pending_deadline, OBSAction::RecordResume); } else { action_button( this->record_pending == OBSAction::RecordPause ? "Pausing...##record_toggle" : "Pause##record_toggle", COL_BTN_YELLOW, "PauseRecord", this->record_pending, this->record_pending_deadline, OBSAction::RecordPause); } ImGui::EndDisabled(); } } }