mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-08-06 00:20:41 -07:00
overlay: OBS control window via WebSocket API (#773)
## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change Adds an overlay window widget for controlling OBS over WebSocket. Add new lines to the FPS widget that shows recording / streaming timers. Show notifications for major state changes (stream started/stopped, recording started/paused/resumed/stopped) ## Testing
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
#include "obs.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
|
||||
#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<milliseconds>(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<long long>(hours),
|
||||
static_cast<long long>(minutes),
|
||||
static_cast<long long>(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<std::mutex> 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<std::mutex> 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<int64_t>)(base_ms, 0);
|
||||
}
|
||||
const int64_t now =
|
||||
duration_cast<milliseconds>(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<int64_t>)(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user